From 450756f5401c01407bcc9d3a9e847707e1db56f6 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 11 Mar 2026 05:25:32 +0100 Subject: [PATCH] refactor(agent-dispatcher): split into 4 focused modules (SOLID Phase 2) --- v2/src/lib/agent-dispatcher.test.ts | 27 -- v2/src/lib/agent-dispatcher.ts | 260 +++----------------- v2/src/lib/utils/auto-anchoring.ts | 51 ++++ v2/src/lib/utils/session-persistence.ts | 117 +++++++++ v2/src/lib/utils/subagent-router.ts | 78 ++++++ v2/src/lib/utils/worktree-detection.test.ts | 28 +++ v2/src/lib/utils/worktree-detection.ts | 17 ++ 7 files changed, 326 insertions(+), 252 deletions(-) create mode 100644 v2/src/lib/utils/auto-anchoring.ts create mode 100644 v2/src/lib/utils/session-persistence.ts create mode 100644 v2/src/lib/utils/subagent-router.ts create mode 100644 v2/src/lib/utils/worktree-detection.test.ts create mode 100644 v2/src/lib/utils/worktree-detection.ts diff --git a/v2/src/lib/agent-dispatcher.test.ts b/v2/src/lib/agent-dispatcher.test.ts index 74be7df..c1e4ce5 100644 --- a/v2/src/lib/agent-dispatcher.test.ts +++ b/v2/src/lib/agent-dispatcher.test.ts @@ -205,7 +205,6 @@ import { isSidecarAlive, setSidecarAlive, waitForPendingPersistence, - detectWorktreeFromCwd, } from './agent-dispatcher'; // Stop any previous dispatcher between tests so `unlistenMsg` is null and start works @@ -624,32 +623,6 @@ describe('agent-dispatcher', () => { }); }); - describe('detectWorktreeFromCwd', () => { - it('detects Claude Code worktree path', () => { - const result = detectWorktreeFromCwd('/home/user/project/.claude/worktrees/my-session'); - expect(result).toBe('/.claude/worktrees/my-session'); - }); - - it('detects Codex worktree path', () => { - const result = detectWorktreeFromCwd('/home/user/project/.codex/worktrees/task-1'); - expect(result).toBe('/.codex/worktrees/task-1'); - }); - - it('detects Cursor worktree path', () => { - const result = detectWorktreeFromCwd('/home/user/project/.cursor/worktrees/feature-x'); - expect(result).toBe('/.cursor/worktrees/feature-x'); - }); - - it('returns null for non-worktree CWD', () => { - expect(detectWorktreeFromCwd('/home/user/project')).toBeNull(); - expect(detectWorktreeFromCwd('/tmp/work')).toBeNull(); - }); - - it('returns null for empty string', () => { - expect(detectWorktreeFromCwd('')).toBeNull(); - }); - }); - describe('init event CWD worktree detection', () => { beforeEach(async () => { await startAgentDispatcher(); diff --git a/v2/src/lib/agent-dispatcher.ts b/v2/src/lib/agent-dispatcher.ts index 88eaee3..0d52b49 100644 --- a/v2/src/lib/agent-dispatcher.ts +++ b/v2/src/lib/agent-dispatcher.ts @@ -1,10 +1,9 @@ // Agent Dispatcher — connects sidecar bridge events to agent store -// Single listener that routes sidecar messages to the correct agent session +// Thin coordinator that routes sidecar messages to specialized modules import { onSidecarMessage, onSidecarExited, restartAgent, type SidecarMessage } from './adapters/agent-bridge'; import { adaptMessage } from './adapters/message-adapters'; import type { InitContent, CostContent, ToolCallContent } from './adapters/claude-messages'; -import type { ProviderId } from './providers/types'; import { updateAgentStatus, setAgentSdkSessionId, @@ -13,46 +12,36 @@ import { updateAgentCost, getAgentSessions, getAgentSession, - createAgentSession, - findChildByToolUseId, } from './stores/agents.svelte'; -import { addPane, getPanes } from './stores/layout.svelte'; import { notify } from './stores/notifications.svelte'; -import { - saveProjectAgentState, - saveAgentMessages, - saveSessionMetric, - type AgentMessageRecord, -} from './adapters/groups-bridge'; import { tel } from './adapters/telemetry-bridge'; import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte'; import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte'; import { extractWritePaths, extractWorktreePath } from './utils/tool-files'; -import { hasAutoAnchored, markAutoAnchored, addAnchors, getAnchorSettings } from './stores/anchors.svelte'; -import { selectAutoAnchors, serializeAnchorsForInjection } from './utils/anchor-serializer'; -import type { SessionAnchor } from './types/anchors'; -import { getEnabledProjects } from './stores/workspace.svelte'; +import { hasAutoAnchored, markAutoAnchored } from './stores/anchors.svelte'; +import { detectWorktreeFromCwd } from './utils/worktree-detection'; +import { + getSessionProjectId, + getSessionProvider, + recordSessionStart, + persistSessionForProject, + clearSessionMaps, +} from './utils/session-persistence'; +import { triggerAutoAnchor } from './utils/auto-anchoring'; +import { + isSubagentToolCall, + getChildPaneId, + spawnSubagentPane, + clearSubagentRoutes, +} from './utils/subagent-router'; + +// Re-export public API consumed by other modules +export { registerSessionProject, waitForPendingPersistence } from './utils/session-persistence'; +export { detectWorktreeFromCwd } from './utils/worktree-detection'; let unlistenMsg: (() => void) | null = null; let unlistenExit: (() => void) | null = null; -// Map sessionId -> projectId for persistence routing -const sessionProjectMap = new Map(); - -// Map sessionId -> provider for message adapter routing -const sessionProviderMap = new Map(); - -// Map sessionId -> start timestamp for metrics -const sessionStartTimes = new Map(); - -// In-flight persistence counter — prevents teardown from racing with async saves -let pendingPersistCount = 0; - -export function registerSessionProject(sessionId: string, projectId: string, provider: ProviderId = 'claude'): void { - sessionProjectMap.set(sessionId, projectId); - sessionProviderMap.set(sessionId, provider); -} - // Sidecar liveness — checked by UI components let sidecarAlive = true; @@ -86,7 +75,7 @@ export async function startAgentDispatcher(): Promise { switch (msg.type) { case 'agent_started': updateAgentStatus(sessionId, 'running'); - sessionStartTimes.set(sessionId, Date.now()); + recordSessionStart(sessionId); tel.info('agent_started', { sessionId }); break; @@ -151,14 +140,8 @@ export async function startAgentDispatcher(): Promise { }); } -// Tool names that indicate a subagent spawn -const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task', 'dispatch_agent']); - -// Map toolUseId -> child session pane id for routing -const toolUseToChildPane = new Map(); - function handleAgentEvent(sessionId: string, event: Record): void { - const provider = sessionProviderMap.get(sessionId) ?? 'claude'; + const provider = getSessionProvider(sessionId); const messages = adaptMessage(provider, event); // Route messages with parentId to the appropriate child pane @@ -166,8 +149,8 @@ function handleAgentEvent(sessionId: string, event: Record): vo const childBuckets = new Map(); for (const msg of messages) { - if (msg.parentId && toolUseToChildPane.has(msg.parentId)) { - const childPaneId = toolUseToChildPane.get(msg.parentId)!; + const childPaneId = msg.parentId ? getChildPaneId(msg.parentId) : undefined; + if (childPaneId) { if (!childBuckets.has(childPaneId)) childBuckets.set(childPaneId, []); childBuckets.get(childPaneId)!.push(msg); } else { @@ -182,8 +165,7 @@ function handleAgentEvent(sessionId: string, event: Record): vo const init = msg.content as InitContent; setAgentSdkSessionId(sessionId, init.sessionId); setAgentModel(sessionId, init.model); - // CWD-based worktree detection: if init CWD contains a worktree path pattern, - // register it for conflict suppression (agents in different worktrees don't conflict) + // CWD-based worktree detection for conflict suppression if (init.cwd) { const wtPath = detectWorktreeFromCwd(init.cwd); if (wtPath) { @@ -195,17 +177,16 @@ function handleAgentEvent(sessionId: string, event: Record): vo case 'tool_call': { const tc = msg.content as ToolCallContent; - if (SUBAGENT_TOOL_NAMES.has(tc.name)) { + if (isSubagentToolCall(tc.name)) { spawnSubagentPane(sessionId, tc); } // Health: record tool start - const projId = sessionProjectMap.get(sessionId); + const projId = getSessionProjectId(sessionId); if (projId) { recordActivity(projId, tc.name); - // Worktree tracking: detect worktree isolation on Agent/Task calls or EnterWorktree + // Worktree tracking const wtPath = extractWorktreePath(tc); if (wtPath) { - // The child session (or this session) is entering a worktree setSessionWorktree(sessionId, wtPath); } // Conflict detection: track file writes @@ -223,8 +204,9 @@ function handleAgentEvent(sessionId: string, event: Record): vo case 'compaction': { // Auto-anchor on first compaction for this project - const compactProjId = sessionProjectMap.get(sessionId); + const compactProjId = getSessionProjectId(sessionId); if (compactProjId && !hasAutoAnchored(compactProjId)) { + markAutoAnchored(compactProjId); const session = getAgentSession(sessionId); if (session) { triggerAutoAnchor(compactProjId, session.messages, session.prompt); @@ -259,7 +241,7 @@ function handleAgentEvent(sessionId: string, event: Record): vo notify('success', `Agent done — $${cost.totalCostUsd.toFixed(4)}, ${cost.numTurns} turns`); } // Health: record token snapshot + tool done - const costProjId = sessionProjectMap.get(sessionId); + const costProjId = getSessionProjectId(sessionId); if (costProjId) { recordTokenSnapshot(costProjId, cost.inputTokens + cost.outputTokens, cost.totalCostUsd); recordToolDone(costProjId); @@ -275,7 +257,7 @@ function handleAgentEvent(sessionId: string, event: Record): vo // Health: record general activity for non-tool messages (text, thinking) if (mainMessages.length > 0) { - const actProjId = sessionProjectMap.get(sessionId); + const actProjId = getSessionProjectId(sessionId); if (actProjId) { const hasToolResult = mainMessages.some(m => m.type === 'tool_result'); if (hasToolResult) recordToolDone(actProjId); @@ -308,176 +290,6 @@ function handleAgentEvent(sessionId: string, event: Record): vo } } -function spawnSubagentPane(parentSessionId: string, tc: ToolCallContent): void { - // Don't create duplicate pane for same tool_use - if (toolUseToChildPane.has(tc.toolUseId)) return; - const existing = findChildByToolUseId(parentSessionId, tc.toolUseId); - if (existing) { - toolUseToChildPane.set(tc.toolUseId, existing.id); - return; - } - - const childId = crypto.randomUUID(); - const prompt = typeof tc.input === 'object' && tc.input !== null - ? (tc.input as Record).prompt as string ?? tc.name - : tc.name; - const label = typeof tc.input === 'object' && tc.input !== null - ? (tc.input as Record).name as string ?? tc.name - : tc.name; - - // Register routing - toolUseToChildPane.set(tc.toolUseId, childId); - - // Create agent session with parent link - createAgentSession(childId, prompt, { - sessionId: parentSessionId, - toolUseId: tc.toolUseId, - }); - updateAgentStatus(childId, 'running'); - - // For project-scoped sessions, subagents render in TeamAgentsPanel (no layout pane) - // For non-project sessions (detached mode), create a layout pane - if (!sessionProjectMap.has(parentSessionId)) { - const parentPane = getPanes().find(p => p.id === parentSessionId); - const groupName = parentPane?.title ?? `Agent ${parentSessionId.slice(0, 8)}`; - addPane({ - id: childId, - type: 'agent', - title: `Sub: ${label}`, - group: groupName, - }); - } -} - -/** Wait until all in-flight persistence operations complete */ -export async function waitForPendingPersistence(): Promise { - while (pendingPersistCount > 0) { - await new Promise(r => setTimeout(r, 10)); - } -} - -/** Persist session state + messages to SQLite for the project that owns this session */ -async function persistSessionForProject(sessionId: string): Promise { - const projectId = sessionProjectMap.get(sessionId); - if (!projectId) return; // Not a project-scoped session - - const session = getAgentSession(sessionId); - if (!session) return; - - pendingPersistCount++; - try { - // Save agent state - await saveProjectAgentState({ - project_id: projectId, - last_session_id: sessionId, - sdk_session_id: session.sdkSessionId ?? null, - status: session.status, - cost_usd: session.costUsd, - input_tokens: session.inputTokens, - output_tokens: session.outputTokens, - last_prompt: session.prompt, - updated_at: Math.floor(Date.now() / 1000), - }); - - // Save messages (use seconds to match session.rs convention) - const nowSecs = Math.floor(Date.now() / 1000); - const records: AgentMessageRecord[] = session.messages.map((m, i) => ({ - id: i, - session_id: sessionId, - project_id: projectId, - sdk_session_id: session.sdkSessionId ?? null, - message_type: m.type, - content: JSON.stringify(m.content), - parent_id: m.parentId ?? null, - created_at: nowSecs, - })); - - if (records.length > 0) { - await saveAgentMessages(sessionId, projectId, session.sdkSessionId, records); - } - - // Persist session metric for historical tracking - const toolCallCount = session.messages.filter(m => m.type === 'tool_call').length; - const startTime = sessionStartTimes.get(sessionId) ?? Math.floor(Date.now() / 1000); - await saveSessionMetric({ - project_id: projectId, - session_id: sessionId, - start_time: Math.floor(startTime / 1000), - end_time: nowSecs, - peak_tokens: session.inputTokens + session.outputTokens, - turn_count: session.numTurns, - tool_call_count: toolCallCount, - cost_usd: session.costUsd, - model: session.model ?? null, - status: session.status, - error_message: session.error ?? null, - }); - } catch (e) { - console.warn('Failed to persist agent session:', e); - } finally { - pendingPersistCount--; - } -} - -/** Auto-anchor first N turns on first compaction event for a project */ -function triggerAutoAnchor( - projectId: string, - messages: import('./adapters/claude-messages').AgentMessage[], - sessionPrompt: string, -): void { - markAutoAnchored(projectId); - - const project = getEnabledProjects().find(p => p.id === projectId); - const settings = getAnchorSettings(project?.anchorBudgetScale); - const { turns, totalTokens } = selectAutoAnchors( - messages, - sessionPrompt, - settings.anchorTurns, - settings.anchorTokenBudget, - ); - - if (turns.length === 0) return; - - const nowSecs = Math.floor(Date.now() / 1000); - const anchors: SessionAnchor[] = turns.map((turn, i) => { - const content = serializeAnchorsForInjection([turn], settings.anchorTokenBudget); - return { - id: crypto.randomUUID(), - projectId, - messageId: `turn-${turn.index}`, - anchorType: 'auto' as const, - content: content, - estimatedTokens: turn.estimatedTokens, - turnIndex: turn.index, - createdAt: nowSecs, - }; - }); - - addAnchors(projectId, anchors); - tel.info('auto_anchor_created', { - projectId, - anchorCount: anchors.length, - totalTokens, - }); - notify('info', `Anchored ${anchors.length} turns (${totalTokens} tokens) for context preservation`); -} - -// Worktree path patterns for various providers -const WORKTREE_CWD_PATTERNS = [ - /\/\.claude\/worktrees\/([^/]+)/, // Claude Code: /.claude/worktrees// - /\/\.codex\/worktrees\/([^/]+)/, // Codex - /\/\.cursor\/worktrees\/([^/]+)/, // Cursor -]; - -/** Extract worktree path from CWD if it matches a known worktree pattern */ -export function detectWorktreeFromCwd(cwd: string): string | null { - for (const pattern of WORKTREE_CWD_PATTERNS) { - const match = cwd.match(pattern); - if (match) return match[0]; // Return the full worktree path segment - } - return null; -} - export function stopAgentDispatcher(): void { if (unlistenMsg) { unlistenMsg(); @@ -488,8 +300,6 @@ export function stopAgentDispatcher(): void { unlistenExit = null; } // Clear routing maps to prevent unbounded memory growth - toolUseToChildPane.clear(); - sessionProjectMap.clear(); - sessionProviderMap.clear(); - sessionStartTimes.clear(); + clearSubagentRoutes(); + clearSessionMaps(); } diff --git a/v2/src/lib/utils/auto-anchoring.ts b/v2/src/lib/utils/auto-anchoring.ts new file mode 100644 index 0000000..f87fd2b --- /dev/null +++ b/v2/src/lib/utils/auto-anchoring.ts @@ -0,0 +1,51 @@ +// Auto-anchoring — creates session anchors on first compaction event +// Extracted from agent-dispatcher.ts (SRP: anchor creation concern) + +import type { AgentMessage } from '../adapters/claude-messages'; +import type { SessionAnchor } from '../types/anchors'; +import { getAnchorSettings, addAnchors } from '../stores/anchors.svelte'; +import { selectAutoAnchors, serializeAnchorsForInjection } from '../utils/anchor-serializer'; +import { getEnabledProjects } from '../stores/workspace.svelte'; +import { tel } from '../adapters/telemetry-bridge'; +import { notify } from '../stores/notifications.svelte'; + +/** Auto-anchor first N turns on first compaction event for a project */ +export function triggerAutoAnchor( + projectId: string, + messages: AgentMessage[], + sessionPrompt: string, +): void { + const project = getEnabledProjects().find(p => p.id === projectId); + const settings = getAnchorSettings(project?.anchorBudgetScale); + const { turns, totalTokens } = selectAutoAnchors( + messages, + sessionPrompt, + settings.anchorTurns, + settings.anchorTokenBudget, + ); + + if (turns.length === 0) return; + + const nowSecs = Math.floor(Date.now() / 1000); + const anchors: SessionAnchor[] = turns.map((turn) => { + const content = serializeAnchorsForInjection([turn], settings.anchorTokenBudget); + return { + id: crypto.randomUUID(), + projectId, + messageId: `turn-${turn.index}`, + anchorType: 'auto' as const, + content: content, + estimatedTokens: turn.estimatedTokens, + turnIndex: turn.index, + createdAt: nowSecs, + }; + }); + + addAnchors(projectId, anchors); + tel.info('auto_anchor_created', { + projectId, + anchorCount: anchors.length, + totalTokens, + }); + notify('info', `Anchored ${anchors.length} turns (${totalTokens} tokens) for context preservation`); +} diff --git a/v2/src/lib/utils/session-persistence.ts b/v2/src/lib/utils/session-persistence.ts new file mode 100644 index 0000000..3a25d33 --- /dev/null +++ b/v2/src/lib/utils/session-persistence.ts @@ -0,0 +1,117 @@ +// Session persistence — maps session IDs to projects/providers and persists state to SQLite +// Extracted from agent-dispatcher.ts (SRP: persistence concern) + +import type { ProviderId } from '../providers/types'; +import { getAgentSession } from '../stores/agents.svelte'; +import { + saveProjectAgentState, + saveAgentMessages, + saveSessionMetric, + type AgentMessageRecord, +} from '../adapters/groups-bridge'; + +// Map sessionId -> projectId for persistence routing +const sessionProjectMap = new Map(); + +// Map sessionId -> provider for message adapter routing +const sessionProviderMap = new Map(); + +// Map sessionId -> start timestamp for metrics +const sessionStartTimes = new Map(); + +// In-flight persistence counter — prevents teardown from racing with async saves +let pendingPersistCount = 0; + +export function registerSessionProject(sessionId: string, projectId: string, provider: ProviderId = 'claude'): void { + sessionProjectMap.set(sessionId, projectId); + sessionProviderMap.set(sessionId, provider); +} + +export function getSessionProjectId(sessionId: string): string | undefined { + return sessionProjectMap.get(sessionId); +} + +export function getSessionProvider(sessionId: string): ProviderId { + return sessionProviderMap.get(sessionId) ?? 'claude'; +} + +export function recordSessionStart(sessionId: string): void { + sessionStartTimes.set(sessionId, Date.now()); +} + +/** Wait until all in-flight persistence operations complete */ +export async function waitForPendingPersistence(): Promise { + while (pendingPersistCount > 0) { + await new Promise(r => setTimeout(r, 10)); + } +} + +/** Persist session state + messages to SQLite for the project that owns this session */ +export async function persistSessionForProject(sessionId: string): Promise { + const projectId = sessionProjectMap.get(sessionId); + if (!projectId) return; // Not a project-scoped session + + const session = getAgentSession(sessionId); + if (!session) return; + + pendingPersistCount++; + try { + // Save agent state + await saveProjectAgentState({ + project_id: projectId, + last_session_id: sessionId, + sdk_session_id: session.sdkSessionId ?? null, + status: session.status, + cost_usd: session.costUsd, + input_tokens: session.inputTokens, + output_tokens: session.outputTokens, + last_prompt: session.prompt, + updated_at: Math.floor(Date.now() / 1000), + }); + + // Save messages (use seconds to match session.rs convention) + const nowSecs = Math.floor(Date.now() / 1000); + const records: AgentMessageRecord[] = session.messages.map((m, i) => ({ + id: i, + session_id: sessionId, + project_id: projectId, + sdk_session_id: session.sdkSessionId ?? null, + message_type: m.type, + content: JSON.stringify(m.content), + parent_id: m.parentId ?? null, + created_at: nowSecs, + })); + + if (records.length > 0) { + await saveAgentMessages(sessionId, projectId, session.sdkSessionId, records); + } + + // Persist session metric for historical tracking + const toolCallCount = session.messages.filter(m => m.type === 'tool_call').length; + const startTime = sessionStartTimes.get(sessionId) ?? Math.floor(Date.now() / 1000); + await saveSessionMetric({ + project_id: projectId, + session_id: sessionId, + start_time: Math.floor(startTime / 1000), + end_time: nowSecs, + peak_tokens: session.inputTokens + session.outputTokens, + turn_count: session.numTurns, + tool_call_count: toolCallCount, + cost_usd: session.costUsd, + model: session.model ?? null, + status: session.status, + error_message: session.error ?? null, + }); + } catch (e) { + console.warn('Failed to persist agent session:', e); + } finally { + pendingPersistCount--; + } +} + +/** Clear all session maps — called on dispatcher shutdown */ +export function clearSessionMaps(): void { + sessionProjectMap.clear(); + sessionProviderMap.clear(); + sessionStartTimes.clear(); +} diff --git a/v2/src/lib/utils/subagent-router.ts b/v2/src/lib/utils/subagent-router.ts new file mode 100644 index 0000000..5576a15 --- /dev/null +++ b/v2/src/lib/utils/subagent-router.ts @@ -0,0 +1,78 @@ +// Subagent routing — manages subagent pane creation and message routing +// Extracted from agent-dispatcher.ts (SRP: subagent lifecycle concern) + +import type { ToolCallContent } from '../adapters/claude-messages'; +import { + createAgentSession, + updateAgentStatus, + findChildByToolUseId, +} from '../stores/agents.svelte'; +import { addPane, getPanes } from '../stores/layout.svelte'; +import { getSessionProjectId } from './session-persistence'; + +// Tool names that indicate a subagent spawn +const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task', 'dispatch_agent']); + +// Map toolUseId -> child session pane id for routing +const toolUseToChildPane = new Map(); + +/** Check if a tool call is a subagent spawn */ +export function isSubagentToolCall(toolName: string): boolean { + return SUBAGENT_TOOL_NAMES.has(toolName); +} + +/** Get the child pane ID for a given toolUseId */ +export function getChildPaneId(toolUseId: string): string | undefined { + return toolUseToChildPane.get(toolUseId); +} + +/** Check if a toolUseId has been mapped to a child pane */ +export function hasChildPane(toolUseId: string): boolean { + return toolUseToChildPane.has(toolUseId); +} + +export function spawnSubagentPane(parentSessionId: string, tc: ToolCallContent): void { + // Don't create duplicate pane for same tool_use + if (toolUseToChildPane.has(tc.toolUseId)) return; + const existing = findChildByToolUseId(parentSessionId, tc.toolUseId); + if (existing) { + toolUseToChildPane.set(tc.toolUseId, existing.id); + return; + } + + const childId = crypto.randomUUID(); + const prompt = typeof tc.input === 'object' && tc.input !== null + ? (tc.input as Record).prompt as string ?? tc.name + : tc.name; + const label = typeof tc.input === 'object' && tc.input !== null + ? (tc.input as Record).name as string ?? tc.name + : tc.name; + + // Register routing + toolUseToChildPane.set(tc.toolUseId, childId); + + // Create agent session with parent link + createAgentSession(childId, prompt, { + sessionId: parentSessionId, + toolUseId: tc.toolUseId, + }); + updateAgentStatus(childId, 'running'); + + // For project-scoped sessions, subagents render in TeamAgentsPanel (no layout pane) + // For non-project sessions (detached mode), create a layout pane + if (!getSessionProjectId(parentSessionId)) { + const parentPane = getPanes().find(p => p.id === parentSessionId); + const groupName = parentPane?.title ?? `Agent ${parentSessionId.slice(0, 8)}`; + addPane({ + id: childId, + type: 'agent', + title: `Sub: ${label}`, + group: groupName, + }); + } +} + +/** Clear subagent routing maps — called on dispatcher shutdown */ +export function clearSubagentRoutes(): void { + toolUseToChildPane.clear(); +} diff --git a/v2/src/lib/utils/worktree-detection.test.ts b/v2/src/lib/utils/worktree-detection.test.ts new file mode 100644 index 0000000..10af0dd --- /dev/null +++ b/v2/src/lib/utils/worktree-detection.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { detectWorktreeFromCwd } from './worktree-detection'; + +describe('detectWorktreeFromCwd', () => { + it('detects Claude Code worktree path', () => { + const result = detectWorktreeFromCwd('/home/user/project/.claude/worktrees/my-session'); + expect(result).toBe('/.claude/worktrees/my-session'); + }); + + it('detects Codex worktree path', () => { + const result = detectWorktreeFromCwd('/home/user/project/.codex/worktrees/task-1'); + expect(result).toBe('/.codex/worktrees/task-1'); + }); + + it('detects Cursor worktree path', () => { + const result = detectWorktreeFromCwd('/home/user/project/.cursor/worktrees/feature-x'); + expect(result).toBe('/.cursor/worktrees/feature-x'); + }); + + it('returns null for non-worktree CWD', () => { + expect(detectWorktreeFromCwd('/home/user/project')).toBeNull(); + expect(detectWorktreeFromCwd('/tmp/work')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(detectWorktreeFromCwd('')).toBeNull(); + }); +}); diff --git a/v2/src/lib/utils/worktree-detection.ts b/v2/src/lib/utils/worktree-detection.ts new file mode 100644 index 0000000..745696b --- /dev/null +++ b/v2/src/lib/utils/worktree-detection.ts @@ -0,0 +1,17 @@ +// Worktree path detection — extracts worktree paths from CWD strings +// Used by agent-dispatcher for conflict suppression (agents in different worktrees don't conflict) + +const WORKTREE_CWD_PATTERNS = [ + /\/\.claude\/worktrees\/([^/]+)/, // Claude Code: /.claude/worktrees// + /\/\.codex\/worktrees\/([^/]+)/, // Codex + /\/\.cursor\/worktrees\/([^/]+)/, // Cursor +]; + +/** Extract worktree path from CWD if it matches a known worktree pattern */ +export function detectWorktreeFromCwd(cwd: string): string | null { + for (const pattern of WORKTREE_CWD_PATTERNS) { + const match = cwd.match(pattern); + if (match) return match[0]; // Return the full worktree path segment + } + return null; +}