@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/rescue.js ADDED
@@ -0,0 +1,308 @@
1
+ const crypto = require('crypto')
2
+ const { execFileSync } = require('child_process')
3
+ const fs = require('fs')
4
+ const os = require('os')
5
+ const path = require('path')
6
+ const { gitCommonDir } = require('./git')
7
+ const { ensureDir } = require('./io')
8
+
9
+ function createSessionEndRescue (root, hookInput) {
10
+ const gitDir = gitCommonDir(root)
11
+ if (!gitDir || !hookInput?.sessionId) return null
12
+ const id = crypto.randomUUID()
13
+ const ref = `refs/turbocommit/session-end-rescues/${id}`
14
+ const stateDir = path.join(gitDir, 'turbocommit', 'session-end-rescues')
15
+ const index = path.join(stateDir, `${id}.index`)
16
+ const recordPath = path.join(stateDir, `${id}.json`)
17
+ const env = { ...process.env, GIT_INDEX_FILE: index }
18
+ let checkoutIdentity
19
+ try {
20
+ ensureDir(stateDir)
21
+ const rawIndex = gitOutput(root, ['rev-parse', '--git-path', 'index'])
22
+ const realIndex = path.isAbsolute(rawIndex) ? rawIndex : path.resolve(root, rawIndex)
23
+ if (realIndex && fs.existsSync(realIndex)) {
24
+ fs.copyFileSync(realIndex, index)
25
+ } else {
26
+ gitOutput(root, ['read-tree', 'HEAD'], { env })
27
+ }
28
+ gitOutput(root, ['add', '-A'], { env })
29
+ const tree = gitOutput(root, ['write-tree'], { env })
30
+ const head = gitOutput(root, ['rev-parse', 'HEAD'])
31
+ const branch = gitOutput(root, ['branch', '--show-current'])
32
+ const commit = gitOutput(root, ['commit-tree', tree, '-p', head, '-m', 'turbocommit SessionEnd rescue'])
33
+ gitOutput(root, ['update-ref', ref, commit, '0'.repeat(commit.length)])
34
+ checkoutIdentity = createCheckoutIdentity(root, id)
35
+ if (!checkoutIdentity) throw new Error('Could not identify rescued checkout')
36
+ const rescue = {
37
+ id,
38
+ ref,
39
+ commit,
40
+ head,
41
+ branch: branch || null,
42
+ root,
43
+ gitDir,
44
+ checkoutIdentity,
45
+ recordPath,
46
+ hookInput
47
+ }
48
+ writeRecord(recordPath, rescue)
49
+ return rescue
50
+ } catch {
51
+ try { gitOutput(root, ['update-ref', '-d', ref]) } catch {}
52
+ cleanupCheckoutIdentity(checkoutIdentity)
53
+ try { fs.unlinkSync(recordPath) } catch {}
54
+ return null
55
+ } finally {
56
+ try { fs.unlinkSync(index) } catch {}
57
+ }
58
+ }
59
+
60
+ function restoreSessionEndRescue (rescue) {
61
+ if (!rescue?.gitDir || !rescue.root || !rescue.commit) return false
62
+ if (fs.existsSync(rescue.root)) return false
63
+ let created = false
64
+ try {
65
+ ensureDir(path.dirname(rescue.root))
66
+ const args = ['--git-dir', rescue.gitDir, 'worktree', 'add', '--force', '--detach', rescue.root, rescue.head]
67
+ execFileSync('git', args, { cwd: stableGitCwd(rescue.gitDir), stdio: 'ignore' })
68
+ created = true
69
+ gitOutput(rescue.root, ['restore', '--source', rescue.commit, '--worktree', '--', '.'])
70
+ return true
71
+ } catch {
72
+ if (created) removeSessionEndRescueWorktree(rescue)
73
+ return false
74
+ }
75
+ }
76
+
77
+ function matchesSessionEndRescueRoot (rescue, root) {
78
+ if (!rescue?.checkoutIdentity || !root) return false
79
+ try {
80
+ if (canonicalExistingPath(gitCommonDir(root)) !== canonicalExistingPath(rescue.gitDir)) return false
81
+ if (gitOutput(root, ['rev-parse', 'HEAD']) !== rescue.head) return false
82
+ if ((gitOutput(root, ['branch', '--show-current']) || null) !== rescue.branch) return false
83
+ return sameCheckoutIdentity(readCheckoutIdentity(root), rescue.checkoutIdentity) &&
84
+ hasCheckoutIdentityMarker(rescue.checkoutIdentity)
85
+ } catch {
86
+ return false
87
+ }
88
+ }
89
+
90
+ function finalizeRestoredSessionEndRescue (rescue, { clean = false } = {}) {
91
+ const recovered = preserveSessionEndRecoveredCommit(rescue, rescue.root)
92
+ if (!recovered) return { resolved: false, preserved: false }
93
+ const { recoveredCommit, recoveredRef } = recovered
94
+
95
+ if (!clean) {
96
+ recordPreservedSessionEndRescue(rescue, recovered, 'dirty-recovery-worktree')
97
+ return { resolved: false, preserved: true, recoveredCommit, recoveredRef, removeWorktree: false }
98
+ }
99
+
100
+ if (!rescue.branch) {
101
+ recordPreservedSessionEndRescue(rescue, recovered, 'detached-recovered')
102
+ return { resolved: false, preserved: true, recoveredCommit, recoveredRef, removeWorktree: true }
103
+ }
104
+
105
+ if (!removeSessionEndRescueWorktree(rescue)) {
106
+ recordPreservedSessionEndRescue(rescue, recovered, 'restore-worktree-removal-failed')
107
+ return { resolved: false, preserved: true, recoveredCommit, recoveredRef, removeWorktree: false }
108
+ }
109
+
110
+ let attached = false
111
+ try {
112
+ execFileSync('git', ['--git-dir', rescue.gitDir, 'worktree', 'add', rescue.root, rescue.branch], {
113
+ cwd: stableGitCwd(rescue.gitDir),
114
+ stdio: 'ignore'
115
+ })
116
+ attached = true
117
+ if (gitOutput(rescue.root, ['rev-parse', 'HEAD']) !== rescue.head) throw new Error('Branch advanced')
118
+ if (gitOutput(rescue.root, ['branch', '--show-current']) !== rescue.branch) throw new Error('Wrong branch')
119
+ if (!isClean(rescue.root)) throw new Error('Attached worktree is dirty')
120
+ gitOutput(rescue.root, ['merge', '--ff-only', recoveredRef])
121
+ if (gitOutput(rescue.root, ['rev-parse', 'HEAD']) !== recoveredCommit) throw new Error('Branch did not advance')
122
+ return { resolved: true, preserved: true, recoveredCommit, recoveredRef, attached: true }
123
+ } catch {
124
+ if (attached) removeSessionEndRescueWorktree(rescue)
125
+ const status = branchAtCapturedHead(rescue) ? 'branch-unavailable' : 'branch-advanced'
126
+ recordPreservedSessionEndRescue(rescue, recovered, status)
127
+ return { resolved: false, preserved: true, recoveredCommit, recoveredRef, removeWorktree: false }
128
+ }
129
+ }
130
+
131
+ function recordPreservedSessionEndRescue (rescue, recovered, status) {
132
+ try {
133
+ writeRecord(rescue.recordPath, {
134
+ ...rescue,
135
+ ...recovered,
136
+ status
137
+ })
138
+ } catch {}
139
+ }
140
+
141
+ function branchAtCapturedHead (rescue) {
142
+ if (!rescue?.branch || !rescue.gitDir || !rescue.head) return false
143
+ try {
144
+ return gitOutput(stableGitCwd(rescue.gitDir), [
145
+ '--git-dir', rescue.gitDir, 'rev-parse', '--verify', `refs/heads/${rescue.branch}`
146
+ ]) === rescue.head
147
+ } catch {
148
+ return false
149
+ }
150
+ }
151
+
152
+ function preserveSessionEndRecoveredCommit (rescue, root) {
153
+ if (!rescue?.gitDir || !rescue.id || !root) return null
154
+ try {
155
+ const recoveredCommit = gitOutput(root, ['rev-parse', 'HEAD'])
156
+ const recoveredRef = `refs/turbocommit/session-end-recovered/${rescue.id}`
157
+ let current = null
158
+ try {
159
+ current = gitOutput(stableGitCwd(rescue.gitDir), [
160
+ '--git-dir', rescue.gitDir, 'rev-parse', '--verify', recoveredRef
161
+ ])
162
+ } catch {}
163
+ if (current && current !== recoveredCommit) return null
164
+ if (!current) {
165
+ execFileSync('git', [
166
+ '--git-dir', rescue.gitDir,
167
+ 'update-ref', recoveredRef, recoveredCommit, '0'.repeat(recoveredCommit.length)
168
+ ], {
169
+ cwd: stableGitCwd(rescue.gitDir),
170
+ stdio: 'ignore'
171
+ })
172
+ }
173
+ return { recoveredCommit, recoveredRef }
174
+ } catch {
175
+ return null
176
+ }
177
+ }
178
+
179
+ function removeSessionEndRescueWorktree (rescue, { force = false } = {}) {
180
+ if (!rescue?.gitDir || !rescue.root) return false
181
+ try {
182
+ const args = ['--git-dir', rescue.gitDir, 'worktree', 'remove']
183
+ if (force) args.push('--force')
184
+ args.push(rescue.root)
185
+ execFileSync('git', args, {
186
+ cwd: stableGitCwd(rescue.gitDir),
187
+ stdio: 'ignore'
188
+ })
189
+ } catch {}
190
+ return !fs.existsSync(rescue.root)
191
+ }
192
+
193
+ function cleanupSessionEndRescue (rescue, { removeWorktree = false } = {}) {
194
+ if (!rescue) return false
195
+ if (removeWorktree && !removeSessionEndRescueWorktree(rescue)) return false
196
+ if (rescue.gitDir && rescue.ref && rescue.commit) {
197
+ try {
198
+ execFileSync('git', ['--git-dir', rescue.gitDir, 'update-ref', '-d', rescue.ref, rescue.commit], {
199
+ cwd: stableGitCwd(rescue.gitDir),
200
+ stdio: 'ignore'
201
+ })
202
+ } catch {}
203
+ }
204
+ cleanupCheckoutIdentity(rescue.checkoutIdentity)
205
+ try { fs.unlinkSync(rescue.recordPath) } catch {}
206
+ return true
207
+ }
208
+
209
+ function isClean (root) {
210
+ try {
211
+ return gitOutput(root, ['status', '--porcelain']) === ''
212
+ } catch {
213
+ return false
214
+ }
215
+ }
216
+
217
+ function writeRecord (file, rescue) {
218
+ const temporary = file + `.${process.pid}.${crypto.randomUUID()}.tmp`
219
+ try {
220
+ fs.writeFileSync(temporary, JSON.stringify(rescue) + '\n')
221
+ fs.renameSync(temporary, file)
222
+ } finally {
223
+ try { fs.unlinkSync(temporary) } catch {}
224
+ }
225
+ }
226
+
227
+ function readCheckoutIdentity (root) {
228
+ try {
229
+ const rootStat = fs.statSync(root, { bigint: true })
230
+ const rawGitDir = gitOutput(root, ['rev-parse', '--git-dir'])
231
+ const worktreeGitDir = canonicalExistingPath(path.isAbsolute(rawGitDir) ? rawGitDir : path.resolve(root, rawGitDir))
232
+ const gitDirStat = fs.statSync(worktreeGitDir, { bigint: true })
233
+ return {
234
+ rootDevice: rootStat.dev.toString(),
235
+ rootInode: rootStat.ino.toString(),
236
+ worktreeGitDir,
237
+ gitDirDevice: gitDirStat.dev.toString(),
238
+ gitDirInode: gitDirStat.ino.toString()
239
+ }
240
+ } catch {
241
+ return null
242
+ }
243
+ }
244
+
245
+ function createCheckoutIdentity (root, id) {
246
+ const identity = readCheckoutIdentity(root)
247
+ if (!identity) return null
248
+ const markerDir = path.join(identity.worktreeGitDir, 'turbocommit-rescue-identities')
249
+ const markerPath = path.join(markerDir, `${id}.marker`)
250
+ const markerToken = crypto.randomUUID()
251
+ ensureDir(markerDir)
252
+ fs.writeFileSync(markerPath, markerToken + '\n', { flag: 'wx' })
253
+ return { ...identity, markerPath, markerToken }
254
+ }
255
+
256
+ function hasCheckoutIdentityMarker (identity) {
257
+ if (!identity?.markerPath || !identity.markerToken) return false
258
+ try {
259
+ return fs.readFileSync(identity.markerPath, 'utf8').trim() === identity.markerToken
260
+ } catch {
261
+ return false
262
+ }
263
+ }
264
+
265
+ function cleanupCheckoutIdentity (identity) {
266
+ if (!identity?.markerPath) return
267
+ try { fs.unlinkSync(identity.markerPath) } catch {}
268
+ try { fs.rmdirSync(path.dirname(identity.markerPath)) } catch {}
269
+ }
270
+
271
+ function sameCheckoutIdentity (actual, expected) {
272
+ if (!actual || !expected) return false
273
+ return actual.rootDevice === expected.rootDevice &&
274
+ actual.rootInode === expected.rootInode &&
275
+ actual.worktreeGitDir === expected.worktreeGitDir &&
276
+ actual.gitDirDevice === expected.gitDirDevice &&
277
+ actual.gitDirInode === expected.gitDirInode
278
+ }
279
+
280
+ function canonicalExistingPath (filePath) {
281
+ return fs.realpathSync(path.resolve(filePath))
282
+ }
283
+
284
+ function gitOutput (cwd, args, opts = {}) {
285
+ return execFileSync('git', args, {
286
+ cwd,
287
+ env: opts.env || process.env,
288
+ encoding: 'utf8',
289
+ stdio: ['ignore', 'pipe', 'ignore']
290
+ }).trim()
291
+ }
292
+
293
+ function stableGitCwd (gitDir) {
294
+ const parent = path.dirname(gitDir)
295
+ return fs.existsSync(parent) ? parent : os.tmpdir()
296
+ }
297
+
298
+ module.exports = {
299
+ createSessionEndRescue,
300
+ restoreSessionEndRescue,
301
+ matchesSessionEndRescueRoot,
302
+ finalizeRestoredSessionEndRescue,
303
+ preserveSessionEndRecoveredCommit,
304
+ recordPreservedSessionEndRescue,
305
+ removeSessionEndRescueWorktree,
306
+ cleanupSessionEndRescue,
307
+ isClean
308
+ }
package/lib/run.js CHANGED
@@ -4,10 +4,25 @@ const path = require('path')
4
4
  const { loadJson } = require('./io')
