@searls/turbocommit 0.14.1 → 0.15.1
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/README.md +59 -14
- package/cli.js +250 -6
- package/lib/agent.js +10 -6
- package/lib/git.js +161 -7
- package/lib/harness.js +2 -0
- package/lib/install.js +21 -6
- package/lib/multi.js +106 -30
- package/lib/rescue.js +308 -0
- package/lib/run.js +215 -35
- package/lib/session.js +40 -1
- package/lib/track.js +849 -27
- package/package.json +1 -1
package/lib/track.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
const crypto = require('crypto')
|
|
2
|
+
const { execFileSync } = require('child_process')
|
|
1
3
|
const fs = require('fs')
|
|
2
4
|
const path = require('path')
|
|
5
|
+
const { fileURLToPath } = require('url')
|
|
3
6
|
const { ensureDir } = require('./io')
|
|
4
7
|
const { turbocommitDir } = require('./session')
|
|
8
|
+
const { canonicalRoot, canonicalTrackedPath, changedPathsInRepository, gitCommonDir } = require('./git')
|
|
9
|
+
|
|
10
|
+
const BASH_SNAPSHOT_TTL_MS = 60 * 60 * 1000
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
13
|
* Directory under the git common dir where turbocommit stores tracking state.
|
|
@@ -18,10 +24,25 @@ function trackingPath (root, sessionId) {
|
|
|
18
24
|
return dir && path.join(dir, sessionId + '.jsonl')
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
function preclaimDir (root) {
|
|
28
|
+
const base = turbocommitDir(root)
|
|
29
|
+
return base && path.join(base, 'preclaims')
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
/**
|
|
22
33
|
* Keys to probe in tool_input for a file path (MCP tools, Write, Edit, etc.)
|
|
23
34
|
*/
|
|
24
|
-
const FILE_PATH_KEYS = [
|
|
35
|
+
const FILE_PATH_KEYS = [
|
|
36
|
+
'file_path',
|
|
37
|
+
'filePath',
|
|
38
|
+
'path',
|
|
39
|
+
'file',
|
|
40
|
+
'notebook_path',
|
|
41
|
+
'relative_path',
|
|
42
|
+
'relativePath',
|
|
43
|
+
'uri'
|
|
44
|
+
]
|
|
45
|
+
const FILE_PATH_ARRAY_KEYS = ['file_paths', 'filePaths', 'paths', 'files']
|
|
25
46
|
|
|
26
47
|
/**
|
|
27
48
|
* Extract a file path from tool_input, heuristically checking known keys.
|
|
@@ -29,22 +50,21 @@ const FILE_PATH_KEYS = ['file_path', 'filePath', 'path', 'file', 'notebook_path'
|
|
|
29
50
|
function extractFilePath (toolInput) {
|
|
30
51
|
if (!toolInput || typeof toolInput !== 'object') return null
|
|
31
52
|
for (const key of FILE_PATH_KEYS) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
53
|
+
const value = normalizeRawPath(toolInput[key], key)
|
|
54
|
+
if (value) return value
|
|
35
55
|
}
|
|
36
56
|
return null
|
|
37
57
|
}
|
|
38
58
|
|
|
39
|
-
function
|
|
59
|
+
function extractRawFilePaths (toolName, toolInput) {
|
|
40
60
|
const paths = []
|
|
41
61
|
const seen = new Set()
|
|
42
|
-
const add = value => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (seen.has(
|
|
46
|
-
seen.add(
|
|
47
|
-
paths.push(
|
|
62
|
+
const add = (value, key) => {
|
|
63
|
+
value = normalizeRawPath(value, key)
|
|
64
|
+
if (!value) return
|
|
65
|
+
if (seen.has(value)) return
|
|
66
|
+
seen.add(value)
|
|
67
|
+
paths.push(value)
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
if (toolName === 'apply_patch' && typeof toolInput?.command === 'string') {
|
|
@@ -63,7 +83,10 @@ function extractFilePaths (toolName, toolInput, cwd) {
|
|
|
63
83
|
return
|
|
64
84
|
}
|
|
65
85
|
for (const [key, nested] of Object.entries(value)) {
|
|
66
|
-
if (FILE_PATH_KEYS.includes(key)) add(nested)
|
|
86
|
+
if (FILE_PATH_KEYS.includes(key)) add(nested, key)
|
|
87
|
+
if (FILE_PATH_ARRAY_KEYS.includes(key) && Array.isArray(nested)) {
|
|
88
|
+
for (const item of nested) add(item, key)
|
|
89
|
+
}
|
|
67
90
|
if (nested && typeof nested === 'object') visit(nested)
|
|
68
91
|
}
|
|
69
92
|
}
|
|
@@ -71,11 +94,37 @@ function extractFilePaths (toolName, toolInput, cwd) {
|
|
|
71
94
|
return paths
|
|
72
95
|
}
|
|
73
96
|
|
|
97
|
+
function normalizeRawPath (value, key) {
|
|
98
|
+
if (typeof value !== 'string' || value.length === 0) return null
|
|
99
|
+
if (key !== 'uri') return value
|
|
100
|
+
if (value.startsWith('file://')) {
|
|
101
|
+
try {
|
|
102
|
+
return fileURLToPath(value)
|
|
103
|
+
} catch {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return null
|
|
108
|
+
return value
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function extractFilePaths (toolName, toolInput, cwd) {
|
|
112
|
+
const seen = new Set()
|
|
113
|
+
return extractRawFilePaths(toolName, toolInput).map(value =>
|
|
114
|
+
path.isAbsolute(value) ? value : path.resolve(cwd || process.cwd(), value)
|
|
115
|
+
).filter(value => {
|
|
116
|
+
if (seen.has(value)) return false
|
|
117
|
+
seen.add(value)
|
|
118
|
+
return true
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
74
122
|
/**
|
|
75
|
-
* PreToolUse handler.
|
|
76
|
-
*
|
|
123
|
+
* PreToolUse handler. Persists ownership before waiting for overlap recovery.
|
|
124
|
+
* Returns false when the caller must deny the tool rather than let it run
|
|
125
|
+
* without a durable ownership claim.
|
|
77
126
|
*/
|
|
78
|
-
function handleTrack (input, root) {
|
|
127
|
+
function handleTrack (input, root, opts = {}) {
|
|
79
128
|
const hookInput = typeof input === 'string' ? parseInput(input) : input
|
|
80
129
|
root = root || hookInput?.root
|
|
81
130
|
if (!root || !hookInput) return
|
|
@@ -88,28 +137,768 @@ function handleTrack (input, root) {
|
|
|
88
137
|
|
|
89
138
|
const toolInput = hookInput.toolInput || hookInput.tool_input || {}
|
|
90
139
|
|
|
91
|
-
|
|
140
|
+
// Skip Bash with no command (malformed input). All other non-Bash tools
|
|
141
|
+
// passed the PreToolUse matcher, so they're known modifying tools even if
|
|
142
|
+
// we can't extract a specific file path (e.g. MultiEdit nests paths in edits[]).
|
|
143
|
+
if (toolName === 'Bash' && typeof toolInput.command !== 'string') return
|
|
92
144
|
|
|
93
145
|
const cwd = hookInput.cwd || hookInput.raw?.cwd || root
|
|
146
|
+
const entry = { tool: toolName, t: Date.now(), cwd }
|
|
147
|
+
const rawFiles = extractRawFilePaths(toolName, toolInput)
|
|
94
148
|
const files = extractFilePaths(toolName, toolInput, cwd)
|
|
149
|
+
if (rawFiles.length > 0) entry.rawFiles = rawFiles
|
|
95
150
|
if (files.length > 0) entry.files = files
|
|
96
151
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
152
|
+
const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
|
|
153
|
+
let pendingSnapshot = false
|
|
154
|
+
let preclaim = null
|
|
155
|
+
try {
|
|
156
|
+
if (toolName === 'Bash') {
|
|
157
|
+
entry.command = toolInput.command
|
|
158
|
+
savePendingBashSnapshot(root, sessionId, toolUseId, cwd)
|
|
159
|
+
pendingSnapshot = true
|
|
160
|
+
appendTracking(root, sessionId, entry)
|
|
161
|
+
} else {
|
|
162
|
+
preclaim = savePreclaim(root, sessionId, entry)
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (pendingSnapshot) removeBashSnapshot(root, sessionId, toolUseId)
|
|
166
|
+
if (preclaim) removePreclaim(preclaim)
|
|
167
|
+
throw error
|
|
100
168
|
}
|
|
101
169
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
170
|
+
const recoveryWaitMs = opts.recoveryWaitMs ?? 5000
|
|
171
|
+
if (pendingSnapshot) {
|
|
172
|
+
const release = acquireBashOverlapRecoveryLock(root, recoveryWaitMs)
|
|
173
|
+
if (!release) {
|
|
174
|
+
removeBashSnapshot(root, sessionId, toolUseId)
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
pruneBashSnapshots(root)
|
|
179
|
+
initializeBashSnapshot(root, sessionId, toolUseId, cwd)
|
|
180
|
+
} catch (error) {
|
|
181
|
+
removeBashSnapshot(root, sessionId, toolUseId)
|
|
182
|
+
throw error
|
|
183
|
+
} finally {
|
|
184
|
+
release()
|
|
185
|
+
}
|
|
186
|
+
return true
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!waitForBashOverlapRecovery(root, recoveryWaitMs)) {
|
|
190
|
+
removePreclaim(preclaim)
|
|
191
|
+
return false
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
appendTracking(root, sessionId, entry)
|
|
195
|
+
return true
|
|
196
|
+
} finally {
|
|
197
|
+
removePreclaim(preclaim)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function handlePostTrack (input, root) {
|
|
202
|
+
const hookInput = typeof input === 'string' ? parseInput(input) : input
|
|
203
|
+
root = root || hookInput?.root
|
|
204
|
+
if (!root || !hookInput) return
|
|
205
|
+
|
|
206
|
+
const sessionId = hookInput.sessionId || hookInput.session_id
|
|
207
|
+
const toolName = hookInput.toolName || hookInput.tool_name
|
|
208
|
+
if (!sessionId || toolName !== 'Bash') return
|
|
209
|
+
|
|
210
|
+
const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
|
|
211
|
+
const release = acquireBashOverlapRecoveryLock(root, 5000)
|
|
212
|
+
if (!release) return
|
|
213
|
+
try {
|
|
214
|
+
const snapshot = loadBashSnapshot(root, sessionId, toolUseId)
|
|
215
|
+
if (!snapshot || Number.isFinite(snapshot.endedAt)) return
|
|
216
|
+
finishBashSnapshot(root, snapshot, {
|
|
217
|
+
cwd: hookInput.cwd || hookInput.raw?.cwd || snapshot.cwd || root,
|
|
218
|
+
endedAt: Date.now()
|
|
219
|
+
})
|
|
220
|
+
} finally {
|
|
221
|
+
release()
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function finishBashSnapshot (root, snapshot, { cwd, endedAt }) {
|
|
226
|
+
if (snapshot.pending === true || !Array.isArray(snapshot.before) || !Number.isFinite(snapshot.startedAt)) return false
|
|
227
|
+
const overlaps = overlappingBashSnapshots(root, snapshot, endedAt)
|
|
228
|
+
const before = new Set(snapshot.before.map(canonicalTrackedPath))
|
|
229
|
+
const changed = changedPathsInRepository(snapshot.root).map(canonicalTrackedPath)
|
|
230
|
+
const overlapping = overlaps.length > 0
|
|
231
|
+
const claimed = overlapping
|
|
232
|
+
? claimedPathsBySessions(root)
|
|
233
|
+
: claimedPathsBySessions(root, snapshot.sessionId)
|
|
234
|
+
const candidates = changed.filter(file => !before.has(file) && !claimed.has(file))
|
|
235
|
+
const files = overlapping ? [] : candidates
|
|
236
|
+
|
|
237
|
+
const entry = { tool: 'Bash', phase: 'post', t: endedAt, cwd }
|
|
238
|
+
if (overlapping) {
|
|
239
|
+
entry.overlapping = true
|
|
240
|
+
entry.overlapEventId = recordBashOverlap(root, snapshot, overlaps, candidates, endedAt)
|
|
241
|
+
}
|
|
242
|
+
if (files.length > 0) {
|
|
243
|
+
entry.rawFiles = files
|
|
244
|
+
entry.files = files
|
|
245
|
+
}
|
|
246
|
+
appendTracking(root, snapshot.sessionId, entry)
|
|
247
|
+
snapshot.endedAt = endedAt
|
|
248
|
+
writeBashSnapshot(root, snapshot.sessionId, snapshot.toolUseId, snapshot)
|
|
249
|
+
return true
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function bashSnapshotDir (root) {
|
|
253
|
+
const base = turbocommitDir(root)
|
|
254
|
+
return base && path.join(base, 'bash-snapshots')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function bashSnapshotPath (root, sessionId, toolUseId) {
|
|
258
|
+
const dir = bashSnapshotDir(root)
|
|
259
|
+
if (!dir) return null
|
|
260
|
+
const key = crypto.createHash('sha256')
|
|
261
|
+
.update(`${canonicalRoot(root)}\0${sessionId}\0${toolUseId || 'current'}`)
|
|
262
|
+
.digest('hex')
|
|
263
|
+
return path.join(dir, key + '.json')
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function savePendingBashSnapshot (root, sessionId, toolUseId, cwd) {
|
|
267
|
+
const checkout = canonicalRoot(root)
|
|
268
|
+
writeBashSnapshot(root, sessionId, toolUseId, {
|
|
269
|
+
root: checkout,
|
|
270
|
+
cwd: cwd || checkout,
|
|
271
|
+
sessionId,
|
|
272
|
+
toolUseId: toolUseId || null,
|
|
273
|
+
createdAt: Date.now(),
|
|
274
|
+
pending: true
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function initializeBashSnapshot (root, sessionId, toolUseId, cwd) {
|
|
279
|
+
const checkout = canonicalRoot(root)
|
|
280
|
+
const snapshot = loadBashSnapshot(root, sessionId, toolUseId)
|
|
281
|
+
if (!snapshot || snapshot.pending !== true) throw new Error('Bash ownership snapshot disappeared before initialization')
|
|
282
|
+
writeBashSnapshot(root, sessionId, toolUseId, {
|
|
283
|
+
...snapshot,
|
|
284
|
+
root: checkout,
|
|
285
|
+
cwd: cwd || checkout,
|
|
286
|
+
startedAt: Date.now(),
|
|
287
|
+
before: changedPathsInRepository(checkout),
|
|
288
|
+
pending: false
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function removeBashSnapshot (root, sessionId, toolUseId) {
|
|
293
|
+
try {
|
|
294
|
+
fs.unlinkSync(bashSnapshotPath(root, sessionId, toolUseId))
|
|
295
|
+
} catch {}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function writeBashSnapshot (root, sessionId, toolUseId, snapshot) {
|
|
299
|
+
const file = bashSnapshotPath(root, sessionId, toolUseId)
|
|
300
|
+
if (!file) return
|
|
301
|
+
ensureDir(path.dirname(file))
|
|
302
|
+
const temporary = file + `.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
303
|
+
try {
|
|
304
|
+
fs.writeFileSync(temporary, JSON.stringify(snapshot) + '\n')
|
|
305
|
+
fs.renameSync(temporary, file)
|
|
306
|
+
} finally {
|
|
307
|
+
try { fs.unlinkSync(temporary) } catch {}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function loadBashSnapshot (root, sessionId, toolUseId) {
|
|
312
|
+
try {
|
|
313
|
+
return JSON.parse(fs.readFileSync(bashSnapshotPath(root, sessionId, toolUseId), 'utf8'))
|
|
314
|
+
} catch {
|
|
315
|
+
return null
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function overlappingBashSnapshots (root, current, now) {
|
|
320
|
+
const dir = bashSnapshotDir(root)
|
|
321
|
+
let files
|
|
322
|
+
try {
|
|
323
|
+
files = fs.readdirSync(dir)
|
|
324
|
+
} catch {
|
|
325
|
+
return []
|
|
326
|
+
}
|
|
327
|
+
return files.flatMap(file => {
|
|
328
|
+
try {
|
|
329
|
+
const other = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
|
|
330
|
+
if (other.sessionId === current.sessionId) return []
|
|
331
|
+
if (canonicalRoot(other.root) !== canonicalRoot(current.root)) return []
|
|
332
|
+
if (other.pending === true || !Number.isFinite(other.startedAt)) return []
|
|
333
|
+
const endedAt = Number.isFinite(other.endedAt) ? other.endedAt : now
|
|
334
|
+
return other.startedAt <= now && endedAt >= current.startedAt ? [other] : []
|
|
335
|
+
} catch {
|
|
336
|
+
return []
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function finalizeBashSnapshots (root, sessionId, endedAt = Date.now(), waitMs = 5000) {
|
|
342
|
+
const files = unfinishedBashSnapshotFiles(root, sessionId)
|
|
343
|
+
if (files.length === 0) return 0
|
|
344
|
+
const release = acquireBashOverlapRecoveryLock(root, waitMs)
|
|
345
|
+
if (!release) return null
|
|
346
|
+
try {
|
|
347
|
+
let finalized = 0
|
|
348
|
+
for (const file of files) {
|
|
349
|
+
try {
|
|
350
|
+
const snapshot = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
351
|
+
if (snapshot.sessionId !== sessionId || Number.isFinite(snapshot.endedAt) || snapshot.pending === true) continue
|
|
352
|
+
if (canonicalRoot(snapshot.root) !== canonicalRoot(root)) continue
|
|
353
|
+
if (finishBashSnapshot(root, snapshot, {
|
|
354
|
+
cwd: snapshot.cwd || snapshot.root || root,
|
|
355
|
+
endedAt
|
|
356
|
+
})) finalized++
|
|
357
|
+
} catch {}
|
|
358
|
+
}
|
|
359
|
+
return finalized
|
|
360
|
+
} finally {
|
|
361
|
+
release()
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function unfinishedBashSnapshotFiles (root, sessionId) {
|
|
366
|
+
const dir = bashSnapshotDir(root)
|
|
367
|
+
let files
|
|
368
|
+
try {
|
|
369
|
+
files = fs.readdirSync(dir)
|
|
370
|
+
} catch {
|
|
371
|
+
return []
|
|
372
|
+
}
|
|
373
|
+
const checkout = canonicalRoot(root)
|
|
374
|
+
return files.map(file => path.join(dir, file)).filter(file => {
|
|
375
|
+
try {
|
|
376
|
+
const snapshot = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
377
|
+
return snapshot.sessionId === sessionId &&
|
|
378
|
+
!Number.isFinite(snapshot.endedAt) &&
|
|
379
|
+
snapshot.pending !== true &&
|
|
380
|
+
canonicalRoot(snapshot.root) === checkout
|
|
381
|
+
} catch {
|
|
382
|
+
return false
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function pruneBashSnapshots (root) {
|
|
388
|
+
const dir = bashSnapshotDir(root)
|
|
389
|
+
let files
|
|
390
|
+
try {
|
|
391
|
+
files = fs.readdirSync(dir)
|
|
392
|
+
} catch {
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
const checkout = canonicalRoot(root)
|
|
396
|
+
const cutoff = Date.now() - BASH_SNAPSHOT_TTL_MS
|
|
397
|
+
const expired = []
|
|
398
|
+
for (const file of files) {
|
|
399
|
+
try {
|
|
400
|
+
const fullPath = path.join(dir, file)
|
|
401
|
+
const snapshot = JSON.parse(fs.readFileSync(fullPath, 'utf8'))
|
|
402
|
+
if (canonicalRoot(snapshot.root) !== checkout) continue
|
|
403
|
+
if (Number.isFinite(snapshot.endedAt) && snapshot.endedAt < cutoff) {
|
|
404
|
+
fs.unlinkSync(fullPath)
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
const startedAt = Number.isFinite(snapshot.startedAt) ? snapshot.startedAt : snapshot.createdAt
|
|
408
|
+
if (!Number.isFinite(snapshot.endedAt) && Number.isFinite(startedAt) && startedAt < cutoff) {
|
|
409
|
+
expired.push({ fullPath, snapshot })
|
|
410
|
+
}
|
|
411
|
+
} catch {}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (expired.length === 0) return
|
|
415
|
+
const expiredIds = new Set(expired.map(({ snapshot }) => bashSnapshotId(snapshot)))
|
|
416
|
+
const eventIds = bashOverlapComponents(unresolvedBashOverlapEvents(root))
|
|
417
|
+
.filter(component => [...component.snapshotIds].some(id => expiredIds.has(id)))
|
|
418
|
+
.flatMap(component => component.events.map(event => event.id))
|
|
419
|
+
if (eventIds.length > 0) invalidateBashOverlap(root, [...new Set(eventIds)], 'expired-snapshot')
|
|
420
|
+
for (const { fullPath } of expired) {
|
|
421
|
+
try { fs.unlinkSync(fullPath) } catch {}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function claimedPathsBySessions (root, excludedSessionId) {
|
|
426
|
+
const result = new Set()
|
|
427
|
+
const dir = trackingDir(root)
|
|
428
|
+
let files
|
|
429
|
+
try {
|
|
430
|
+
files = fs.readdirSync(dir)
|
|
431
|
+
} catch {
|
|
432
|
+
files = []
|
|
433
|
+
}
|
|
434
|
+
for (const file of files) {
|
|
435
|
+
if (file === excludedSessionId + '.jsonl' || !file.endsWith('.jsonl')) continue
|
|
436
|
+
try {
|
|
437
|
+
const entries = fs.readFileSync(path.join(dir, file), 'utf8').trim().split('\n')
|
|
438
|
+
for (const line of entries) {
|
|
439
|
+
const entry = JSON.parse(line)
|
|
440
|
+
if (!Array.isArray(entry.files)) continue
|
|
441
|
+
for (const claimed of entry.files) result.add(canonicalTrackedPath(claimed))
|
|
442
|
+
}
|
|
443
|
+
} catch {}
|
|
444
|
+
}
|
|
445
|
+
const claimsDir = preclaimDir(root)
|
|
446
|
+
let claims
|
|
447
|
+
try {
|
|
448
|
+
claims = fs.readdirSync(claimsDir)
|
|
449
|
+
} catch {
|
|
450
|
+
claims = []
|
|
451
|
+
}
|
|
452
|
+
for (const file of claims) {
|
|
453
|
+
if (!file.endsWith('.json')) continue
|
|
454
|
+
try {
|
|
455
|
+
const claim = JSON.parse(fs.readFileSync(path.join(claimsDir, file), 'utf8'))
|
|
456
|
+
if (claim.sessionId === excludedSessionId || !Array.isArray(claim.entry?.files)) continue
|
|
457
|
+
for (const claimed of claim.entry.files) result.add(canonicalTrackedPath(claimed))
|
|
458
|
+
} catch {}
|
|
459
|
+
}
|
|
460
|
+
return result
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function bashOverlapPath (root) {
|
|
464
|
+
const base = turbocommitDir(root)
|
|
465
|
+
if (!base) return null
|
|
466
|
+
const checkout = crypto.createHash('sha256').update(canonicalRoot(root)).digest('hex').slice(0, 20)
|
|
467
|
+
return path.join(base, `bash-overlaps-${checkout}.jsonl`)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function bashOverlapRecoveryLockRef (root) {
|
|
471
|
+
if (!turbocommitDir(root)) return null
|
|
472
|
+
const checkout = crypto.createHash('sha256').update(canonicalRoot(root)).digest('hex').slice(0, 20)
|
|
473
|
+
return `refs/turbocommit/bash-overlap-locks/${checkout}`
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function bashSessionStopDir (root) {
|
|
477
|
+
const base = turbocommitDir(root)
|
|
478
|
+
return base && path.join(base, 'bash-stops')
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function bashSessionStopPath (root, sessionId) {
|
|
482
|
+
const dir = bashSessionStopDir(root)
|
|
483
|
+
if (!dir) return null
|
|
484
|
+
const key = crypto.createHash('sha256')
|
|
485
|
+
.update(`${canonicalRoot(root)}\0${sessionId}`)
|
|
486
|
+
.digest('hex')
|
|
487
|
+
return path.join(dir, key + '.json')
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function recordBashOverlap (root, current, overlaps, files, at) {
|
|
491
|
+
const event = {
|
|
492
|
+
type: 'overlap',
|
|
493
|
+
id: crypto.randomUUID(),
|
|
494
|
+
t: at,
|
|
495
|
+
root: canonicalRoot(current.root),
|
|
496
|
+
completedSessionId: current.sessionId,
|
|
497
|
+
completedAt: at,
|
|
498
|
+
snapshotIds: [current, ...overlaps].map(bashSnapshotId).sort(),
|
|
499
|
+
sessionIds: [...new Set([current, ...overlaps].map(snapshot => snapshot.sessionId))].sort(),
|
|
500
|
+
paths: files.map(file => ({ path: file, fingerprint: fingerprintTrackedPath(file) }))
|
|
501
|
+
}
|
|
502
|
+
appendBashOverlapEvent(root, event)
|
|
503
|
+
return event.id
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function bashSnapshotId (snapshot) {
|
|
507
|
+
return crypto.createHash('sha256')
|
|
508
|
+
.update(`${canonicalRoot(snapshot.root)}\0${snapshot.sessionId}\0${snapshot.toolUseId || 'current'}`)
|
|
509
|
+
.digest('hex')
|
|
510
|
+
}
|
|
106
511
|
|
|
512
|
+
function fingerprintTrackedPath (file) {
|
|
513
|
+
try {
|
|
514
|
+
const stat = fs.lstatSync(file)
|
|
515
|
+
if (stat.isSymbolicLink()) {
|
|
516
|
+
return 'link:' + crypto.createHash('sha256').update(fs.readlinkSync(file)).digest('hex')
|
|
517
|
+
}
|
|
518
|
+
if (stat.isFile()) {
|
|
519
|
+
const executable = (stat.mode & 0o111) !== 0 ? 'x' : '-'
|
|
520
|
+
return `file:${executable}:` + crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
|
|
521
|
+
}
|
|
522
|
+
return `other:${stat.mode}:${stat.size}:${stat.mtimeMs}`
|
|
523
|
+
} catch (error) {
|
|
524
|
+
return error && error.code === 'ENOENT' ? 'missing' : null
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function recordBashSessionStop (root, sessionId, at = Date.now()) {
|
|
529
|
+
const overlapFile = bashOverlapPath(root)
|
|
530
|
+
if (!overlapFile || !fs.existsSync(overlapFile)) return
|
|
531
|
+
const file = sessionId && bashSessionStopPath(root, sessionId)
|
|
532
|
+
if (!file) return
|
|
533
|
+
ensureDir(path.dirname(file))
|
|
534
|
+
const temporary = file + `.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
535
|
+
try {
|
|
536
|
+
fs.writeFileSync(temporary, JSON.stringify({ root: canonicalRoot(root), sessionId, t: at }) + '\n')
|
|
537
|
+
fs.renameSync(temporary, file)
|
|
538
|
+
if (!fs.existsSync(overlapFile)) {
|
|
539
|
+
try { fs.unlinkSync(file) } catch {}
|
|
540
|
+
}
|
|
541
|
+
} finally {
|
|
542
|
+
try { fs.unlinkSync(temporary) } catch {}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function appendBashOverlapEvent (root, event) {
|
|
547
|
+
const file = bashOverlapPath(root)
|
|
548
|
+
if (!file) return
|
|
549
|
+
ensureDir(path.dirname(file))
|
|
550
|
+
fs.appendFileSync(file, JSON.stringify(event) + '\n')
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function readBashOverlapEvents (root) {
|
|
554
|
+
try {
|
|
555
|
+
return fs.readFileSync(bashOverlapPath(root), 'utf8').trim().split('\n').map(line => {
|
|
556
|
+
try {
|
|
557
|
+
return JSON.parse(line)
|
|
558
|
+
} catch {
|
|
559
|
+
return null
|
|
560
|
+
}
|
|
561
|
+
}).filter(Boolean)
|
|
562
|
+
} catch {
|
|
563
|
+
return []
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function readBashSessionStops (root) {
|
|
568
|
+
const dir = bashSessionStopDir(root)
|
|
569
|
+
const checkout = canonicalRoot(root)
|
|
570
|
+
let files
|
|
571
|
+
try {
|
|
572
|
+
files = fs.readdirSync(dir)
|
|
573
|
+
} catch {
|
|
574
|
+
return []
|
|
575
|
+
}
|
|
576
|
+
return files.flatMap(file => {
|
|
577
|
+
try {
|
|
578
|
+
const stop = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
|
|
579
|
+
return stop.root === checkout && stop.sessionId && Number.isFinite(stop.t) ? [stop] : []
|
|
580
|
+
} catch {
|
|
581
|
+
return []
|
|
582
|
+
}
|
|
583
|
+
})
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function unresolvedBashOverlapEvents (root, events = readBashOverlapEvents(root)) {
|
|
587
|
+
const checkout = canonicalRoot(root)
|
|
588
|
+
const terminal = new Set(events
|
|
589
|
+
.filter(event => (event.type === 'resolved' || event.type === 'invalidated') && event.root === checkout)
|
|
590
|
+
.flatMap(event => event.eventIds || []))
|
|
591
|
+
return events.filter(event =>
|
|
592
|
+
event.type === 'overlap' && event.root === checkout && !terminal.has(event.id))
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function bashOverlapComponents (overlaps) {
|
|
596
|
+
const components = []
|
|
597
|
+
for (const event of overlaps) {
|
|
598
|
+
const matching = components.filter(component =>
|
|
599
|
+
event.snapshotIds.some(id => component.snapshotIds.has(id)))
|
|
600
|
+
const component = matching.shift() || { events: [], snapshotIds: new Set() }
|
|
601
|
+
component.events.push(event)
|
|
602
|
+
for (const id of event.snapshotIds) component.snapshotIds.add(id)
|
|
603
|
+
for (const merged of matching) {
|
|
604
|
+
component.events.push(...merged.events)
|
|
605
|
+
for (const id of merged.snapshotIds) component.snapshotIds.add(id)
|
|
606
|
+
components.splice(components.indexOf(merged), 1)
|
|
607
|
+
}
|
|
608
|
+
if (!components.includes(component)) components.push(component)
|
|
609
|
+
}
|
|
610
|
+
return components
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function readReadyBashOverlaps (root) {
|
|
614
|
+
const events = readBashOverlapEvents(root)
|
|
615
|
+
const stops = new Map()
|
|
616
|
+
for (const stop of readBashSessionStops(root)) {
|
|
617
|
+
stops.set(stop.sessionId, Math.max(stops.get(stop.sessionId) || 0, stop.t))
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const components = bashOverlapComponents(unresolvedBashOverlapEvents(root, events))
|
|
621
|
+
|
|
622
|
+
return components.flatMap(component => {
|
|
623
|
+
const sessionIds = new Set()
|
|
624
|
+
const completionTimes = new Map()
|
|
625
|
+
const paths = new Map()
|
|
626
|
+
for (const event of component.events.sort((a, b) => a.t - b.t)) {
|
|
627
|
+
for (const sessionId of event.sessionIds || []) sessionIds.add(sessionId)
|
|
628
|
+
if (event.completedSessionId && Number.isFinite(event.completedAt)) {
|
|
629
|
+
completionTimes.set(event.completedSessionId, Math.max(
|
|
630
|
+
completionTimes.get(event.completedSessionId) || 0,
|
|
631
|
+
event.completedAt
|
|
632
|
+
))
|
|
633
|
+
} else {
|
|
634
|
+
for (const sessionId of event.sessionIds || []) {
|
|
635
|
+
completionTimes.set(sessionId, Math.max(completionTimes.get(sessionId) || 0, event.t))
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
for (const item of event.paths || []) paths.set(item.path, item)
|
|
639
|
+
}
|
|
640
|
+
const ready = [...sessionIds].every(sessionId => {
|
|
641
|
+
const completedAt = completionTimes.get(sessionId)
|
|
642
|
+
return Number.isFinite(completedAt) && (stops.get(sessionId) || 0) >= completedAt
|
|
643
|
+
})
|
|
644
|
+
if (!ready) return []
|
|
645
|
+
return [{
|
|
646
|
+
eventIds: component.events.map(event => event.id),
|
|
647
|
+
sessionIds: [...sessionIds].sort(),
|
|
648
|
+
paths: [...paths.values()].sort((a, b) => a.path.localeCompare(b.path))
|
|
649
|
+
}]
|
|
650
|
+
})
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function compactBashOverlapEvents (root) {
|
|
654
|
+
const overlaps = unresolvedBashOverlapEvents(root)
|
|
655
|
+
const file = bashOverlapPath(root)
|
|
656
|
+
if (!file) return
|
|
657
|
+
if (overlaps.length === 0) {
|
|
658
|
+
try { fs.unlinkSync(file) } catch {}
|
|
659
|
+
for (const stop of readBashSessionStops(root)) {
|
|
660
|
+
try { fs.unlinkSync(bashSessionStopPath(root, stop.sessionId)) } catch {}
|
|
661
|
+
}
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const referencedSessions = new Set(overlaps.flatMap(event => event.sessionIds || []))
|
|
666
|
+
for (const stop of readBashSessionStops(root)) {
|
|
667
|
+
if (referencedSessions.has(stop.sessionId)) continue
|
|
668
|
+
try { fs.unlinkSync(bashSessionStopPath(root, stop.sessionId)) } catch {}
|
|
669
|
+
}
|
|
670
|
+
ensureDir(path.dirname(file))
|
|
671
|
+
const temporary = file + `.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
672
|
+
try {
|
|
673
|
+
fs.writeFileSync(temporary, overlaps
|
|
674
|
+
.sort((a, b) => a.t - b.t)
|
|
675
|
+
.map(event => JSON.stringify(event)).join('\n') + '\n')
|
|
676
|
+
fs.renameSync(temporary, file)
|
|
677
|
+
} finally {
|
|
678
|
+
try { fs.unlinkSync(temporary) } catch {}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function resolveBashOverlap (root, eventIds) {
|
|
683
|
+
appendBashOverlapEvent(root, {
|
|
684
|
+
type: 'resolved',
|
|
685
|
+
root: canonicalRoot(root),
|
|
686
|
+
eventIds,
|
|
687
|
+
t: Date.now()
|
|
688
|
+
})
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function invalidateBashOverlap (root, eventIds, reason) {
|
|
692
|
+
appendBashOverlapEvent(root, {
|
|
693
|
+
type: 'invalidated',
|
|
694
|
+
root: canonicalRoot(root),
|
|
695
|
+
eventIds,
|
|
696
|
+
reason,
|
|
697
|
+
t: Date.now()
|
|
698
|
+
})
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function hasActiveBashSnapshot (root) {
|
|
702
|
+
const dir = bashSnapshotDir(root)
|
|
703
|
+
const checkout = canonicalRoot(root)
|
|
704
|
+
let files
|
|
705
|
+
try {
|
|
706
|
+
files = fs.readdirSync(dir)
|
|
707
|
+
} catch {
|
|
708
|
+
return false
|
|
709
|
+
}
|
|
710
|
+
return files.some(file => {
|
|
711
|
+
try {
|
|
712
|
+
const snapshot = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
|
|
713
|
+
return canonicalRoot(snapshot.root) === checkout && !Number.isFinite(snapshot.endedAt)
|
|
714
|
+
} catch {
|
|
715
|
+
return false
|
|
716
|
+
}
|
|
717
|
+
})
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function acquireBashOverlapRecoveryLock (root, waitMs = 0) {
|
|
721
|
+
// update-ref supplies the compare-and-swap that a lock file cannot. A stale
|
|
722
|
+
// owner can only be replaced if the ref still points to its exact token.
|
|
723
|
+
// Released token blobs become unreachable and are removed by normal Git GC.
|
|
724
|
+
const ref = bashOverlapRecoveryLockRef(root)
|
|
725
|
+
const gitDir = gitCommonDir(root)
|
|
726
|
+
if (!ref || !gitDir) return null
|
|
727
|
+
const deadline = Date.now() + waitMs
|
|
728
|
+
const owner = JSON.stringify({
|
|
729
|
+
pid: process.pid,
|
|
730
|
+
startIdentity: processStartIdentity(process.pid),
|
|
731
|
+
token: crypto.randomUUID()
|
|
732
|
+
})
|
|
733
|
+
const token = writeRecoveryLockOwner(gitDir, owner)
|
|
734
|
+
if (!token) return null
|
|
735
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4))
|
|
736
|
+
while (true) {
|
|
737
|
+
if (!fs.existsSync(root)) return null
|
|
738
|
+
const state = readRecoveryLockToken(gitDir, ref)
|
|
739
|
+
if (!state.ok) return null
|
|
740
|
+
const current = state.token
|
|
741
|
+
if ((!current && updateRecoveryLock(gitDir, ref, token, '0'.repeat(token.length))) ||
|
|
742
|
+
(current && !isRecoveryLockOwnerAlive(gitDir, current) && updateRecoveryLock(gitDir, ref, token, current))) {
|
|
743
|
+
return () => {
|
|
744
|
+
deleteRecoveryLock(gitDir, ref, token)
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (Date.now() >= deadline) return null
|
|
748
|
+
Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()))
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function waitForBashOverlapRecovery (root, waitMs) {
|
|
753
|
+
const ref = bashOverlapRecoveryLockRef(root)
|
|
754
|
+
const gitDir = gitCommonDir(root)
|
|
755
|
+
if (!ref || !gitDir) return false
|
|
756
|
+
const deadline = Date.now() + waitMs
|
|
757
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4))
|
|
758
|
+
while (true) {
|
|
759
|
+
if (!fs.existsSync(root)) return false
|
|
760
|
+
const state = readRecoveryLockToken(gitDir, ref)
|
|
761
|
+
if (!state.ok) return false
|
|
762
|
+
const current = state.token
|
|
763
|
+
if (state.ok && !current) return true
|
|
764
|
+
if (current && !isRecoveryLockOwnerAlive(gitDir, current) && deleteRecoveryLock(gitDir, ref, current)) continue
|
|
765
|
+
if (Date.now() >= deadline) return false
|
|
766
|
+
Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()))
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function writeRecoveryLockOwner (gitDir, owner) {
|
|
771
|
+
try {
|
|
772
|
+
return execFileSync('git', ['--git-dir', gitDir, 'hash-object', '-w', '--stdin'], {
|
|
773
|
+
cwd: stableGitCwd(gitDir),
|
|
774
|
+
input: owner,
|
|
775
|
+
encoding: 'utf8',
|
|
776
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
777
|
+
}).trim() || null
|
|
778
|
+
} catch {
|
|
779
|
+
return null
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function readRecoveryLockToken (gitDir, ref) {
|
|
784
|
+
try {
|
|
785
|
+
const token = execFileSync('git', ['--git-dir', gitDir, 'rev-parse', '--verify', '--quiet', ref], {
|
|
786
|
+
cwd: stableGitCwd(gitDir),
|
|
787
|
+
encoding: 'utf8',
|
|
788
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
789
|
+
}).trim() || null
|
|
790
|
+
return { ok: true, token }
|
|
791
|
+
} catch (error) {
|
|
792
|
+
return { ok: error?.status === 1, token: null }
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function isRecoveryLockOwnerAlive (gitDir, token) {
|
|
797
|
+
let owner
|
|
798
|
+
try {
|
|
799
|
+
owner = execFileSync('git', ['--git-dir', gitDir, 'cat-file', 'blob', token], {
|
|
800
|
+
cwd: stableGitCwd(gitDir),
|
|
801
|
+
encoding: 'utf8',
|
|
802
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
803
|
+
})
|
|
804
|
+
} catch {
|
|
805
|
+
return false
|
|
806
|
+
}
|
|
807
|
+
const parsed = parseRecoveryLockOwner(owner)
|
|
808
|
+
const pid = parsed?.pid
|
|
809
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
810
|
+
if (parsed.startIdentity) {
|
|
811
|
+
const currentStartIdentity = processStartIdentity(pid)
|
|
812
|
+
if (currentStartIdentity && currentStartIdentity !== parsed.startIdentity) return false
|
|
813
|
+
}
|
|
814
|
+
return isProcessAlive(pid)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function updateRecoveryLock (gitDir, ref, token, expected) {
|
|
818
|
+
try {
|
|
819
|
+
execFileSync('git', ['--git-dir', gitDir, 'update-ref', ref, token, expected], {
|
|
820
|
+
cwd: stableGitCwd(gitDir),
|
|
821
|
+
stdio: 'ignore'
|
|
822
|
+
})
|
|
823
|
+
return true
|
|
824
|
+
} catch {
|
|
825
|
+
return false
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function deleteRecoveryLock (gitDir, ref, expected) {
|
|
830
|
+
try {
|
|
831
|
+
execFileSync('git', ['--git-dir', gitDir, 'update-ref', '-d', ref, expected], {
|
|
832
|
+
cwd: stableGitCwd(gitDir),
|
|
833
|
+
stdio: 'ignore'
|
|
834
|
+
})
|
|
835
|
+
return true
|
|
836
|
+
} catch {
|
|
837
|
+
return false
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function stableGitCwd (gitDir) {
|
|
842
|
+
const parent = path.dirname(gitDir)
|
|
843
|
+
return fs.existsSync(parent) ? parent : process.cwd()
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function parseRecoveryLockOwner (owner) {
|
|
847
|
+
try {
|
|
848
|
+
const parsed = JSON.parse(owner)
|
|
849
|
+
if (Number.isInteger(parsed?.pid)) return parsed
|
|
850
|
+
} catch {}
|
|
851
|
+
const pid = Number(owner.split(':', 1)[0])
|
|
852
|
+
return Number.isInteger(pid) ? { pid } : null
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function processStartIdentity (pid) {
|
|
856
|
+
try {
|
|
857
|
+
return execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
858
|
+
encoding: 'utf8',
|
|
859
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
860
|
+
}).trim() || null
|
|
861
|
+
} catch {
|
|
862
|
+
return null
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function isProcessAlive (pid) {
|
|
867
|
+
try {
|
|
868
|
+
process.kill(pid, 0)
|
|
869
|
+
return true
|
|
870
|
+
} catch (error) {
|
|
871
|
+
return error?.code !== 'ESRCH'
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function appendTracking (root, sessionId, entry) {
|
|
107
876
|
const file = trackingPath(root, sessionId)
|
|
108
877
|
if (!file) return
|
|
109
878
|
ensureDir(path.dirname(file))
|
|
110
879
|
fs.appendFileSync(file, JSON.stringify(entry) + '\n')
|
|
111
880
|
}
|
|
112
881
|
|
|
882
|
+
function savePreclaim (root, sessionId, entry) {
|
|
883
|
+
const dir = preclaimDir(root)
|
|
884
|
+
if (!dir) throw new Error('Cannot persist path ownership outside a git repository')
|
|
885
|
+
ensureDir(dir)
|
|
886
|
+
const file = path.join(dir, `${crypto.randomUUID()}.json`)
|
|
887
|
+
const temporary = file + `.${process.pid}.tmp`
|
|
888
|
+
try {
|
|
889
|
+
fs.writeFileSync(temporary, JSON.stringify({ sessionId, entry }) + '\n')
|
|
890
|
+
fs.renameSync(temporary, file)
|
|
891
|
+
return file
|
|
892
|
+
} finally {
|
|
893
|
+
try { fs.unlinkSync(temporary) } catch {}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function removePreclaim (file) {
|
|
898
|
+
if (!file) return
|
|
899
|
+
try { fs.unlinkSync(file) } catch {}
|
|
900
|
+
}
|
|
901
|
+
|
|
113
902
|
function parseInput (input) {
|
|
114
903
|
try {
|
|
115
904
|
return JSON.parse(input)
|
|
@@ -120,12 +909,13 @@ function parseInput (input) {
|
|
|
120
909
|
|
|
121
910
|
/**
|
|
122
911
|
* Check whether a session has tracked any file-modifying tool calls.
|
|
123
|
-
* Bash
|
|
124
|
-
*
|
|
125
|
-
* The definitive signal comes from Write/Edit/NotebookEdit/MCP tools.
|
|
912
|
+
* A Bash pre-hook alone doesn't count because shell commands may be read-only.
|
|
913
|
+
* A Bash post-hook counts only when its snapshot found newly dirty paths.
|
|
126
914
|
*/
|
|
127
915
|
function hasTrackedModifications (root, sessionId) {
|
|
128
|
-
return readTracking(root, sessionId).some(entry =>
|
|
916
|
+
return readTracking(root, sessionId).some(entry =>
|
|
917
|
+
entry.tool !== 'Bash' || (entry.phase === 'post' && Array.isArray(entry.files) && entry.files.length > 0)
|
|
918
|
+
)
|
|
129
919
|
}
|
|
130
920
|
|
|
131
921
|
function readTracking (root, sessionId) {
|
|
@@ -152,14 +942,46 @@ function cleanupTracking (root, sessionId) {
|
|
|
152
942
|
try {
|
|
153
943
|
fs.unlinkSync(trackingPath(root, sessionId))
|
|
154
944
|
} catch {}
|
|
945
|
+
const dir = preclaimDir(root)
|
|
946
|
+
let files
|
|
947
|
+
try {
|
|
948
|
+
files = fs.readdirSync(dir)
|
|
949
|
+
} catch {
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
for (const file of files) {
|
|
953
|
+
if (!file.endsWith('.json')) continue
|
|
954
|
+
try {
|
|
955
|
+
const fullPath = path.join(dir, file)
|
|
956
|
+
const claim = JSON.parse(fs.readFileSync(fullPath, 'utf8'))
|
|
957
|
+
if (claim.sessionId === sessionId) fs.unlinkSync(fullPath)
|
|
958
|
+
} catch {}
|
|
959
|
+
}
|
|
155
960
|
}
|
|
156
961
|
|
|
157
962
|
module.exports = {
|
|
158
963
|
handleTrack,
|
|
964
|
+
handlePostTrack,
|
|
965
|
+
finalizeBashSnapshots,
|
|
966
|
+
pruneBashSnapshots,
|
|
967
|
+
recordBashSessionStop,
|
|
968
|
+
readBashOverlapEvents,
|
|
969
|
+
readBashSessionStops,
|
|
970
|
+
readReadyBashOverlaps,
|
|
971
|
+
compactBashOverlapEvents,
|
|
972
|
+
resolveBashOverlap,
|
|
973
|
+
invalidateBashOverlap,
|
|
974
|
+
hasActiveBashSnapshot,
|
|
975
|
+
acquireBashOverlapRecoveryLock,
|
|
976
|
+
bashOverlapRecoveryLockRef,
|
|
977
|
+
bashSnapshotPath,
|
|
978
|
+
fingerprintTrackedPath,
|
|
979
|
+
claimedPathsBySessions,
|
|
159
980
|
hasTrackedModifications,
|
|
160
981
|
cleanupTracking,
|
|
161
982
|
extractFilePath,
|
|
162
983
|
extractFilePaths,
|
|
984
|
+
extractRawFilePaths,
|
|
163
985
|
readTracking,
|
|
164
986
|
trackingDir,
|
|
165
987
|
trackingPath
|