@searls/turbocommit 0.15.0 → 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,10 +1,11 @@
1
1
  const crypto = require('crypto')
2
+ const { execFileSync } = require('child_process')
2
3
  const fs = require('fs')
3
4
  const path = require('path')
4
5
  const { fileURLToPath } = require('url')
5
6
  const { ensureDir } = require('./io')
6
7
  const { turbocommitDir } = require('./session')
7
- const { canonicalRoot, canonicalTrackedPath, changedPathsInRepository } = require('./git')
8
+ const { canonicalRoot, canonicalTrackedPath, changedPathsInRepository, gitCommonDir } = require('./git')
8
9
 
9
10
  const BASH_SNAPSHOT_TTL_MS = 60 * 60 * 1000
10
11
 
@@ -23,6 +24,11 @@ function trackingPath (root, sessionId) {
23
24
  return dir && path.join(dir, sessionId + '.jsonl')
24
25
  }
25
26
 
27
+ function preclaimDir (root) {
28
+ const base = turbocommitDir(root)
29
+ return base && path.join(base, 'preclaims')
30
+ }
31
+
26
32
  /**
27
33
  * Keys to probe in tool_input for a file path (MCP tools, Write, Edit, etc.)
28
34
  */
@@ -114,10 +120,11 @@ function extractFilePaths (toolName, toolInput, cwd) {
114
120
  }
115
121
 
116
122
  /**
117
- * PreToolUse handler. Appends a tracking entry for potentially-modifying tools.
118
- * 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.
119
126
  */
120
- function handleTrack (input, root) {
127
+ function handleTrack (input, root, opts = {}) {
121
128
  const hookInput = typeof input === 'string' ? parseInput(input) : input
122
129
  root = root || hookInput?.root
123
130
  if (!root || !hookInput) return
@@ -130,6 +137,11 @@ function handleTrack (input, root) {
130
137
 
131
138
  const toolInput = hookInput.toolInput || hookInput.tool_input || {}
132
139
 
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
144
+
133
145
  const cwd = hookInput.cwd || hookInput.raw?.cwd || root
134
146
  const entry = { tool: toolName, t: Date.now(), cwd }
135
147
  const rawFiles = extractRawFilePaths(toolName, toolInput)
@@ -137,21 +149,53 @@ function handleTrack (input, root) {
137
149
  if (rawFiles.length > 0) entry.rawFiles = rawFiles
138
150
  if (files.length > 0) entry.files = files
139
151
 
140
- // For Bash, record the command
141
- if (toolName === 'Bash' && typeof toolInput.command === 'string') {
142
- entry.command = toolInput.command
143
- saveBashSnapshot(root, sessionId, hookInput.toolUseId || hookInput.tool_use_id)
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
144
168
  }
145
169
 
146
- // Skip Bash with no command (malformed input). All other non-Bash tools
147
- // passed the PreToolUse matcher, so they're known modifying tools even if
148
- // we can't extract a specific file path (e.g. MultiEdit nests paths in edits[]).
149
- 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
+ }
150
188
 
151
- const file = trackingPath(root, sessionId)
152
- if (!file) return
153
- ensureDir(path.dirname(file))
154
- fs.appendFileSync(file, JSON.stringify(entry) + '\n')
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
+ }
155
199
  }
156
200
 
157
201
  function handlePostTrack (input, root) {
@@ -163,31 +207,46 @@ function handlePostTrack (input, root) {
163
207
  const toolName = hookInput.toolName || hookInput.tool_name
164
208
  if (!sessionId || toolName !== 'Bash') return
165
209
 
166
- const cwd = hookInput.cwd || hookInput.raw?.cwd || root
167
210
  const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
168
- const snapshot = loadBashSnapshot(root, sessionId, toolUseId)
169
- if (!snapshot) return
170
-
171
- const now = Date.now()
172
- const overlapping = hasOverlappingBashSnapshot(root, snapshot, now)
173
- snapshot.endedAt = now
174
- writeBashSnapshot(root, sessionId, toolUseId, snapshot)
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
+ }
175
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)
176
228
  const before = new Set(snapshot.before.map(canonicalTrackedPath))
177
- const claimed = claimedPathsByOtherSessions(root, sessionId)
178
- const files = overlapping
179
- ? []
180
- : changedPathsInRepository(snapshot.root)
181
- .map(canonicalTrackedPath)
182
- .filter(file => !before.has(file) && !claimed.has(file))
183
-
184
- const entry = { tool: 'Bash', phase: 'post', t: now, cwd }
185
- if (overlapping) entry.overlapping = true
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
+ }
186
242
  if (files.length > 0) {
187
243
  entry.rawFiles = files
188
244
  entry.files = files
189
245
  }
190
- appendTracking(root, sessionId, entry)
246
+ appendTracking(root, snapshot.sessionId, entry)
247
+ snapshot.endedAt = endedAt
248
+ writeBashSnapshot(root, snapshot.sessionId, snapshot.toolUseId, snapshot)
249
+ return true
191
250
  }
192
251
 