5
5
  const { parseTranscript, parseCodexTranscript, formatBody, formatTitleTranscript, extractHeadline, extractModel, extractCodexModel } = require('./transcript')
6
6
  const { runTitleAgent, runBodyAgent } = require('./agent')
7
- const { gitRoot, hasChanges, changedPaths, addAndCommit, stagePaths, commitPaths, commitStagedPaths, stagedChangeContext, currentBranch, pushClean } = require('./git')
7
+ const { gitRoot, hasChanges, changedPaths, addAndCommit, stagePaths, commitPaths, commitStagedPaths, stagedChangeContext, currentBranch, pushClean, hasRepositoryOperation } = require('./git')
8
8
  const { logEvent } = require('./log')
9
9
  const { wrapText } = require('./wrap')
10
- const { hasTrackedModifications, cleanupTracking, readTracking } = require('./track')
10
+ const {
11
+ hasTrackedModifications,
12
+ cleanupTracking,
13
+ readTracking,
14
+ finalizeBashSnapshots,
15
+ recordBashSessionStop,
16
+ readReadyBashOverlaps,
17
+ pruneBashSnapshots,
18
+ compactBashOverlapEvents,
19
+ resolveBashOverlap,
20
+ invalidateBashOverlap,
21
+ hasActiveBashSnapshot,
22
+ acquireBashOverlapRecoveryLock,
23
+ fingerprintTrackedPath,
24
+ claimedPathsBySessions
25
+ } = require('./track')
11
26
  const { redact, buildRedactions } = require('./redact')
