@searls/turbocommit 0.15.2 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/lib/install.js +3 -3
- package/lib/run.js +6 -1
- package/lib/session.js +2 -3
- package/lib/shell.js +70 -0
- package/lib/track.js +123 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,8 +25,14 @@ turbocommit registers hooks with the harnesses you use:
|
|
|
25
25
|
|
|
26
26
|
- **PreToolUse** tentatively claims the paths supplied to editing tools while
|
|
27
27
|
recovery finishes, promotes the claim only when the tool may proceed, and
|
|
28
|
-
snapshots the repository before shell commands.
|
|
29
|
-
- **PostToolUse** attributes paths that became dirty during a shell command
|
|
28
|
+
snapshots the repository before shell commands and MCP tool calls.
|
|
29
|
+
- **PostToolUse** attributes paths that became dirty during a shell command or
|
|
30
|
+
MCP tool call, so files a tool rewrites without naming them (an Xcode project
|
|
31
|
+
file, for example) belong to the session that changed them. A shell command
|
|
32
|
+
that names a path inside another enabled checkout (absolute, `~`-relative, or
|
|
33
|
+
relative to a directory the command mentions) is snapshotted in that checkout
|
|
34
|
+
too, so a pin bump made with `sed` in a sibling repository is attributed and
|
|
35
|
+
committed there.
|
|
30
36
|
Concurrent commands in one session share ownership. Commands from different
|
|
31
37
|
sessions retain hashed overlap evidence instead of claiming each other's
|
|
32
38
|
paths. Once every involved turn stops and no shell remains active in that
|
|
@@ -59,8 +65,8 @@ turbocommit registers hooks with the harnesses you use:
|
|
|
59
65
|
A session that starts in an enabled repository can commit changes in other
|
|
60
66
|
enabled local checkouts during the same turn. Turbocommit discovers each
|
|
61
67
|
checkout from explicit file paths supplied to Claude Code tools, Codex
|
|
62
|
-
`apply_patch`, and path-bearing MCP tools
|
|
63
|
-
|
|
68
|
+
`apply_patch`, and path-bearing MCP tools, and from paths named in shell
|
|
69
|
+
commands that resolve inside another enabled checkout.
|
|
64
70
|
|
|
65
71
|
Each touched checkout:
|
|
66
72
|
|
package/lib/install.js
CHANGED
|
@@ -11,11 +11,11 @@ const HOOK_DEFS = {
|
|
|
11
11
|
hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use --harness claude' }]
|
|
12
12
|
},
|
|
13
13
|
PostToolUse: {
|
|
14
|
-
matcher: 'Bash',
|
|
14
|
+
matcher: 'Bash|mcp__.*',
|
|
15
15
|
hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness claude' }]
|
|
16
16
|
},
|
|
17
17
|
PostToolUseFailure: {
|
|
18
|
-
matcher: 'Bash',
|
|
18
|
+
matcher: 'Bash|mcp__.*',
|
|
19
19
|
hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness claude' }]
|
|
20
20
|
},
|
|
21
21
|
SessionStart: {
|
|
@@ -35,7 +35,7 @@ const CODEX_HOOK_DEFS = {
|
|
|
35
35
|
hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use --harness codex' }]
|
|
36
36
|
},
|
|
37
37
|
PostToolUse: {
|
|
38
|
-
matcher: 'Bash',
|
|
38
|
+
matcher: 'Bash|mcp__.*',
|
|
39
39
|
hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness codex' }]
|
|
40
40
|
},
|
|
41
41
|
SessionStart: {
|
package/lib/run.js
CHANGED
|
@@ -21,7 +21,8 @@ const {
|
|
|
21
21
|
hasActiveBashSnapshot,
|
|
22
22
|
acquireBashOverlapRecoveryLock,
|
|
23
23
|
fingerprintTrackedPath,
|
|
24
|
-
claimedPathsBySessions
|
|
24
|
+
claimedPathsBySessions,
|
|
25
|
+
trackedShellCheckouts
|
|
25
26
|
} = require('./track')
|
|
26
27
|
const { redact, buildRedactions } = require('./redact')
|
|
27
28
|
const { handleSessionEnd, getAncestors, savePending, collectPending, cleanupConsumed, cleanupStale, readWatermark, saveWatermark, resolveParentCommit } = require('./session')
|
|
@@ -128,6 +129,7 @@ function run (input, opts = {}) {
|
|
|
128
129
|
if (config.enabled !== true) return
|
|
129
130
|
const recoveryDeadline = opts.recoveryDeadline ?? recoveryDeadlineFromWait(opts.recoveryWaitMs ?? 5000)
|
|
130
131
|
|
|
132
|
+
const checkouts = hookInput.sessionId ? trackedShellCheckouts(root, hookInput.sessionId) : []
|
|
131
133
|
if (hookInput.sessionId) {
|
|
132
134
|
const finalized = finalizeBashSnapshots(root, hookInput.sessionId, Date.now(), remainingRecoveryWait(recoveryDeadline))
|
|
133
135
|
if (finalized == null) return
|
|
@@ -139,6 +141,9 @@ function run (input, opts = {}) {
|
|
|
139
141
|
} finally {
|
|
140
142
|
const recoveryConfig = opts.deferPush ? { ...config, push: false } : config
|
|
141
143
|
recoverBashOverlaps(root, hookInput, recoveryConfig, remainingRecoveryWait(recoveryDeadline))
|
|
144
|
+
for (const checkout of checkouts) {
|
|
145
|
+
recoverBashOverlaps(checkout, hookInput, undefined, remainingRecoveryWait(recoveryDeadline))
|
|
146
|
+
}
|
|
142
147
|
}
|
|
143
148
|
}
|
|
144
149
|
|
package/lib/session.js
CHANGED
|
@@ -328,11 +328,10 @@ function trackingMayDescribeModifications (file) {
|
|
|
328
328
|
} catch {
|
|
329
329
|
return true
|
|
330
330
|
}
|
|
331
|
+
const { describesModification } = require('./track')
|
|
331
332
|
return lines.some(line => {
|
|
332
333
|
try {
|
|
333
|
-
|
|
334
|
-
return entry.tool !== 'Bash' ||
|
|
335
|
-
(entry.phase === 'post' && Array.isArray(entry.files) && entry.files.length > 0)
|
|
334
|
+
return describesModification(JSON.parse(line))
|
|
336
335
|
} catch {
|
|
337
336
|
return true
|
|
338
337
|
}
|
package/lib/shell.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const os = require('os')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { canonicalRoot, gitRootForPath } = require('./git')
|
|
5
|
+
const { activeConfig } = require('./config')
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Enabled checkouts other than the anchor that a shell command names.
|
|
9
|
+
*
|
|
10
|
+
* A command can `cd` anywhere before it edits, so every word that resolves to
|
|
11
|
+
* an existing path is probed for the repository containing it. Relative words
|
|
12
|
+
* are resolved against the command's working directory and against every
|
|
13
|
+
* absolute directory the command mentions, which is how a loop such as
|
|
14
|
+
* `cd ~/code && for r in app/Core lib/Core; do ...` reaches each checkout.
|
|
15
|
+
*/
|
|
16
|
+
function shellCheckouts (command, cwd, anchor) {
|
|
17
|
+
if (typeof command !== 'string' || !command) return []
|
|
18
|
+
const anchorRoot = canonicalRoot(anchor)
|
|
19
|
+
const words = shellWords(command)
|
|
20
|
+
const bases = [cwd, ...words.filter(word => path.isAbsolute(word) && isDirectory(word))]
|
|
21
|
+
|
|
22
|
+
const candidates = new Set()
|
|
23
|
+
for (const word of words) {
|
|
24
|
+
const resolved = path.isAbsolute(word)
|
|
25
|
+
? [word]
|
|
26
|
+
: bases.map(base => path.resolve(base, word))
|
|
27
|
+
for (const candidate of resolved) {
|
|
28
|
+
if (!fs.existsSync(candidate)) continue
|
|
29
|
+
const canonical = canonicalRoot(candidate)
|
|
30
|
+
if (isInside(canonical, anchorRoot) || canonical.startsWith('/dev/')) continue
|
|
31
|
+
candidates.add(isDirectory(canonical) ? canonical : path.dirname(canonical))
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rootsByDir = new Map()
|
|
36
|
+
const checkouts = []
|
|
37
|
+
for (const dir of candidates) {
|
|
38
|
+
if (!rootsByDir.has(dir)) rootsByDir.set(dir, gitRootForPath(dir))
|
|
39
|
+
const root = rootsByDir.get(dir)
|
|
40
|
+
if (!root || root === anchorRoot || checkouts.includes(root)) continue
|
|
41
|
+
if (activeConfig(root).config.enabled !== true) continue
|
|
42
|
+
checkouts.push(root)
|
|
43
|
+
}
|
|
44
|
+
return checkouts
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function shellWords (command) {
|
|
48
|
+
const home = os.homedir()
|
|
49
|
+
return command.split(/[\s;|&()<>`"'=]+/).flatMap(word => {
|
|
50
|
+
word = word.replace(/[,:.]+$/, '')
|
|
51
|
+
if (word === '~' || word.startsWith('~/')) word = home + word.slice(1)
|
|
52
|
+
if (!word || word.startsWith('-') || word.length > 1024) return []
|
|
53
|
+
if (/[$*?{}[\]\\]/.test(word)) return []
|
|
54
|
+
return [word]
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isDirectory (file) {
|
|
59
|
+
try {
|
|
60
|
+
return fs.statSync(file).isDirectory()
|
|
61
|
+
} catch {
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isInside (file, root) {
|
|
67
|
+
return file === root || file.startsWith(root + path.sep)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { shellCheckouts }
|
package/lib/track.js
CHANGED
|
@@ -6,6 +6,7 @@ const { fileURLToPath } = require('url')
|
|
|
6
6
|
const { ensureDir } = require('./io')
|
|
7
7
|
const { turbocommitDir } = require('./session')
|
|
8
8
|
const { canonicalRoot, canonicalTrackedPath, changedPathsInRepository, gitCommonDir } = require('./git')
|
|
9
|
+
const { shellCheckouts } = require('./shell')
|
|
9
10
|
|
|
10
11
|
const BASH_SNAPSHOT_TTL_MS = 60 * 60 * 1000
|
|
11
12
|
// A session's tracking file is rewritten on every tool call and deleted when
|
|
@@ -45,6 +46,8 @@ const FILE_PATH_KEYS = [
|
|
|
45
46
|
'notebook_path',
|
|
46
47
|
'relative_path',
|
|
47
48
|
'relativePath',
|
|
49
|
+
'sourcePath',
|
|
50
|
+
'destinationPath',
|
|
48
51
|
'uri'
|
|
49
52
|
]
|
|
50
53
|
const FILE_PATH_ARRAY_KEYS = ['file_paths', 'filePaths', 'paths', 'files']
|
|
@@ -124,6 +127,15 @@ function extractFilePaths (toolName, toolInput, cwd) {
|
|
|
124
127
|
})
|
|
125
128
|
}
|
|
126
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Bash and MCP tools change paths they never name (a shell script, an Xcode
|
|
132
|
+
* project file rewritten as a side effect), so both are attributed by
|
|
133
|
+
* snapshotting the checkout before and after the call.
|
|
134
|
+
*/
|
|
135
|
+
function snapshotsRepository (toolName) {
|
|
136
|
+
return toolName === 'Bash' || (typeof toolName === 'string' && toolName.startsWith('mcp__'))
|
|
137
|
+
}
|
|
138
|
+
|
|
127
139
|
/**
|
|
128
140
|
* PreToolUse handler. Persists ownership before waiting for overlap recovery.
|
|
129
141
|
* Returns false when the caller must deny the tool rather than let it run
|
|
@@ -155,38 +167,45 @@ function handleTrack (input, root, opts = {}) {
|
|
|
155
167
|
if (files.length > 0) entry.files = files
|
|
156
168
|
|
|
157
169
|
const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
|
|
170
|
+
// A shell command can edit any checkout it names, so each of those is
|
|
171
|
+
// snapshotted alongside the anchor and attributed back to this session.
|
|
172
|
+
const checkouts = toolName === 'Bash' ? shellCheckouts(toolInput.command, cwd, root) : []
|
|
173
|
+
const snapshotRoots = [root, ...checkouts]
|
|
174
|
+
const removeSnapshots = () => {
|
|
175
|
+
for (const checkout of snapshotRoots) removeBashSnapshot(checkout, sessionId, toolUseId)
|
|
176
|
+
}
|
|
158
177
|
let pendingSnapshot = false
|
|
159
178
|
let preclaim = null
|
|
160
179
|
try {
|
|
161
|
-
if (toolName
|
|
162
|
-
entry.command = toolInput.command
|
|
163
|
-
|
|
180
|
+
if (snapshotsRepository(toolName)) {
|
|
181
|
+
if (toolName === 'Bash') entry.command = toolInput.command
|
|
182
|
+
if (checkouts.length > 0) entry.checkouts = checkouts
|
|
164
183
|
pendingSnapshot = true
|
|
184
|
+
for (const checkout of snapshotRoots) {
|
|
185
|
+
savePendingBashSnapshot(checkout, sessionId, toolUseId, cwd, toolName, checkout === root ? checkouts : [])
|
|
186
|
+
}
|
|
165
187
|
appendTracking(root, sessionId, entry)
|
|
166
188
|
} else {
|
|
167
189
|
preclaim = savePreclaim(root, sessionId, entry)
|
|
168
190
|
}
|
|
169
191
|
} catch (error) {
|
|
170
|
-
if (pendingSnapshot)
|
|
192
|
+
if (pendingSnapshot) removeSnapshots()
|
|
171
193
|
if (preclaim) removePreclaim(preclaim)
|
|
172
194
|
throw error
|
|
173
195
|
}
|
|
174
196
|
|
|
175
197
|
const recoveryWaitMs = opts.recoveryWaitMs ?? 5000
|
|
176
198
|
if (pendingSnapshot) {
|
|
177
|
-
const release = acquireBashOverlapRecoveryLock(root, recoveryWaitMs)
|
|
178
|
-
if (!release) {
|
|
179
|
-
removeBashSnapshot(root, sessionId, toolUseId)
|
|
180
|
-
return false
|
|
181
|
-
}
|
|
182
199
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
200
|
+
for (const checkout of snapshotRoots) {
|
|
201
|
+
if (!startBashSnapshot(checkout, sessionId, toolUseId, cwd, recoveryWaitMs)) {
|
|
202
|
+
removeSnapshots()
|
|
203
|
+
return false
|
|
204
|
+
}
|
|
205
|
+
}
|
|
185
206
|
} catch (error) {
|
|
186
|
-
|
|
207
|
+
removeSnapshots()
|
|
187
208
|
throw error
|
|
188
|
-
} finally {
|
|
189
|
-
release()
|
|
190
209
|
}
|
|
191
210
|
return true
|
|
192
211
|
}
|
|
@@ -210,24 +229,52 @@ function handlePostTrack (input, root) {
|
|
|
210
229
|
|
|
211
230
|
const sessionId = hookInput.sessionId || hookInput.session_id
|
|
212
231
|
const toolName = hookInput.toolName || hookInput.tool_name
|
|
213
|
-
if (!sessionId || toolName
|
|
232
|
+
if (!sessionId || !snapshotsRepository(toolName)) return
|
|
214
233
|
|
|
215
234
|
const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
|
|
216
|
-
const
|
|
217
|
-
|
|
235
|
+
const cwd = hookInput.cwd || hookInput.raw?.cwd
|
|
236
|
+
const snapshot = completeBashSnapshot(root, root, sessionId, toolUseId, cwd)
|
|
237
|
+
if (!snapshot) return
|
|
238
|
+
for (const checkout of Array.isArray(snapshot.checkouts) ? snapshot.checkouts : []) {
|
|
239
|
+
completeBashSnapshot(checkout, root, sessionId, toolUseId, cwd)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function startBashSnapshot (root, sessionId, toolUseId, cwd, waitMs) {
|
|
244
|
+
const release = acquireBashOverlapRecoveryLock(root, waitMs)
|
|
245
|
+
if (!release) return false
|
|
246
|
+
try {
|
|
247
|
+
pruneBashSnapshots(root)
|
|
248
|
+
initializeBashSnapshot(root, sessionId, toolUseId, cwd)
|
|
249
|
+
return true
|
|
250
|
+
} finally {
|
|
251
|
+
release()
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function completeBashSnapshot (root, trackingRoot, sessionId, toolUseId, cwd, waitMs = 5000) {
|
|
256
|
+
const release = acquireBashOverlapRecoveryLock(root, waitMs)
|
|
257
|
+
if (!release) return null
|
|
218
258
|
try {
|
|
219
259
|
const snapshot = loadBashSnapshot(root, sessionId, toolUseId)
|
|
220
|
-
if (!snapshot || Number.isFinite(snapshot.endedAt)) return
|
|
260
|
+
if (!snapshot || Number.isFinite(snapshot.endedAt)) return null
|
|
221
261
|
finishBashSnapshot(root, snapshot, {
|
|
222
|
-
cwd:
|
|
223
|
-
endedAt: Date.now()
|
|
262
|
+
cwd: cwd || snapshot.cwd || root,
|
|
263
|
+
endedAt: Date.now(),
|
|
264
|
+
trackingRoot
|
|
224
265
|
})
|
|
266
|
+
return snapshot
|
|
225
267
|
} finally {
|
|
226
268
|
release()
|
|
227
269
|
}
|
|
228
270
|
}
|
|
229
271
|
|
|
230
|
-
|
|
272
|
+
/**
|
|
273
|
+
* Attributes the paths a finished shell snapshot made dirty in `root` to the
|
|
274
|
+
* session's tracking file in `trackingRoot`, which is the session's anchor
|
|
275
|
+
* checkout when the command reached into another repository.
|
|
276
|
+
*/
|
|
277
|
+
function finishBashSnapshot (root, snapshot, { cwd, endedAt, trackingRoot = root }) {
|
|
231
278
|
if (snapshot.pending === true || !Array.isArray(snapshot.before) || !Number.isFinite(snapshot.startedAt)) return false
|
|
232
279
|
const overlaps = overlappingBashSnapshots(root, snapshot, endedAt)
|
|
233
280
|
const before = new Set(snapshot.before.map(canonicalTrackedPath))
|
|
@@ -239,7 +286,7 @@ function finishBashSnapshot (root, snapshot, { cwd, endedAt }) {
|
|
|
239
286
|
const candidates = changed.filter(file => !before.has(file) && !claimed.has(file))
|
|
240
287
|
const files = overlapping ? [] : candidates
|
|
241
288
|
|
|
242
|
-
const entry = { tool: 'Bash', phase: 'post', t: endedAt, cwd }
|
|
289
|
+
const entry = { tool: snapshot.toolName || 'Bash', phase: 'post', t: endedAt, cwd }
|
|
243
290
|
if (overlapping) {
|
|
244
291
|
entry.overlapping = true
|
|
245
292
|
entry.overlapEventId = recordBashOverlap(root, snapshot, overlaps, candidates, endedAt)
|
|
@@ -248,7 +295,7 @@ function finishBashSnapshot (root, snapshot, { cwd, endedAt }) {
|
|
|
248
295
|
entry.rawFiles = files
|
|
249
296
|
entry.files = files
|
|
250
297
|
}
|
|
251
|
-
appendTracking(
|
|
298
|
+
appendTracking(trackingRoot, snapshot.sessionId, entry)
|
|
252
299
|
snapshot.endedAt = endedAt
|
|
253
300
|
writeBashSnapshot(root, snapshot.sessionId, snapshot.toolUseId, snapshot)
|
|
254
301
|
return true
|
|
@@ -268,15 +315,17 @@ function bashSnapshotPath (root, sessionId, toolUseId) {
|
|
|
268
315
|
return path.join(dir, key + '.json')
|
|
269
316
|
}
|
|
270
317
|
|
|
271
|
-
function savePendingBashSnapshot (root, sessionId, toolUseId, cwd) {
|
|
318
|
+
function savePendingBashSnapshot (root, sessionId, toolUseId, cwd, toolName = 'Bash', checkouts = []) {
|
|
272
319
|
const checkout = canonicalRoot(root)
|
|
273
320
|
writeBashSnapshot(root, sessionId, toolUseId, {
|
|
274
321
|
root: checkout,
|
|
275
322
|
cwd: cwd || checkout,
|
|
276
323
|
sessionId,
|
|
324
|
+
toolName,
|
|
277
325
|
toolUseId: toolUseId || null,
|
|
278
326
|
createdAt: Date.now(),
|
|
279
|
-
pending: true
|
|
327
|
+
pending: true,
|
|
328
|
+
...(checkouts.length > 0 ? { checkouts } : {})
|
|
280
329
|
})
|
|
281
330
|
}
|
|
282
331
|
|
|
@@ -343,7 +392,22 @@ function overlappingBashSnapshots (root, current, now) {
|
|
|
343
392
|
})
|
|
344
393
|
}
|
|
345
394
|
|
|
395
|
+
/**
|
|
396
|
+
* Finishes every shell snapshot the session left unfinished in its anchor and
|
|
397
|
+
* in each checkout its shell commands named. Returns null when a recovery lock
|
|
398
|
+
* could not be acquired in time.
|
|
399
|
+
*/
|
|
346
400
|
function finalizeBashSnapshots (root, sessionId, endedAt = Date.now(), waitMs = 5000) {
|
|
401
|
+
let finalized = 0
|
|
402
|
+
for (const checkout of [root, ...trackedShellCheckouts(root, sessionId)]) {
|
|
403
|
+
const count = finalizeBashSnapshotsIn(checkout, root, sessionId, endedAt, waitMs)
|
|
404
|
+
if (count == null) return null
|
|
405
|
+
finalized += count
|
|
406
|
+
}
|
|
407
|
+
return finalized
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function finalizeBashSnapshotsIn (root, trackingRoot, sessionId, endedAt, waitMs) {
|
|
347
411
|
const files = unfinishedBashSnapshotFiles(root, sessionId)
|
|
348
412
|
if (files.length === 0) return 0
|
|
349
413
|
const release = acquireBashOverlapRecoveryLock(root, waitMs)
|
|
@@ -357,7 +421,8 @@ function finalizeBashSnapshots (root, sessionId, endedAt = Date.now(), waitMs =
|
|
|
357
421
|
if (canonicalRoot(snapshot.root) !== canonicalRoot(root)) continue
|
|
358
422
|
if (finishBashSnapshot(root, snapshot, {
|
|
359
423
|
cwd: snapshot.cwd || snapshot.root || root,
|
|
360
|
-
endedAt
|
|
424
|
+
endedAt,
|
|
425
|
+
trackingRoot
|
|
361
426
|
})) finalized++
|
|
362
427
|
} catch {}
|
|
363
428
|
}
|
|
@@ -367,6 +432,22 @@ function finalizeBashSnapshots (root, sessionId, endedAt = Date.now(), waitMs =
|
|
|
367
432
|
}
|
|
368
433
|
}
|
|
369
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Other checkouts the session's shell commands named, read from its tracking
|
|
437
|
+
* file in the anchor checkout.
|
|
438
|
+
*/
|
|
439
|
+
function trackedShellCheckouts (root, sessionId) {
|
|
440
|
+
const anchor = canonicalRoot(root)
|
|
441
|
+
const checkouts = []
|
|
442
|
+
for (const entry of readTracking(root, sessionId)) {
|
|
443
|
+
for (const checkout of Array.isArray(entry.checkouts) ? entry.checkouts : []) {
|
|
444
|
+
const canonical = canonicalRoot(checkout)
|
|
445
|
+
if (canonical !== anchor && !checkouts.includes(canonical)) checkouts.push(canonical)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return checkouts
|
|
449
|
+
}
|
|
450
|
+
|
|
370
451
|
function unfinishedBashSnapshotFiles (root, sessionId) {
|
|
371
452
|
const dir = bashSnapshotDir(root)
|
|
372
453
|
let files
|
|
@@ -534,6 +615,13 @@ function fingerprintTrackedPath (file) {
|
|
|
534
615
|
}
|
|
535
616
|
|
|
536
617
|
function recordBashSessionStop (root, sessionId, at = Date.now()) {
|
|
618
|
+
recordBashSessionStopIn(root, sessionId, at)
|
|
619
|
+
for (const checkout of trackedShellCheckouts(root, sessionId)) {
|
|
620
|
+
recordBashSessionStopIn(checkout, sessionId, at)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function recordBashSessionStopIn (root, sessionId, at) {
|
|
537
625
|
const overlapFile = bashOverlapPath(root)
|
|
538
626
|
if (!overlapFile || !fs.existsSync(overlapFile)) return
|
|
539
627
|
const file = sessionId && bashSessionStopPath(root, sessionId)
|
|
@@ -918,12 +1006,15 @@ function parseInput (input) {
|
|
|
918
1006
|
/**
|
|
919
1007
|
* Check whether a session has tracked any file-modifying tool calls.
|
|
920
1008
|
* A Bash pre-hook alone doesn't count because shell commands may be read-only.
|
|
921
|
-
* A
|
|
1009
|
+
* A snapshot post-hook counts only when it found newly dirty paths.
|
|
922
1010
|
*/
|
|
923
1011
|
function hasTrackedModifications (root, sessionId) {
|
|
924
|
-
return readTracking(root, sessionId).some(
|
|
925
|
-
|
|
926
|
-
|
|
1012
|
+
return readTracking(root, sessionId).some(describesModification)
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function describesModification (entry) {
|
|
1016
|
+
if (entry.phase === 'post') return Array.isArray(entry.files) && entry.files.length > 0
|
|
1017
|
+
return entry.tool !== 'Bash'
|
|
927
1018
|
}
|
|
928
1019
|
|
|
929
1020
|
function readTracking (root, sessionId) {
|
|
@@ -986,11 +1077,13 @@ module.exports = {
|
|
|
986
1077
|
fingerprintTrackedPath,
|
|
987
1078
|
claimedPathsBySessions,
|
|
988
1079
|
hasTrackedModifications,
|
|
1080
|
+
describesModification,
|
|
989
1081
|
cleanupTracking,
|
|
990
1082
|
extractFilePath,
|
|
991
1083
|
extractFilePaths,
|
|
992
1084
|
extractRawFilePaths,
|
|
993
1085
|
readTracking,
|
|
1086
|
+
trackedShellCheckouts,
|
|
994
1087
|
trackingDir,
|
|
995
1088
|
trackingPath
|
|
996
1089
|
}
|