feat(conflicts): add bash write detection, dismiss/acknowledge, and worktree suppression
This commit is contained in:
parent
05191127ea
commit
38b8447ae6
8 changed files with 354 additions and 24 deletions
|
|
@ -174,10 +174,12 @@ vi.mock('./stores/notifications.svelte', () => ({
|
|||
vi.mock('./stores/conflicts.svelte', () => ({
|
||||
recordFileWrite: vi.fn().mockReturnValue(false),
|
||||
clearSessionWrites: vi.fn(),
|
||||
setSessionWorktree: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils/tool-files', () => ({
|
||||
extractWritePaths: vi.fn().mockReturnValue([]),
|
||||
extractWorktreePath: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
// Use fake timers to control setTimeout in sidecar crash recovery
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ import {
|
|||
} from './adapters/groups-bridge';
|
||||
import { tel } from './adapters/telemetry-bridge';
|
||||
import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte';
|
||||
import { recordFileWrite, clearSessionWrites } from './stores/conflicts.svelte';
|
||||
import { extractWritePaths } from './utils/tool-files';
|
||||
import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte';
|
||||
import { extractWritePaths, extractWorktreePath } from './utils/tool-files';
|
||||
|
||||
let unlistenMsg: (() => void) | null = null;
|
||||
let unlistenExit: (() => void) | null = null;
|
||||
|
|
@ -184,6 +184,12 @@ function handleAgentEvent(sessionId: string, event: Record<string, unknown>): vo
|
|||
const projId = sessionProjectMap.get(sessionId);
|
||||
if (projId) {
|
||||
recordActivity(projId, tc.name);
|
||||
// Worktree tracking: detect worktree isolation on Agent/Task calls or EnterWorktree
|
||||
const wtPath = extractWorktreePath(tc);
|
||||
if (wtPath) {
|
||||
// The child session (or this session) is entering a worktree
|
||||
setSessionWorktree(sessionId, wtPath);
|
||||
}
|
||||
// Conflict detection: track file writes
|
||||
const writePaths = extractWritePaths(tc);
|
||||
for (const filePath of writePaths) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import type { ProjectConfig } from '../../types/groups';
|
||||
import { PROJECT_ACCENTS } from '../../types/groups';
|
||||
import type { ProjectHealth } from '../../stores/health.svelte';
|
||||
import { acknowledgeConflicts } from '../../stores/conflicts.svelte';
|
||||
|
||||
interface Props {
|
||||
project: ProjectConfig;
|
||||
|
|
@ -81,9 +82,13 @@
|
|||
</div>
|
||||
<div class="header-info">
|
||||
{#if health && health.fileConflictCount > 0}
|
||||
<span class="info-conflict" title="{health.fileConflictCount} file conflict{health.fileConflictCount > 1 ? 's' : ''} — multiple agents writing same file">
|
||||
⚠ {health.fileConflictCount} conflict{health.fileConflictCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
<button
|
||||
class="info-conflict"
|
||||
title="{health.fileConflictCount} file conflict{health.fileConflictCount > 1 ? 's' : ''} — click to dismiss"
|
||||
onclick={(e: MouseEvent) => { e.stopPropagation(); acknowledgeConflicts(project.id); }}
|
||||
>
|
||||
⚠ {health.fileConflictCount} conflict{health.fileConflictCount > 1 ? 's' : ''} ✕
|
||||
</button>
|
||||
<span class="info-sep">·</span>
|
||||
{/if}
|
||||
{#if contextPct !== null && contextPct > 0}
|
||||
|
|
@ -238,6 +243,14 @@
|
|||
background: color-mix(in srgb, var(--ctp-red) 12%, transparent);
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 0.1875rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.info-conflict:hover {
|
||||
background: color-mix(in srgb, var(--ctp-red) 25%, transparent);
|
||||
}
|
||||
|
||||
.info-profile {
|
||||
|
|
|
|||
|
|
@ -31,8 +31,29 @@ interface FileWriteEntry {
|
|||
// projectId -> filePath -> FileWriteEntry
|
||||
let projectFileWrites = $state<Map<string, Map<string, FileWriteEntry>>>(new Map());
|
||||
|
||||
// projectId -> set of acknowledged file paths (suppresses badge until new conflict on that file)
|
||||
let acknowledgedFiles = $state<Map<string, Set<string>>>(new Map());
|
||||
|
||||
// sessionId -> worktree path (null = main working tree)
|
||||
let sessionWorktrees = $state<Map<string, string | null>>(new Map());
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
/** Register the worktree path for a session (null = main working tree) */
|
||||
export function setSessionWorktree(sessionId: string, worktreePath: string | null): void {
|
||||
sessionWorktrees.set(sessionId, worktreePath ?? null);
|
||||
}
|
||||
|
||||
/** Check if two sessions are in different worktrees (conflict suppression) */
|
||||
function areInDifferentWorktrees(sessionIdA: string, sessionIdB: string): boolean {
|
||||
const wtA = sessionWorktrees.get(sessionIdA) ?? null;
|
||||
const wtB = sessionWorktrees.get(sessionIdB) ?? null;
|
||||
// Both null = same main tree, both same string = same worktree → not different
|
||||
if (wtA === wtB) return false;
|
||||
// One or both non-null and different → different worktrees
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Record that a session wrote to a file. Returns true if this creates a new conflict. */
|
||||
export function recordFileWrite(projectId: string, sessionId: string, filePath: string): boolean {
|
||||
let projectMap = projectFileWrites.get(projectId);
|
||||
|
|
@ -42,7 +63,7 @@ export function recordFileWrite(projectId: string, sessionId: string, filePath:
|
|||
}
|
||||
|
||||
let entry = projectMap.get(filePath);
|
||||
const hadConflict = entry ? entry.sessionIds.size >= 2 : false;
|
||||
const hadConflict = entry ? countRealConflictSessions(entry, sessionId) >= 2 : false;
|
||||
|
||||
if (!entry) {
|
||||
entry = { sessionIds: new Set([sessionId]), lastWriteTs: Date.now() };
|
||||
|
|
@ -50,21 +71,46 @@ export function recordFileWrite(projectId: string, sessionId: string, filePath:
|
|||
return false;
|
||||
}
|
||||
|
||||
const isNewSession = !entry.sessionIds.has(sessionId);
|
||||
entry.sessionIds.add(sessionId);
|
||||
entry.lastWriteTs = Date.now();
|
||||
|
||||
// New conflict = we just went from 1 session to 2+
|
||||
return !hadConflict && entry.sessionIds.size >= 2;
|
||||
// Check if this is a real conflict (not suppressed by worktrees)
|
||||
const realConflictCount = countRealConflictSessions(entry, sessionId);
|
||||
const isNewConflict = !hadConflict && realConflictCount >= 2;
|
||||
|
||||
// Clear acknowledgement when a new session writes to a previously-acknowledged file
|
||||
if (isNewSession && realConflictCount >= 2) {
|
||||
const ackSet = acknowledgedFiles.get(projectId);
|
||||
if (ackSet) ackSet.delete(filePath);
|
||||
}
|
||||
|
||||
return isNewConflict;
|
||||
}
|
||||
|
||||
/** Get all conflicts for a project */
|
||||
/**
|
||||
* Count sessions that are in a real conflict with the given session
|
||||
* (same worktree or both in main tree). Returns total including the session itself.
|
||||
*/
|
||||
function countRealConflictSessions(entry: FileWriteEntry, forSessionId: string): number {
|
||||
let count = 0;
|
||||
for (const sid of entry.sessionIds) {
|
||||
if (sid === forSessionId || !areInDifferentWorktrees(sid, forSessionId)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Get all conflicts for a project (excludes acknowledged and worktree-suppressed) */
|
||||
export function getProjectConflicts(projectId: string): ProjectConflicts {
|
||||
const projectMap = projectFileWrites.get(projectId);
|
||||
if (!projectMap) return { projectId, conflicts: [], conflictCount: 0 };
|
||||
|
||||
const ackSet = acknowledgedFiles.get(projectId);
|
||||
const conflicts: FileConflict[] = [];
|
||||
for (const [filePath, entry] of projectMap) {
|
||||
if (entry.sessionIds.size >= 2) {
|
||||
if (hasRealConflict(entry) && !(ackSet?.has(filePath))) {
|
||||
conflicts.push({
|
||||
filePath,
|
||||
shortName: filePath.split('/').pop() ?? filePath,
|
||||
|
|
@ -79,27 +125,56 @@ export function getProjectConflicts(projectId: string): ProjectConflicts {
|
|||
return { projectId, conflicts, conflictCount: conflicts.length };
|
||||
}
|
||||
|
||||
/** Check if a project has any file conflicts */
|
||||
/** Check if a project has any unacknowledged real conflicts */
|
||||
export function hasConflicts(projectId: string): boolean {
|
||||
const projectMap = projectFileWrites.get(projectId);
|
||||
if (!projectMap) return false;
|
||||
for (const entry of projectMap.values()) {
|
||||
if (entry.sessionIds.size >= 2) return true;
|
||||
const ackSet = acknowledgedFiles.get(projectId);
|
||||
for (const [filePath, entry] of projectMap) {
|
||||
if (hasRealConflict(entry) && !(ackSet?.has(filePath))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Get total conflict count across all projects */
|
||||
/** Get total unacknowledged conflict count across all projects */
|
||||
export function getTotalConflictCount(): number {
|
||||
let total = 0;
|
||||
for (const projectMap of projectFileWrites.values()) {
|
||||
for (const entry of projectMap.values()) {
|
||||
if (entry.sessionIds.size >= 2) total++;
|
||||
for (const [projectId, projectMap] of projectFileWrites) {
|
||||
const ackSet = acknowledgedFiles.get(projectId);
|
||||
for (const [filePath, entry] of projectMap) {
|
||||
if (hasRealConflict(entry) && !(ackSet?.has(filePath))) total++;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Check if a file write entry has a real conflict (2+ sessions in same worktree) */
|
||||
function hasRealConflict(entry: FileWriteEntry): boolean {
|
||||
if (entry.sessionIds.size < 2) return false;
|
||||
// Check all pairs for same-worktree conflict
|
||||
const sids = Array.from(entry.sessionIds);
|
||||
for (let i = 0; i < sids.length; i++) {
|
||||
for (let j = i + 1; j < sids.length; j++) {
|
||||
if (!areInDifferentWorktrees(sids[i], sids[j])) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Acknowledge all current conflicts for a project (suppresses badge until new conflict) */
|
||||
export function acknowledgeConflicts(projectId: string): void {
|
||||
const projectMap = projectFileWrites.get(projectId);
|
||||
if (!projectMap) return;
|
||||
|
||||
const ackSet = acknowledgedFiles.get(projectId) ?? new Set();
|
||||
for (const [filePath, entry] of projectMap) {
|
||||
if (hasRealConflict(entry)) {
|
||||
ackSet.add(filePath);
|
||||
}
|
||||
}
|
||||
acknowledgedFiles.set(projectId, ackSet);
|
||||
}
|
||||
|
||||
/** Remove a session from all file write tracking (call on session end) */
|
||||
export function clearSessionWrites(projectId: string, sessionId: string): void {
|
||||
const projectMap = projectFileWrites.get(projectId);
|
||||
|
|
@ -114,15 +189,22 @@ export function clearSessionWrites(projectId: string, sessionId: string): void {
|
|||
|
||||
if (projectMap.size === 0) {
|
||||
projectFileWrites.delete(projectId);
|
||||
acknowledgedFiles.delete(projectId);
|
||||
}
|
||||
|
||||
// Clean up worktree tracking
|
||||
sessionWorktrees.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Clear all conflict tracking for a project */
|
||||
export function clearProjectConflicts(projectId: string): void {
|
||||
projectFileWrites.delete(projectId);
|
||||
acknowledgedFiles.delete(projectId);
|
||||
}
|
||||
|
||||
/** Clear all conflict state */
|
||||
export function clearAllConflicts(): void {
|
||||
projectFileWrites = new Map();
|
||||
acknowledgedFiles = new Map();
|
||||
sessionWorktrees = new Map();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
clearSessionWrites,
|
||||
clearProjectConflicts,
|
||||
clearAllConflicts,
|
||||
acknowledgeConflicts,
|
||||
setSessionWorktree,
|
||||
} from './conflicts.svelte';
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -154,4 +156,83 @@ describe('conflicts store', () => {
|
|||
expect(getTotalConflictCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acknowledgeConflicts', () => {
|
||||
it('suppresses conflict from counts after acknowledge', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(true);
|
||||
acknowledgeConflicts('proj-1');
|
||||
expect(hasConflicts('proj-1')).toBe(false);
|
||||
expect(getTotalConflictCount()).toBe(0);
|
||||
expect(getProjectConflicts('proj-1').conflictCount).toBe(0);
|
||||
});
|
||||
|
||||
it('resurfaces conflict when new write arrives on acknowledged file', () => {
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
acknowledgeConflicts('proj-1');
|
||||
expect(hasConflicts('proj-1')).toBe(false);
|
||||
// Third session writes same file — should resurface
|
||||
recordFileWrite('proj-1', 'sess-c', '/a.ts');
|
||||
// recordFileWrite returns false for already-conflicted file, but the ack should be cleared
|
||||
expect(hasConflicts('proj-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('no-ops for unknown project', () => {
|
||||
acknowledgeConflicts('nonexistent'); // Should not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('worktree suppression', () => {
|
||||
it('suppresses conflict between sessions in different worktrees', () => {
|
||||
setSessionWorktree('sess-a', null); // main tree
|
||||
setSessionWorktree('sess-b', '/tmp/wt-1'); // worktree
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(false);
|
||||
expect(getTotalConflictCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('detects conflict between sessions in same worktree', () => {
|
||||
setSessionWorktree('sess-a', '/tmp/wt-1');
|
||||
setSessionWorktree('sess-b', '/tmp/wt-1');
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects conflict between sessions both in main tree', () => {
|
||||
setSessionWorktree('sess-a', null);
|
||||
setSessionWorktree('sess-b', null);
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses conflict when two worktrees differ', () => {
|
||||
setSessionWorktree('sess-a', '/tmp/wt-1');
|
||||
setSessionWorktree('sess-b', '/tmp/wt-2');
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('sessions without worktree info conflict normally (backward compat)', () => {
|
||||
// No setSessionWorktree calls — both default to null (main tree)
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('clearSessionWrites cleans up worktree tracking', () => {
|
||||
setSessionWorktree('sess-a', '/tmp/wt-1');
|
||||
recordFileWrite('proj-1', 'sess-a', '/a.ts');
|
||||
clearSessionWrites('proj-1', 'sess-a');
|
||||
// Subsequent session in main tree should not be compared against stale wt data
|
||||
recordFileWrite('proj-1', 'sess-b', '/a.ts');
|
||||
recordFileWrite('proj-1', 'sess-c', '/a.ts');
|
||||
expect(hasConflicts('proj-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export interface ProjectConfig {
|
|||
cwd: string;
|
||||
profile: string;
|
||||
enabled: boolean;
|
||||
/** When true, agents for this project use git worktrees for isolation */
|
||||
useWorktrees?: boolean;
|
||||
}
|
||||
|
||||
export interface GroupConfig {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { extractFilePaths, extractWritePaths } from './tool-files';
|
||||
import { extractFilePaths, extractWritePaths, extractWorktreePath } from './tool-files';
|
||||
import type { ToolCallContent } from '../adapters/sdk-messages';
|
||||
|
||||
function makeTc(name: string, input: unknown): ToolCallContent {
|
||||
|
|
@ -32,7 +32,7 @@ describe('extractFilePaths', () => {
|
|||
expect(result).toEqual([{ path: '/src', op: 'grep' }]);
|
||||
});
|
||||
|
||||
it('extracts Bash file paths from common commands', () => {
|
||||
it('extracts Bash read paths from common commands', () => {
|
||||
const result = extractFilePaths(makeTc('Bash', { command: 'cat /etc/hosts' }));
|
||||
expect(result).toEqual([{ path: '/etc/hosts', op: 'bash' }]);
|
||||
});
|
||||
|
|
@ -51,10 +51,57 @@ describe('extractFilePaths', () => {
|
|||
const result = extractFilePaths(makeTc('Read', {}));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
// Bash write detection
|
||||
it('detects echo > redirect as write', () => {
|
||||
const result = extractFilePaths(makeTc('Bash', { command: 'echo "hello" > /tmp/out.txt' }));
|
||||
expect(result).toEqual([{ path: '/tmp/out.txt', op: 'write' }]);
|
||||
});
|
||||
|
||||
it('detects >> append redirect as write', () => {
|
||||
const result = extractFilePaths(makeTc('Bash', { command: 'echo "data" >> /tmp/log.txt' }));
|
||||
expect(result).toEqual([{ path: '/tmp/log.txt', op: 'write' }]);
|
||||
});
|
||||
|
||||
it('detects sed -i as write', () => {
|
||||
const result = extractFilePaths(makeTc('Bash', { command: "sed -i 's/foo/bar/g' /src/config.ts" }));
|
||||
expect(result).toEqual([{ path: '/src/config.ts', op: 'write' }]);
|
||||
});
|
||||
|
||||
it('detects tee as write', () => {
|
||||
const result = extractFilePaths(makeTc('Bash', { command: 'echo "content" | tee /tmp/output.log' }));
|
||||
expect(result).toEqual([{ path: '/tmp/output.log', op: 'write' }]);
|
||||
});
|
||||
|
||||
it('detects tee -a as write', () => {
|
||||
const result = extractFilePaths(makeTc('Bash', { command: 'echo "append" | tee -a /tmp/output.log' }));
|
||||
expect(result).toEqual([{ path: '/tmp/output.log', op: 'write' }]);
|
||||
});
|
||||
|
||||
it('detects cp destination as write', () => {
|
||||
const result = extractFilePaths(makeTc('Bash', { command: 'cp /src/a.ts /src/b.ts' }));
|
||||
expect(result).toEqual([{ path: '/src/b.ts', op: 'write' }]);
|
||||
});
|
||||
|
||||
it('detects mv destination as write', () => {
|
||||
const result = extractFilePaths(makeTc('Bash', { command: 'mv /old/file.ts /new/file.ts' }));
|
||||
expect(result).toEqual([{ path: '/new/file.ts', op: 'write' }]);
|
||||
});
|
||||
|
||||
it('ignores /dev/null redirects', () => {
|
||||
const result = extractFilePaths(makeTc('Bash', { command: 'echo "test" > /dev/null' }));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('prefers write detection over read for ambiguous commands', () => {
|
||||
// "cat file > out" should detect the write target, not the read source
|
||||
const result = extractFilePaths(makeTc('Bash', { command: 'cat /src/input.ts > /tmp/output.ts' }));
|
||||
expect(result).toEqual([{ path: '/tmp/output.ts', op: 'write' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractWritePaths', () => {
|
||||
it('returns only write-op paths', () => {
|
||||
it('returns only write-op paths for Write/Edit tools', () => {
|
||||
expect(extractWritePaths(makeTc('Write', { file_path: '/a.ts' }))).toEqual(['/a.ts']);
|
||||
expect(extractWritePaths(makeTc('Edit', { file_path: '/b.ts' }))).toEqual(['/b.ts']);
|
||||
});
|
||||
|
|
@ -65,7 +112,37 @@ describe('extractWritePaths', () => {
|
|||
expect(extractWritePaths(makeTc('Grep', { path: '/src' }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty for bash commands', () => {
|
||||
it('returns empty for bash read commands', () => {
|
||||
expect(extractWritePaths(makeTc('Bash', { command: 'cat /foo' }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects bash write commands', () => {
|
||||
expect(extractWritePaths(makeTc('Bash', { command: 'echo "x" > /tmp/out.ts' }))).toEqual(['/tmp/out.ts']);
|
||||
expect(extractWritePaths(makeTc('Bash', { command: "sed -i 's/a/b/' /src/file.ts" }))).toEqual(['/src/file.ts']);
|
||||
expect(extractWritePaths(makeTc('Bash', { command: 'cp /a.ts /b.ts' }))).toEqual(['/b.ts']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractWorktreePath', () => {
|
||||
it('detects Agent tool with isolation: worktree', () => {
|
||||
const result = extractWorktreePath(makeTc('Agent', { prompt: 'do stuff', isolation: 'worktree' }));
|
||||
expect(result).toMatch(/^worktree:/);
|
||||
});
|
||||
|
||||
it('detects Task tool with isolation: worktree', () => {
|
||||
const result = extractWorktreePath(makeTc('Task', { prompt: 'do stuff', isolation: 'worktree' }));
|
||||
expect(result).toMatch(/^worktree:/);
|
||||
});
|
||||
|
||||
it('returns null for Agent without isolation', () => {
|
||||
expect(extractWorktreePath(makeTc('Agent', { prompt: 'do stuff' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('detects EnterWorktree with path', () => {
|
||||
expect(extractWorktreePath(makeTc('EnterWorktree', { path: '/tmp/wt-1' }))).toBe('/tmp/wt-1');
|
||||
});
|
||||
|
||||
it('returns null for unrelated tool', () => {
|
||||
expect(extractWorktreePath(makeTc('Read', { file_path: '/foo' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,25 @@ export interface ToolFileRef {
|
|||
op: 'read' | 'write' | 'glob' | 'grep' | 'bash';
|
||||
}
|
||||
|
||||
// Patterns for read-like bash commands
|
||||
const BASH_READ_RE = /(?:cat|head|tail|less|vim|nano|code)\s+["']?([^\s"'|;&]+)/;
|
||||
|
||||
// Patterns for bash commands that write to files
|
||||
const BASH_WRITE_PATTERNS: RegExp[] = [
|
||||
// Redirection: echo/printf/cat ... > file or >> file
|
||||
/(?:>>?)\s*["']?([^\s"'|;&]+)/,
|
||||
// sed -i (in-place edit)
|
||||
/\bsed\s+(?:-[^i\s]*)?-i[^-]?\s*(?:'[^']*'|"[^"]*"|[^\s]+\s+)["']?([^\s"'|;&]+)/,
|
||||
// tee file
|
||||
/\btee\s+(?:-a\s+)?["']?([^\s"'|;&]+)/,
|
||||
// cp source dest — last arg is destination
|
||||
/\bcp\s+(?:-[^\s]*\s+)*[^\s]+\s+["']?([^\s"'|;&]+)/,
|
||||
// mv source dest — last arg is destination
|
||||
/\bmv\s+(?:-[^\s]*\s+)*[^\s]+\s+["']?([^\s"'|;&]+)/,
|
||||
// chmod/chown — modifies file metadata
|
||||
/\b(?:chmod|chown)\s+(?:-[^\s]*\s+)*[^\s]+\s+["']?([^\s"'|;&]+)/,
|
||||
];
|
||||
|
||||
/** Extract file paths referenced by a tool call */
|
||||
export function extractFilePaths(tc: ToolCallContent): ToolFileRef[] {
|
||||
const results: ToolFileRef[] = [];
|
||||
|
|
@ -37,17 +56,65 @@ export function extractFilePaths(tc: ToolCallContent): ToolFileRef[] {
|
|||
case 'Bash':
|
||||
case 'bash': {
|
||||
const cmd = String(input?.command ?? '');
|
||||
const fileMatch = cmd.match(/(?:cat|head|tail|less|vim|nano|code)\s+["']?([^\s"'|;&]+)/);
|
||||
if (fileMatch) results.push({ path: fileMatch[1], op: 'bash' });
|
||||
// Check for write patterns first
|
||||
const writeRefs = extractBashWritePaths(cmd);
|
||||
for (const path of writeRefs) {
|
||||
results.push({ path, op: 'write' });
|
||||
}
|
||||
// Check for read patterns (only if no write detected to avoid double-counting)
|
||||
if (writeRefs.length === 0) {
|
||||
const readMatch = cmd.match(BASH_READ_RE);
|
||||
if (readMatch) results.push({ path: readMatch[1], op: 'bash' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Extract only write-operation file paths (Write, Edit) */
|
||||
/** Extract write-target file paths from a bash command string */
|
||||
function extractBashWritePaths(cmd: string): string[] {
|
||||
const paths: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const pattern of BASH_WRITE_PATTERNS) {
|
||||
const match = cmd.match(pattern);
|
||||
if (match && match[1] && !seen.has(match[1])) {
|
||||
// Filter out obvious non-file targets (flags, -, /dev/null)
|
||||
const target = match[1];
|
||||
if (target === '-' || target.startsWith('-') || target === '/dev/null') continue;
|
||||
seen.add(target);
|
||||
paths.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Extract only write-operation file paths (Write, Edit, and Bash writes) */
|
||||
export function extractWritePaths(tc: ToolCallContent): string[] {
|
||||
return extractFilePaths(tc)
|
||||
.filter(r => r.op === 'write')
|
||||
.map(r => r.path);
|
||||
}
|
||||
|
||||
/** Extract worktree path from an Agent/Task tool call with isolation: "worktree", or EnterWorktree */
|
||||
export function extractWorktreePath(tc: ToolCallContent): string | null {
|
||||
const input = tc.input as Record<string, unknown> | null;
|
||||
if (!input) return null;
|
||||
|
||||
const name = tc.name;
|
||||
// Agent/Task tool with isolation: "worktree"
|
||||
if ((name === 'Agent' || name === 'Task' || name === 'dispatch_agent') && input.isolation === 'worktree') {
|
||||
// The worktree path comes from the tool_result, not the tool_call.
|
||||
// But we can flag this session as "worktree-isolated" with a synthetic marker.
|
||||
return `worktree:${tc.toolUseId}`;
|
||||
}
|
||||
|
||||
// EnterWorktree tool call carries the path directly
|
||||
if (name === 'EnterWorktree' && typeof input.path === 'string') {
|
||||
return input.path;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue