@searls/turbocommit 0.15.0 → 0.15.2

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