193
252
  function bashSnapshotDir (root) {
@@ -199,23 +258,43 @@ function bashSnapshotPath (root, sessionId, toolUseId) {
199
258
  const dir = bashSnapshotDir(root)
200
259
  if (!dir) return null
201
260
  const key = crypto.createHash('sha256')
202
- .update(`${sessionId}\0${toolUseId || 'current'}`)
261
+ .update(`${canonicalRoot(root)}\0${sessionId}\0${toolUseId || 'current'}`)
203
262
  .digest('hex')
204
263
  return path.join(dir, key + '.json')
205
264
  }
206
265
 
207
- function saveBashSnapshot (root, sessionId, toolUseId) {
266
+ function savePendingBashSnapshot (root, sessionId, toolUseId, cwd) {
208
267
  const checkout = canonicalRoot(root)
209
- pruneBashSnapshots(root)
210
268
  writeBashSnapshot(root, sessionId, toolUseId, {
211
269
  root: checkout,
270
+ cwd: cwd || checkout,
212
271
  sessionId,
213
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,
214
286
  startedAt: Date.now(),
215
- before: changedPathsInRepository(checkout)
287
+ before: changedPathsInRepository(checkout),
288
+ pending: false
216
289
  })
217
290
  }
218
291
 
292
+ function removeBashSnapshot (root, sessionId, toolUseId) {
293
+ try {
294
+ fs.unlinkSync(bashSnapshotPath(root, sessionId, toolUseId))
295
+ } catch {}
296
+ }
297
+
219
298
  function writeBashSnapshot (root, sessionId, toolUseId, snapshot) {
220
299
  const file = bashSnapshotPath(root, sessionId, toolUseId)
221
300
  if (!file) return
@@ -237,21 +316,68 @@ function loadBashSnapshot (root, sessionId, toolUseId) {
237
316
  }
238
317
  }
239
318
 
240
- function hasOverlappingBashSnapshot (root, current, now) {
319
+ function overlappingBashSnapshots (root, current, now) {
241
320
  const dir = bashSnapshotDir(root)
242
321
  let files
243
322
  try {
244
323
  files = fs.readdirSync(dir)
245
324
  } catch {
246
- return false
325
+ return []
247
326
  }
248
- return files.some(file => {
327
+ return files.flatMap(file => {
249
328
  try {
250
329
  const other = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
251
- if (other.sessionId === current.sessionId && other.toolUseId === current.toolUseId) return false
252
- if (canonicalRoot(other.root) !== canonicalRoot(current.root)) return false
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 []
253
333
  const endedAt = Number.isFinite(other.endedAt) ? other.endedAt : now
254
- return other.startedAt <= now && endedAt >= current.startedAt
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
255
381
  } catch {
256
382
  return false
257
383
  }
@@ -266,27 +392,47 @@ function pruneBashSnapshots (root) {
266
392
  } catch {
267
393
  return
268
394
  }
395
+ const checkout = canonicalRoot(root)
269
396
  const cutoff = Date.now() - BASH_SNAPSHOT_TTL_MS
397
+ const expired = []
270
398
  for (const file of files) {
271
399
  try {
272
400
  const fullPath = path.join(dir, file)
273
401
  const snapshot = JSON.parse(fs.readFileSync(fullPath, 'utf8'))
274
- if ((snapshot.endedAt || snapshot.startedAt || 0) < cutoff) fs.unlinkSync(fullPath)
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
+ }
275
411
  } catch {}
276
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
+ }
277
423
  }
278
424
 
279
- function claimedPathsByOtherSessions (root, sessionId) {
425
+ function claimedPathsBySessions (root, excludedSessionId) {
280
426
  const result = new Set()
281
427
  const dir = trackingDir(root)
282
428
  let files
283
429
  try {
284
430
  files = fs.readdirSync(dir)
285
431
  } catch {
286
- return result
432
+ files = []
287
433
  }
288
434
  for (const file of files) {
289
- if (file === sessionId + '.jsonl' || !file.endsWith('.jsonl')) continue
435
+ if (file === excludedSessionId + '.jsonl' || !file.endsWith('.jsonl')) continue
290
436
  try {
291
437
  const entries = fs.readFileSync(path.join(dir, file), 'utf8').trim().split('\n')
292
438
  for (const line of entries) {
@@ -296,9 +442,436 @@ function claimedPathsByOtherSessions (root, sessionId) {
296
442
  }
297
443
  } catch {}
298
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
+ }
299
460
  return result
300
461
  }
301
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
+ }
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
+
302
875
  function appendTracking (root, sessionId, entry) {
303
876
  const file = trackingPath(root, sessionId)
304
877
  if (!file) return
@@ -306,6 +879,26 @@ function appendTracking (root, sessionId, entry) {
306
879
  fs.appendFileSync(file, JSON.stringify(entry) + '\n')
307
880
  }
308
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
+
309
902
  function parseInput (input) {
310
903
  try {
311
904
  return JSON.parse(input)
@@ -349,11 +942,41 @@ function cleanupTracking (root, sessionId) {
349
942
  try {
350
943
  fs.unlinkSync(trackingPath(root, sessionId))
351
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
+ }
352
960
  }
353
961
 
354
962
  module.exports = {
355
963
  handleTrack,
356
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,
357
980
  hasTrackedModifications,
358
981
  cleanupTracking,
359
982
  extractFilePath,