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.
Files changed (61) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/CONTRIBUTING.md +36 -0
  3. package/README.en.md +60 -23
  4. package/README.md +54 -21
  5. package/SECURITY.md +20 -0
  6. package/cordis.patch.yml +32 -0
  7. package/docs/README.md +17 -0
  8. package/docs/architecture/overview.md +70 -0
  9. package/docs/examples/first-mission.md +58 -0
  10. package/docs/faq.md +74 -0
  11. package/docs/guide/hot-reload.md +189 -0
  12. package/docs/guide/install-upgrade-migration.md +59 -0
  13. package/docs/guide/quickstart.md +97 -0
  14. package/docs/operations/runbook.md +80 -0
  15. package/docs/operations/security.md +62 -0
  16. package/docs/reference/blockers.md +58 -0
  17. package/docs/reference/config.md +114 -0
  18. package/docs/reference/doctor.md +31 -0
  19. package/docs/reference/errors.md +50 -0
  20. package/docs/reference/events.md +45 -0
  21. package/docs/reference/operations.md +52 -0
  22. package/docs/reference/testing.md +61 -0
  23. package/docs/reference/tools.md +75 -0
  24. package/docs/zh/README.md +58 -0
  25. package/docs/zh/faq.md +72 -0
  26. package/docs/zh/guide/hot-reload.md +178 -0
  27. package/docs/zh/guide/install-upgrade-migration.md +57 -0
  28. package/docs/zh/guide/quickstart.md +96 -0
  29. package/docs/zh/operations/security.md +42 -0
  30. package/docs/zh/reference/blockers.md +62 -0
  31. package/docs/zh/reference/config.md +113 -0
  32. package/docs/zh/reference/doctor.md +30 -0
  33. package/docs/zh/reference/errors.md +52 -0
  34. package/docs/zh/reference/events.md +46 -0
  35. package/docs/zh/reference/operations.md +48 -0
  36. package/docs/zh/reference/testing.md +60 -0
  37. package/docs/zh/reference/tools.md +75 -0
  38. package/index.js +293 -49
  39. package/lib/batch.js +578 -0
  40. package/lib/config.js +381 -0
  41. package/lib/doctor.js +165 -0
  42. package/lib/errors.js +111 -0
  43. package/lib/hmr.js +196 -0
  44. package/lib/leases.js +317 -0
  45. package/lib/legacy.js +413 -0
  46. package/lib/modeStore.js +47 -16
  47. package/lib/store.js +24 -1
  48. package/lib/tests.js +628 -0
  49. package/lib/tools.js +1594 -160
  50. package/lib/tower.js +1025 -101
  51. package/lib/workspaceLock.js +117 -0
  52. package/lib/worktreeOwnership.js +166 -0
  53. package/package.json +21 -4
  54. package/reviewer.js +40 -0
  55. package/scripts/check-docs.mjs +135 -0
  56. package/scripts/enable-dev-hmr.mjs +347 -0
  57. package/skills/tower-recovery.md +40 -0
  58. package/skills/tower-review.md +16 -0
  59. package/skills/tower-worker-protocol.md +15 -0
  60. package/skills.js +63 -0
  61. package/worker.js +7 -4