12
27
  const { handleSessionEnd, getAncestors, savePending, collectPending, cleanupConsumed, cleanupStale, readWatermark, saveWatermark, resolveParentCommit } = require('./session')
13
28
  const { activeConfig } = require('./config')
@@ -108,8 +123,34 @@ function run (input, opts = {}) {
108
123
  if (!hookInput) return
109
124
 
110
125
  const root = gitRoot(hookInput.cwd || process.cwd())
111
- if (!root || activeConfig(root).config.enabled !== true) return
126
+ if (!root) return
127
+ const { config } = activeConfig(root)
128
+ if (config.enabled !== true) return
129
+ const recoveryDeadline = opts.recoveryDeadline ?? recoveryDeadlineFromWait(opts.recoveryWaitMs ?? 5000)
130
+
131
+ if (hookInput.sessionId) {
132
+ const finalized = finalizeBashSnapshots(root, hookInput.sessionId, Date.now(), remainingRecoveryWait(recoveryDeadline))
133
+ if (finalized == null) return
134
+ recordBashSessionStop(root, hookInput.sessionId)
135
+ }
112
136
 
137
+ try {
138
+ return runTrackedStop(hookInput, root, opts)
139
+ } finally {
140
+ const recoveryConfig = opts.deferPush ? { ...config, push: false } : config
141
+ recoverBashOverlaps(root, hookInput, recoveryConfig, remainingRecoveryWait(recoveryDeadline))
142
+ }
143
+ }
144
+
145
+ function recoveryDeadlineFromWait (waitMs) {
146
+ return waitMs === Infinity ? Infinity : Date.now() + waitMs
147
+ }
148
+
149
+ function remainingRecoveryWait (deadline) {
150
+ return deadline === Infinity ? Infinity : Math.max(0, deadline - Date.now())
151
+ }
152
+
153
+ function runTrackedStop (hookInput, root, opts) {
113
154
  const entries = hookInput.sessionId ? readTracking(root, hookInput.sessionId) : []
114
155
  const tracked = trackedChangesFromEntries(root, entries)
115
156
  const manifest = loadManifest(root)
@@ -128,12 +169,84 @@ function run (input, opts = {}) {
128
169
  (tracked.repos.length !== 1 || tracked.repos[0].root !== root)
129
170
 
130
171
  if (tracked.ambiguous.length > 0) {
131
- return runSingle(hookInput, { paths: [], trackingReason: 'ambiguous-tracking' })
172
+ return runSingle(hookInput, { paths: [], trackingReason: 'ambiguous-tracking', operationDeadline: opts.operationDeadline })
132
173
  }
133
174
  if (manifest.repos.length > 0 || tracked.repos.length > 1 || hasCrossRootPath) {
134
- return runMulti(hookInput, root, tracked.repos, manifest)
175
+ return runMulti(hookInput, root, tracked.repos, manifest, opts)
176
+ }
177
+ return runSingle(hookInput, { paths: tracked.repos[0]?.paths || [], operationDeadline: opts.operationDeadline })
178
+ }
179
+
180
+ function recoverBashOverlaps (root, hookInput, config, waitMs = 5000) {
181
+ if (process.env.TURBOCOMMIT_DISABLED || !root) return
182
+ hookInput = hookInput || {}
183
+ config = config || activeConfig(root).config
184
+ if (config.enabled !== true) return
185
+ const release = acquireBashOverlapRecoveryLock(root, waitMs)
186
+ if (!release) return
187
+ let shouldPush = false
188
+ try {
189
+ pruneBashSnapshots(root)
190
+ if (hasActiveBashSnapshot(root) || hasRepositoryOperation(root)) return
191
+ for (const overlap of readReadyBashOverlaps(root)) {
192
+ const claimed = claimedPathsBySessions(root)
193
+ const changedFingerprint = overlap.paths.some(item =>
194
+ item.fingerprint == null || fingerprintTrackedPath(item.path) !== item.fingerprint)
195
+ const claimedPath = overlap.paths.some(item => claimed.has(item.path))
196
+ if (changedFingerprint || claimedPath) {
197
+ logEvent('skip', {
198
+ harness: hookInput.harness,
199
+ project: path.basename(root),
200
+ branch: currentBranch(root),
201
+ reason: changedFingerprint ? 'bash-overlap-changed' : 'bash-overlap-claimed',
202
+ paths: overlap.paths.length
203
+ })
204
+ if (changedFingerprint) {
205
+ invalidateBashOverlap(root, overlap.eventIds, 'changed-fingerprint')
206
+ }
207
+ continue
208
+ }
209
+
210
+ const paths = changedPaths(root, overlap.paths.map(item => item.path))
211
+ if (paths.length === 0) {
212
+ resolveBashOverlap(root, overlap.eventIds)
213
+ continue
214
+ }
215
+
216
+ const title = 'Recover overlapping shell changes'
217
+ const body = 'Recovered unchanged paths observed during overlapping shell commands.\n\n' +
218
+ overlap.sessionIds.map(sessionId => `Session: ${sessionId}`).join('\n')
219
+ const commit = commitPaths(root, paths, title, body, { pathScoped: true })
220
+ if (!commit) continue
221
+
222
+ logEvent('success', {
223
+ harness: hookInput.harness,
224
+ project: path.basename(root),
225
+ branch: currentBranch(root),
226
+ title,
227
+ recovered: paths.length
228
+ })
229
+ if (config.push === true) shouldPush = true
230
+ resolveBashOverlap(root, overlap.eventIds)
231
+ }
232
+ } catch {
233
+ logEvent('fail', {
234
+ harness: hookInput.harness,
235
+ project: path.basename(root),
236
+ branch: currentBranch(root),
237
+ reason: 'bash-overlap-recovery'
238
+ })
239
+ } finally {
240
+ try { compactBashOverlapEvents(root) } catch {}
241
+ release()
242
+ }
243
+ if (shouldPush) {
244
+ logEvent(pushClean(root) ? 'push' : 'push-fail', {
245
+ harness: hookInput.harness,
246
+ project: path.basename(root),
247
+ branch: currentBranch(root)
248
+ })
135
249
  }
136
- return runSingle(hookInput, { paths: tracked.repos[0]?.paths || [] })
137
250
  }
138
251
 
139
252
  function runSingle (input, opts = {}) {
@@ -186,6 +299,7 @@ function runSingle (input, opts = {}) {
186
299
  if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
187
300
  savePending(root, sessionId, formatBody(effectivePairs))
188
301
  }
302
+ cleanupTracking(root, sessionId)
189
303
  logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-tracking' })
190
304
  cleanupStale(root)
191
305
  return
@@ -230,14 +344,14 @@ function runSingle (input, opts = {}) {
230
344
  let headline
231
345
  if (config.title?.type !== 'transcript') {
232
346
  const titleTranscript = formatTitleTranscript(effectivePairs)
233
- headline = runTitleAgent(root, config.title || {}, titleTranscript, hookInput.harness)
347
+ headline = runTitleAgent(root, config.title || {}, titleTranscript, hookInput.harness, { deadline: opts.operationDeadline })
234
348
  }
235
349
  headline = headline || extractHeadline(effectivePairs)
236
350
 
237
351
  // Body: transcript by default, agent if opted in
238
352
  let body
239
353
  if (config.body?.type === 'agent') {
240
- body = runBodyAgent(root, config.body, formattedTranscript, hookInput.harness)
354
+ body = runBodyAgent(root, config.body, formattedTranscript, hookInput.harness, { deadline: opts.operationDeadline })
241
355
  }
242
356
  body = body || formattedTranscript
243
357
 
@@ -296,7 +410,7 @@ function runSingle (input, opts = {}) {
296
410
 
297
411
  logEvent('success', { harness: hookInput.harness, project, branch, context, title: safeHeadline })
298
412
 
299
- if (config.push === true) {
413
+ if (config.push === true && !opts.deferPush) {
300
414
  if (pushClean(root)) {
301
415
  logEvent('push', { harness: hookInput.harness, project, branch })
302
416
  } else {
@@ -318,7 +432,7 @@ function runSingle (input, opts = {}) {
318
432
  }
319
433
  }
320
434
 
321
- function runMulti (hookInput, anchor, currentRepos, manifest) {
435
+ function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
322
436
  recordCodexStop(hookInput, anchor)
323
437
 
324
438
  const transcriptPath = resolveTranscriptPath(hookInput)
@@ -429,13 +543,13 @@ function runMulti (hookInput, anchor, currentRepos, manifest) {
429
543
 
430
544
  let headline
431
545
  if (config.title?.type !== 'transcript') {
432
- headline = runTitleAgent(root, config.title || {}, titleInput, hookInput.harness)
546
+ headline = runTitleAgent(root, config.title || {}, titleInput, hookInput.harness, { deadline: opts.operationDeadline })
433
547
  }
434
548
  headline = headline || extractHeadline(effectivePairs)
435
549
 
436
550
  let body
437
551
  if (config.body?.type === 'agent') {
438
- body = runBodyAgent(root, config.body, rawBody, hookInput.harness)
552
+ body = runBodyAgent(root, config.body, rawBody, hookInput.harness, { deadline: opts.operationDeadline })
439
553
  }
440
554
  body = body || rawBody
441
555
 
@@ -485,6 +599,7 @@ function runMulti (hookInput, anchor, currentRepos, manifest) {
485
599
 
486
600
  for (const success of successes) {
487
601
  if (success.config.push !== true) continue
602
+ if (opts.deferPush && success.root === anchor) continue
488
603
  if (pushClean(success.root)) {
489
604
  logEvent('push', {
490
605
  harness: hookInput.harness,
@@ -565,4 +680,4 @@ function recordCodexStop (hookInput, root) {
565
680
  if (hookInput.harness === 'codex' && hookInput.event === 'Stop') handleSessionEnd(hookInput, root)
566
681
  }
567
682
 
568
- module.exports = { run, runPreCompact, formatModelName, resolveCoauthor, readClaudeAttribution, resolveTranscriptPath, recordCodexStop }
683
+ module.exports = { run, runPreCompact, recoverBashOverlaps, formatModelName, resolveCoauthor, readClaudeAttribution, resolveTranscriptPath, recordCodexStop }
package/lib/session.js CHANGED
@@ -257,7 +257,7 @@ function cleanupStale (root, maxAgeMs) {
257
257
  const base = turbocommitDir(root)
258
258
  if (!base) return
259
259
 
260
- for (const sub of ['breadcrumbs', 'chains', 'tracking', 'watermarks']) {
260
+ for (const sub of ['breadcrumbs', 'chains', 'watermarks']) {
261
261
  const dir = path.join(base, sub)
262
262
  let files
263
263
  try {
@@ -276,6 +276,8 @@ function cleanupStale (root, maxAgeMs) {
276
276
  }
277
277
  }
278
278
 
279
+ cleanupStaleTracking(root, now, ttl)
280
+
279
281
  // Clean stale pending directories
280
282
  const pDir = path.join(base, 'pending')
281
283
  let pdirs
@@ -300,6 +302,43 @@ function cleanupStale (root, maxAgeMs) {
300
302
  }
301
303
  }
302
304
 
305
+ function cleanupStaleTracking (root, now, ttl) {
306
+ const dir = path.join(turbocommitDir(root), 'tracking')
307
+ let files
308
+ try {
309
+ files = fs.readdirSync(dir)
310
+ } catch {
311
+ return
312
+ }
313
+ for (const file of files) {
314
+ const fullPath = path.join(dir, file)
315
+ try {
316
+ const stat = fs.statSync(fullPath)
317
+ if (now - stat.mtimeMs <= ttl || trackingMayDescribeModifications(fullPath)) continue
318
+ fs.unlinkSync(fullPath)
319
+ } catch {}
320
+ }
321
+ }
322
+
323
+ function trackingMayDescribeModifications (file) {
324
+ let lines
325
+ try {
326
+ const data = fs.readFileSync(file, 'utf8').trim()
327
+ lines = data ? data.split('\n') : []
328
+ } catch {
329
+ return true
330
+ }
331
+ return lines.some(line => {
332
+ try {
333
+ const entry = JSON.parse(line)
334
+ return entry.tool !== 'Bash' ||
335
+ (entry.phase === 'post' && Array.isArray(entry.files) && entry.files.length > 0)
336
+ } catch {
337
+ return true
338
+ }
339
+ })
340
+ }
341
+
303
342
  module.exports = {
304
343
  handleSessionEnd,
305
344
  handleSessionStart,