From 38b8447ae680dbb2ddfe8aa1047ab456563c7e19 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 11 Mar 2026 00:25:26 +0100 Subject: [PATCH] feat(conflicts): add bash write detection, dismiss/acknowledge, and worktree suppression --- v2/src/lib/agent-dispatcher.test.ts | 2 + v2/src/lib/agent-dispatcher.ts | 10 +- .../components/Workspace/ProjectHeader.svelte | 19 +++- v2/src/lib/stores/conflicts.svelte.ts | 106 ++++++++++++++++-- v2/src/lib/stores/conflicts.test.ts | 81 +++++++++++++ v2/src/lib/types/groups.ts | 2 + v2/src/lib/utils/tool-files.test.ts | 85 +++++++++++++- v2/src/lib/utils/tool-files.ts | 73 +++++++++++- 8 files changed, 354 insertions(+), 24 deletions(-) diff --git a/v2/src/lib/agent-dispatcher.test.ts b/v2/src/lib/agent-dispatcher.test.ts index e26e884..dbca069 100644 --- a/v2/src/lib/agent-dispatcher.test.ts +++ b/v2/src/lib/agent-dispatcher.test.ts @@ -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 diff --git a/v2/src/lib/agent-dispatcher.ts b/v2/src/lib/agent-dispatcher.ts index f2ccd22..76dacb6 100644 --- a/v2/src/lib/agent-dispatcher.ts +++ b/v2/src/lib/agent-dispatcher.ts @@ -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): 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) { diff --git a/v2/src/lib/components/Workspace/ProjectHeader.svelte b/v2/src/lib/components/Workspace/ProjectHeader.svelte index 3147803..2a44f3a 100644 --- a/v2/src/lib/components/Workspace/ProjectHeader.svelte +++ b/v2/src/lib/components/Workspace/ProjectHeader.svelte @@ -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 @@
{#if health && health.fileConflictCount > 0} - - ⚠ {health.fileConflictCount} conflict{health.fileConflictCount > 1 ? 's' : ''} - + · {/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 { diff --git a/v2/src/lib/stores/conflicts.svelte.ts b/v2/src/lib/stores/conflicts.svelte.ts index d2e8a26..b0d2fe1 100644 --- a/v2/src/lib/stores/conflicts.svelte.ts +++ b/v2/src/lib/stores/conflicts.svelte.ts @@ -31,8 +31,29 @@ interface FileWriteEntry { // projectId -> filePath -> FileWriteEntry let projectFileWrites = $state>>(new Map()); +// projectId -> set of acknowledged file paths (suppresses badge until new conflict on that file) +let acknowledgedFiles = $state>>(new Map()); + +// sessionId -> worktree path (null = main working tree) +let sessionWorktrees = $state>(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(); } diff --git a/v2/src/lib/stores/conflicts.test.ts b/v2/src/lib/stores/conflicts.test.ts index e794e14..ba684cd 100644 --- a/v2/src/lib/stores/conflicts.test.ts +++ b/v2/src/lib/stores/conflicts.test.ts @@ -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); + }); + }); }); diff --git a/v2/src/lib/types/groups.ts b/v2/src/lib/types/groups.ts index 247d420..6123d79 100644 --- a/v2/src/lib/types/groups.ts +++ b/v2/src/lib/types/groups.ts @@ -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 { diff --git a/v2/src/lib/utils/tool-files.test.ts b/v2/src/lib/utils/tool-files.test.ts index 079101c..bb24182 100644 --- a/v2/src/lib/utils/tool-files.test.ts +++ b/v2/src/lib/utils/tool-files.test.ts @@ -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(); + }); }); diff --git a/v2/src/lib/utils/tool-files.ts b/v2/src/lib/utils/tool-files.ts index 46fe1be..6a89041 100644 --- a/v2/src/lib/utils/tool-files.ts +++ b/v2/src/lib/utils/tool-files.ts @@ -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(); + + 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 | 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; +}