package/lib/legacy.js ADDED
@@ -0,0 +1,413 @@
1
+ // Read-only legacy snapshot support.
2
+ //
3
+ // Validates, and stores an immutable copy of, a Tower Codex (schemaVersion: 1)
4
+ // or DSH `state.json` payload under `<common>/dsh-tower/legacy/<sha256>.json`.
5
+ // Loading a snapshot never activates, adopts, or resumes anything: `paused`
6
+ // missions stay paused, no mission/worktree/state identity is created, and this
7
+ // module never writes `state.json` — the caller records the returned snapshot
8
+ // summary in `state.legacySnapshots` inside its own lock.
9
+ //
10
+ // `validateLegacyMap` is the audit-only mapping check: a map is a record that
11
+ // legacy id X corresponds to DSH mission Y. It never adopts or restores work.
12
+ import fsp from 'node:fs/promises'
13
+ import path from 'node:path'
14
+ import crypto from 'node:crypto'
15
+
16
+ const SCHEMA_VERSION = 1
17
+ const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024
18
+ const DSH_STATUSES = ['planned', 'active', 'completed', 'blocked', 'paused', 'merged', 'abandoned']
19
+ // Tower Codex also retires missions; retired is read-only history here.
20
+ const CODEX_STATUSES = [...DSH_STATUSES, 'retired']
21
+ const DSH_ID_RE = /^M\d+$/
22
+ const CODEX_ID_RE = /^T\d{4,}$/
23
+ const SHA256_RE = /^[a-f0-9]{64}$/
24
+
25
+ function fail(code, message) {
26
+ const error = new Error(message)
27
+ error.code = code
28
+ throw error
29
+ }
30
+
31
+ function isPlainObject(value) {
32
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
33
+ }
34
+
35
+ function isNonEmptyString(value) {
36
+ return typeof value === 'string' && value.trim().length > 0
37
+ }
38
+
39
+ function sha256(buffer) {
40
+ return crypto.createHash('sha256').update(buffer).digest('hex')
41
+ }
42
+
43
+ function win32Lower(value) {
44
+ return process.platform === 'win32' ? value.toLowerCase() : value
45
+ }
46
+
47
+ function isInside(parent, child) {
48
+ const relative = path.relative(win32Lower(path.resolve(parent)), win32Lower(path.resolve(child)))
49
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
50
+ }
51
+
52
+ async function realpathOrResolve(target) {
53
+ try {
54
+ return await fsp.realpath(target)
55
+ } catch (error) {
56
+ if (error?.code === 'ENOENT') return path.resolve(target)
57
+ throw error
58
+ }
59
+ }
60
+
61
+ function invalidResult(error, format = null) {
62
+ return {
63
+ ok: false,
64
+ format,
65
+ schemaVersion: null,
66
+ missions: [],
67
+ missionIds: [],
68
+ missionCount: 0,
69
+ paused: [],
70
+ events: null,
71
+ legacySnapshots: null,
72
+ repoRoot: null,
73
+ baseBranch: null,
74
+ error,
75
+ }
76
+ }
77
+
78
+ function validateScope(scope, id) {
79
+ if (!Array.isArray(scope)) return `mission ${id} has no scope array`
80
+ for (const entry of scope) {
81
+ if (typeof entry !== 'string' || entry.trim().length === 0) return `mission ${id} scope entries must be non-empty strings`
82
+ const value = entry.replace(/\\/g, '/').trim()
83
+ if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) return `mission ${id} scope must be relative: ${entry}`
84
+ if (value.split('/').some((part) => part === '..')) return `mission ${id} scope must not contain "..": ${entry}`
85
+ }
86
+ return null
87
+ }
88
+
89
+ function validateTasks(tasks, id) {
90
+ if (!Array.isArray(tasks)) return `mission ${id} has no tasks array`
91
+ for (const entry of tasks) {
92
+ if (typeof entry === 'string') {
93
+ if (entry.trim().length === 0) return `mission ${id} task entries must be non-empty strings`
94
+ continue
95
+ }
96
+ if (!isPlainObject(entry) || !isNonEmptyString(entry.text)) {
97
+ return `mission ${id} task entries must be strings or objects with a text field`
98
+ }
99
+ if (entry.status !== undefined && typeof entry.status !== 'string') {
100
+ return `mission ${id} task status must be a string`
101
+ }
102
+ }
103
+ return null
104
+ }
105
+
106
+ function validateDeps(deps, id) {
107
+ if (!Array.isArray(deps)) return `mission ${id} has no deps array`
108
+ for (const entry of deps) {
109
+ if (typeof entry !== 'string' || entry.trim().length === 0) {
110
+ return `mission ${id} deps entries must be non-empty mission ids`
111
+ }
112
+ }
113
+ return null
114
+ }
115
+
116
+ function validateReviews(reviews, id) {
117
+ if (!Array.isArray(reviews)) return `mission ${id} has no reviews array`
118
+ for (const entry of reviews) {
119
+ if (!isPlainObject(entry)) return `mission ${id} review entries must be objects`
120
+ }
121
+ return null
122
+ }
123
+
124
+ function validateMissions(payload, format) {
125
+ const statuses = format === 'tower-codex' ? CODEX_STATUSES : DSH_STATUSES
126
+ const idRe = format === 'tower-codex' ? CODEX_ID_RE : DSH_ID_RE
127
+ const missions = []
128
+ const missionIds = []
129
+ const paused = []
130
+ const seen = new Set()
131
+ for (let index = 0; index < payload.missions.length; index += 1) {
132
+ const mission = payload.missions[index]
133
+ if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object` }
134
+ if (typeof mission.id !== 'string' || !idRe.test(mission.id)) {
135
+ return { error: `missions[${index}] has unsupported id ${JSON.stringify(mission.id)} (expected ${format === 'tower-codex' ? 'T####' : 'M#'})` }
136
+ }
137
+ if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}` }
138
+ seen.add(mission.id)
139
+ if (typeof mission.status !== 'string' || !statuses.includes(mission.status)) {
140
+ return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}` }
141
+ }
142
+ if (mission.kind !== undefined && mission.kind !== 'build' && mission.kind !== 'survey') {
143
+ return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}` }
144
+ }
145
+ for (const problem of [
146
+ validateScope(mission.scope, mission.id),
147
+ validateTasks(mission.tasks, mission.id),
148
+ validateDeps(mission.deps, mission.id),
149
+ validateReviews(mission.reviews, mission.id),
150
+ ]) {
151
+ if (problem) return { error: problem }
152
+ }
153
+ missions.push({ id: mission.id, status: mission.status, kind: mission.kind ?? 'build' })
154
+ missionIds.push(mission.id)
155
+ if (mission.status === 'paused') paused.push(mission.id)
156
+ }
157
+ return { missions, missionIds, paused, error: null }
158
+ }
159
+
160
+ /**
161
+ * Validate a legacy payload without touching the filesystem or the Tower state.
162
+ * Supports a Tower Codex `schemaVersion: 1` state (repoRoot/baseBranch/
163
+ * missions/events/legacySnapshots with T#### missions) and a DSH `state.json`
164
+ * (repoRoot/missions with M# missions).
165
+ *
166
+ * @returns {{ok:boolean, format:'tower-codex'|'dsh'|null, schemaVersion:number|null,
167
+ * missions:Array<{id:string,status:string,kind:string}>, missionIds:string[],
168
+ * missionCount:number, paused:string[], events:number|null,
169
+ * legacySnapshots:number|null, repoRoot:string|null, baseBranch:string|null,
170
+ * error:string|null}}
171
+ */
172
+ export function validateLegacySnapshot(payload) {
173
+ if (!isPlainObject(payload)) return invalidResult('legacy snapshot must be a JSON object')
174
+ if (payload.schemaVersion !== SCHEMA_VERSION) {
175
+ return invalidResult(
176
+ payload.schemaVersion === undefined
177
+ ? 'legacy snapshot has no schemaVersion (expected 1)'
178
+ : `unsupported legacy schemaVersion ${JSON.stringify(payload.schemaVersion)} (expected 1)`,
179
+ )
180
+ }
181
+ if (!Array.isArray(payload.missions)) return invalidResult('legacy snapshot has no missions array')
182
+
183
+ let format
184
+ if (payload.missions.length === 0) {
185
+ format = isNonEmptyString(payload.baseBranch) ? 'tower-codex' : 'dsh'
186
+ } else {
187
+ const firstId = payload.missions[0]?.id
188
+ if (typeof firstId === 'string' && CODEX_ID_RE.test(firstId)) format = 'tower-codex'
189
+ else if (typeof firstId === 'string' && DSH_ID_RE.test(firstId)) format = 'dsh'
190
+ else {
191
+ return invalidResult(
192
+ `unsupported legacy mission id ${JSON.stringify(firstId)} (expected T#### for Tower Codex or M# for DSH)`,
193
+ )
194
+ }
195
+ }
196
+
197
+ const rejected = (error) => invalidResult(error, format)
198
+ const validated = validateMissions(payload, format)
199
+ if (validated.error) return rejected(validated.error)
200
+
201
+ if (isNonEmptyString(payload.repoRoot) === false) {
202
+ return rejected(`legacy ${format} snapshot has no repoRoot string`)
203
+ }
204
+ if (format === 'tower-codex' && isNonEmptyString(payload.baseBranch) === false) {
205
+ return rejected('legacy tower-codex snapshot has no baseBranch string')
206
+ }
207
+ if (payload.events !== undefined && !Array.isArray(payload.events)) {
208
+ return rejected('legacy snapshot events must be an array')
209
+ }
210
+ if (payload.legacySnapshots !== undefined && !Array.isArray(payload.legacySnapshots)) {
211
+ return rejected('legacy snapshot legacySnapshots must be an array')
212
+ }
213
+ if (payload.revision !== undefined && (!Number.isInteger(payload.revision) || payload.revision < 0)) {
214
+ return rejected('legacy snapshot revision must be a non-negative integer')
215
+ }
216
+
217
+ return {
218
+ ok: true,
219
+ format,
220
+ schemaVersion: SCHEMA_VERSION,
221
+ missions: validated.missions,
222
+ missionIds: validated.missionIds,
223
+ missionCount: validated.missionIds.length,
224
+ paused: validated.paused,
225
+ events: Array.isArray(payload.events) ? payload.events.length : null,
226
+ legacySnapshots: Array.isArray(payload.legacySnapshots) ? payload.legacySnapshots.length : null,
227
+ repoRoot: payload.repoRoot.trim(),
228
+ baseBranch: format === 'tower-codex' ? payload.baseBranch.trim() : (isNonEmptyString(payload.base) ? payload.base.trim() : null),
229
+ error: null,
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Read a legacy snapshot, validate it, and store the exact payload bytes as
235
+ * `<common>/dsh-tower/legacy/<sha256>.json`. The import is idempotent for the
236
+ * same bytes; it never writes state.json, never activates/adopts a mission, and
237
+ * `paused` stays paused in the returned summary.
238
+ *
239
+ * @returns {{summary:object, snapshot:object}} where `snapshot` is the record
240
+ * the caller may append to `state.legacySnapshots`.
241
+ */
242
+ export async function importLegacySnapshot(input = {}) {
243
+ const repoRoot = input?.repoRoot
244
+ const commonDir = input?.commonDir
245
+ const sourcePath = input?.sourcePath
246
+
247
+ if (!isNonEmptyString(repoRoot)) fail('invalid-argument', 'repoRoot is required')
248
+ if (!path.isAbsolute(repoRoot)) fail('invalid-argument', `repoRoot must be an absolute path: ${repoRoot}`)
249
+ if (!isNonEmptyString(commonDir)) fail('invalid-argument', 'commonDir is required')
250
+ if (!path.isAbsolute(commonDir)) fail('invalid-argument', `commonDir must be an absolute path: ${commonDir}`)
251
+ if (!isNonEmptyString(sourcePath)) fail('invalid-argument', 'sourcePath is required')
252
+ if (!path.isAbsolute(sourcePath)) fail('invalid-argument', `sourcePath must be an absolute path: ${sourcePath}`)
253
+
254
+ const resolvedSource = path.resolve(sourcePath)
255
+ let sourceStat
256
+ try {
257
+ sourceStat = await fsp.stat(resolvedSource)
258
+ } catch (error) {
259
+ if (error?.code === 'ENOENT') fail('invalid-argument', `sourcePath does not exist: ${resolvedSource}`)
260
+ throw error
261
+ }
262
+ if (!sourceStat.isFile()) fail('invalid-argument', `sourcePath is not a regular file: ${resolvedSource}`)
263
+
264
+ const stateDir = path.join(path.resolve(commonDir), 'dsh-tower')
265
+ const stateReal = await realpathOrResolve(stateDir)
266
+ const sourceReal = await realpathOrResolve(resolvedSource)
267
+ if (isInside(stateReal, sourceReal)) {
268
+ fail('legacy-invalid', `sourcePath must not be inside the Tower state directory: ${stateReal}`)
269
+ }
270
+
271
+ const buffer = await fsp.readFile(sourceReal)
272
+ if (buffer.length > MAX_SNAPSHOT_BYTES) {
273
+ fail(
274
+ 'legacy-invalid',
275
+ `legacy snapshot is too large: ${buffer.length} bytes (max ${MAX_SNAPSHOT_BYTES})`,
276
+ )
277
+ }
278
+ let text = buffer.toString('utf8')
279
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1)
280
+ let payload
281
+ try {
282
+ payload = JSON.parse(text)
283
+ } catch (error) {
284
+ fail('legacy-invalid', `invalid legacy JSON at ${resolvedSource}: ${error.message}`)
285
+ }
286
+ const validation = validateLegacySnapshot(payload)
287
+ if (!validation.ok) fail('legacy-invalid', `${resolvedSource}: ${validation.error}`)
288
+
289
+ const sourceHash = sha256(buffer)
290
+ const legacyDir = path.join(stateDir, 'legacy')
291
+ const storedPath = path.join(legacyDir, `${sourceHash}.json`)
292
+ await fsp.mkdir(legacyDir, { recursive: true })
293
+
294
+ let idempotent = false
295
+ try {
296
+ const handle = await fsp.open(storedPath, 'wx')
297
+ try {
298
+ await handle.writeFile(buffer)
299
+ await handle.sync()
300
+ } finally {
301
+ await handle.close()
302
+ }
303
+ } catch (error) {
304
+ if (error?.code !== 'EEXIST') throw error
305
+ const existing = await fsp.readFile(storedPath).catch(() => null)
306
+ if (!existing || sha256(existing) !== sourceHash) {
307
+ fail('legacy-invalid', `legacy snapshot path exists with different contents: ${storedPath}`)
308
+ }
309
+ idempotent = true
310
+ }
311
+
312
+ const importedAt = Date.now()
313
+ const summary = {
314
+ format: validation.format,
315
+ schemaVersion: SCHEMA_VERSION,
316
+ missionCount: validation.missionCount,
317
+ missionIds: validation.missionIds,
318
+ paused: validation.paused,
319
+ pausedCount: validation.paused.length,
320
+ events: validation.events,
321
+ legacySnapshots: validation.legacySnapshots,
322
+ sourcePath: resolvedSource,
323
+ sourceHash,
324
+ storedPath,
325
+ bytes: buffer.length,
326
+ idempotent,
327
+ importedAt,
328
+ }
329
+ const snapshot = {
330
+ sourceHash,
331
+ sourcePath: resolvedSource,
332
+ storedPath,
333
+ format: validation.format,
334
+ schemaVersion: SCHEMA_VERSION,
335
+ repoRoot: validation.repoRoot,
336
+ baseBranch: validation.baseBranch,
337
+ missionIds: validation.missionIds,
338
+ missionCount: validation.missionCount,
339
+ pausedMissionIds: validation.paused,
340
+ bytes: buffer.length,
341
+ importedAt,
342
+ }
343
+ return { summary, snapshot }
344
+ }
345
+
346
+ /**
347
+ * Audit-only validation for a legacy-id → DSH-mission mapping. The caller
348
+ * records `{sourceHash, oldId, newMissionId}` (plus its own timestamp) in
349
+ * `state.legacyMaps`; mapping never adopts, restores, or resumes anything.
350
+ *
351
+ * @returns {{ok:true, duplicate:boolean, map:{sourceHash:string, oldId:string,
352
+ * newMissionId:string|null}}}
353
+ */
354
+ export function validateLegacyMap(state, input = {}) {
355
+ if (!isPlainObject(state)) fail('invalid-argument', 'state is required')
356
+ const sourceHash = input?.sourceHash
357
+ const oldId = input?.oldId
358
+ const rawNewMissionId = input?.newMissionId
359
+
360
+ if (!isNonEmptyString(sourceHash)) fail('invalid-argument', 'sourceHash is required')
361
+ if (!SHA256_RE.test(sourceHash)) {
362
+ fail('invalid-argument', `sourceHash must be a 64-character hex SHA256: ${sourceHash}`)
363
+ }
364
+ if (!isNonEmptyString(oldId)) fail('invalid-argument', 'oldId is required')
365
+ if (rawNewMissionId !== undefined && rawNewMissionId !== null && rawNewMissionId !== '' && typeof rawNewMissionId !== 'string') {
366
+ fail('invalid-argument', `newMissionId must be a mission id string: ${JSON.stringify(rawNewMissionId)}`)
367
+ }
368
+ const newMissionId = isNonEmptyString(rawNewMissionId) ? rawNewMissionId.trim() : null
369
+
370
+ const snapshots = Array.isArray(state.legacySnapshots) ? state.legacySnapshots : []
371
+ const snapshot = snapshots.find((entry) => {
372
+ if (!isPlainObject(entry)) return false
373
+ const hash = entry.sourceHash ?? entry.hash ?? entry.sha256
374
+ return hash === sourceHash
375
+ })
376
+ if (!snapshot) {
377
+ fail('legacy-invalid', `unknown legacy snapshot ${sourceHash.slice(0, 12)}; import it with tower_import_legacy first`)
378
+ }
379
+
380
+ const snapshotIds = Array.isArray(snapshot.missionIds)
381
+ ? snapshot.missionIds.filter((id) => typeof id === 'string')
382
+ : Array.isArray(snapshot.missions)
383
+ ? snapshot.missions.map((mission) => (typeof mission === 'string' ? mission : mission?.id)).filter((id) => typeof id === 'string')
384
+ : []
385
+ const missionIds = (Array.isArray(state.missions) ? state.missions : [])
386
+ .map((mission) => mission?.id)
387
+ .filter((id) => typeof id === 'string')
388
+
389
+ if (!snapshotIds.includes(oldId) && !missionIds.includes(oldId)) {
390
+ fail(
391
+ 'legacy-invalid',
392
+ `legacy id ${oldId} is not in snapshot ${sourceHash.slice(0, 12)} and is not a known mission`,
393
+ )
394
+ }
395
+ if (newMissionId !== null && !missionIds.includes(newMissionId)) {
396
+ fail('legacy-invalid', `unknown new mission "${newMissionId}"; plan it before mapping`)
397
+ }
398
+
399
+ const maps = Array.isArray(state.legacyMaps) ? state.legacyMaps : []
400
+ const existing = maps.find((entry) => isPlainObject(entry) && entry.sourceHash === sourceHash && entry.oldId === oldId)
401
+ if (existing) {
402
+ const existingTarget = existing.newMissionId ?? null
403
+ if (existingTarget !== newMissionId) {
404
+ fail(
405
+ 'legacy-invalid',
406
+ `legacy map for ${oldId} already points to ${existingTarget ?? 'no mission'}; mappings are append-only and must not be rewritten`,
407
+ )
408
+ }
409
+ return { ok: true, duplicate: true, map: existing }
410
+ }
411
+
412
+ return { ok: true, duplicate: false, map: { sourceHash, oldId, newMissionId } }
413
+ }
package/lib/modeStore.js CHANGED
@@ -1,39 +1,57 @@
1
1
  // Tower mode persistence.
2
2
  //
3
3
  // The session-projection route needs zod schemas, which this bundle cannot
4
- // resolve from the DSH installation; a small atomic JSON store under
5
- // DSH_HOME/tower-mode keeps the mode across restarts without adding a
6
- // dependency or touching profile state.
4
+ // resolve from the DSH installation on every deployment; a small atomic JSON
5
+ // store under DSH_HOME/tower-mode keeps the mode across restarts.
7
6
  import fs from 'node:fs'
8
7
  import os from 'node:os'
9
8
  import path from 'node:path'
10
9
 
11
- const MAX_ENTRIES = 200
10
+ const DEFAULT_MAX_ENTRIES = 200
12
11
 
13
- function storeDir() {
12
+ function sleepSync(ms) {
13
+ try {
14
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
15
+ } catch {
16
+ // best effort; a busy loop would be worse
17
+ }
18
+ }
19
+
20
+ function defaultStoreDir() {
14
21
  const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
15
22
  return path.join(home, 'tower-mode')
16
23
  }
17
24
 
18
- function storeFile() {
19
- return path.join(storeDir(), 'modes.json')
25
+ function storeDir(baseDir) {
26
+ return baseDir && String(baseDir).trim() ? path.resolve(String(baseDir)) : defaultStoreDir()
27
+ }
28
+
29
+ function storeFile(baseDir) {
30
+ return path.join(storeDir(baseDir), 'modes.json')
20
31
  }
21
32
 
22
33
  /** Read persisted modes into a Map keyed by agent/session id. */
23
- export function loadModes() {
34
+ export function loadModes(baseDir) {
24
35
  const modes = new Map()
25
36
  try {
26
- const raw = fs.readFileSync(storeFile(), 'utf8')
37
+ const raw = fs.readFileSync(storeFile(baseDir), 'utf8')
27
38
  const parsed = JSON.parse(raw)
28
39
  const entries = parsed && typeof parsed === 'object' && parsed.modes && typeof parsed.modes === 'object'
29
40
  ? parsed.modes
30
41
  : {}
31
42
  for (const [key, value] of Object.entries(entries)) {
32
43
  if (value && value.on === true) {
44
+ const lock = value.lock && typeof value.lock === 'object'
45
+ && typeof value.lock.token === 'string'
46
+ && typeof value.lock.commonDir === 'string'
47
+ ? { token: value.lock.token, commonDir: value.lock.commonDir }
48
+ : null
33
49
  modes.set(key, {
34
50
  on: true,
35
51
  pausedGoal: value.pausedGoal && typeof value.pausedGoal === 'object' ? value.pausedGoal : null,
36
52
  lastChange: value.lastChange && typeof value.lastChange === 'object' ? value.lastChange : null,
53
+ lock,
54
+ baseBranch: typeof value.baseBranch === 'string' && value.baseBranch.trim() ? value.baseBranch.trim() : null,
37
55
  at: Number(value.at) || 0,
38
56
  })
39
57
  }
@@ -45,24 +63,37 @@ export function loadModes() {
45
63
  }
46
64
 
47
65
  /** Persist the current modes atomically; failures never break the command. */
48
- export function saveModes(modes) {
66
+ export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES) {
49
67
  try {
50
- const dir = storeDir()
68
+ const dir = storeDir(baseDir)
51
69
  fs.mkdirSync(dir, { recursive: true })
70
+ const limit = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES
52
71
  const entries = [...modes.entries()]
53
72
  .sort((a, b) => (Number(b[1]?.at) || 0) - (Number(a[1]?.at) || 0))
54
- .slice(0, MAX_ENTRIES)
73
+ .slice(0, limit)
55
74
  const payload = { version: 1, modes: Object.fromEntries(entries) }
56
- const file = storeFile()
75
+ const file = storeFile(baseDir)
57
76
  const tmp = `${file}.tmp-${process.pid}`
58
77
  fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
59
- fs.renameSync(tmp, file)
78
+ let lastError = null
79
+ for (let attempt = 0; attempt < 5; attempt += 1) {
80
+ try {
81
+ fs.renameSync(tmp, file)
82
+ return true
83
+ } catch (error) {
84
+ lastError = error
85
+ if (!['EPERM', 'EBUSY', 'EACCES'].includes(error?.code)) break
86
+ sleepSync(10 * (attempt + 1))
87
+ }
88
+ }
89
+ try { fs.unlinkSync(tmp) } catch {}
90
+ if (lastError) return false
60
91
  return true
61
92
  } catch {
62
93
  return false
63
94
  }
64
95
  }
65
96
 
66
- export function storePath() {
67
- return storeFile()
97
+ export function storePath(baseDir) {
98
+ return storeFile(baseDir)
68
99
  }
package/lib/store.js CHANGED
@@ -45,9 +45,32 @@ export async function writeJsonAtomic(file, value) {
45
45
  } finally {
46
46
  await handle.close()
47
47
  }
48
- await fsp.rename(tmp, file)
48
+ await renameWithRetry(tmp, file)
49
49
  }
50
50
 
51
+ /**
52
+ * Windows can reject a rename while an indexer, antivirus, or a concurrent
53
+ * reader still holds the destination; retry briefly instead of failing a
54
+ * successful mutation. The temp file is removed when the rename never lands.
55
+ */
56
+ async function renameWithRetry(tmp, file) {
57
+ for (let attempt = 0; ; attempt += 1) {
58
+ try {
59
+ await fsp.rename(tmp, file)
60
+ return
61
+ } catch (error) {
62
+ const transient = error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES'
63
+ if (!transient || attempt >= 12) {
64
+ await fsp.unlink(tmp).catch(() => {})
65
+ throw error
66
+ }
67
+ await sleep(10 + attempt * 15)
68
+ }
69
+ }
70
+ }
71
+
72
+ export { renameWithRetry }
73
+
51
74
  export async function appendJsonl(file, value) {
52
75
  await fsp.appendFile(file, `${JSON.stringify(value)}\n`, 'utf8')
53
76
  }