@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/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 = ['file_path', 'filePath', 'path', 'file', 'notebook_path']
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
- if (typeof toolInput[key] === 'string' && toolInput[key].length > 0) {
33
- return toolInput[key]
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 extractFilePaths (toolName, toolInput, cwd) {
59
+ function extractRawFilePaths (toolName, toolInput) {
40
60
  const paths = []
41
61
  const seen = new Set()
42
- const add = value => {
43
- if (typeof value !== 'string' || value.length === 0) return
44
- const resolved = path.isAbsolute(value) ? value : path.resolve(cwd || process.cwd(), value)
45
- if (seen.has(resolved)) return
46
- seen.add(resolved)
47
- paths.push(resolved)
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. Appends a tracking entry for potentially-modifying tools.
76
- * Always exits 0 (never blocks tool execution).
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
- const entry = { tool: toolName, t: Date.now() }
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
- // For Bash, record the command
98
- if (toolName === 'Bash' && typeof toolInput.command === 'string') {
99
- entry.command = toolInput.command
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
- // Skip Bash with no command (malformed input). All other non-Bash tools
103
- // passed the PreToolUse matcher, so they're known modifying tools even if
104
- // we can't extract a specific file path (e.g. MultiEdit nests paths in edits[]).
105
- if (toolName === 'Bash' && typeof toolInput.command !== 'string') return
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 entries alone don't count — Bash is too noisy (ls, git status, etc.)
124
- * and we can't reliably distinguish read-only from write commands.
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 => entry.tool !== 'Bash')
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