# P0 Architecture Fixes Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Fix the three P0 issues from the architecture review: split useHookServer, add protocol resilience, and add MCP collision detection.

**Architecture:** Decompose the 632-line god hook into focused hooks while preserving the public API. Make the protocol layer forward-compatible by relaxing version checks and auto-passthroughing unknown events. Add collision detection to MCP server registration matching the existing command registry pattern.

**Tech Stack:** React hooks (Ink), TypeScript, vitest, net (Node.js UDS)

---

## Task 1: Add Protocol Resilience (envelope.ts + hooks.test.ts)

The smallest, most self-contained P0 fix. No dependencies on the other tasks.

**Files:**

- Modify: `source/types/hooks/envelope.ts:74-96` (isValidHookEventEnvelope)
- Modify: `source/types/hooks/envelope.ts:55-69` (VALID_HOOK_EVENT_NAMES)
- Modify: `source/hooks/useHookServer.ts:455-458` (rejection → passthrough)
- Test: `source/types/hooks.test.ts`

### Step 1: Write failing tests for version range and unknown events

Add these tests to `source/types/hooks.test.ts` inside the `isValidHookEventEnvelope` describe block:

```typescript
it('should accept envelopes with version >= PROTOCOL_VERSION (forward compat)', () => {
	expect(
		isValidHookEventEnvelope({...validEnvelope, v: PROTOCOL_VERSION + 1}),
	).toBe(true);
});

it('should reject envelopes with version < 1 (no backwards compat below v1)', () => {
	expect(isValidHookEventEnvelope({...validEnvelope, v: 0})).toBe(false);
});

it('should accept unknown hook event names for forward compatibility', () => {
	expect(
		isValidHookEventEnvelope({
			...validEnvelope,
			hook_event_name: 'FutureEvent',
		}),
	).toBe(true);
});
```

### Step 2: Run tests to verify they fail

Run: `npx vitest run source/types/hooks.test.ts`
Expected: 3 FAIL (version 2 rejected, FutureEvent rejected)

### Step 3: Update isValidHookEventEnvelope for forward compatibility

In `source/types/hooks/envelope.ts`, change the validation function:

```typescript
export function isValidHookEventEnvelope(
	obj: unknown,
): obj is HookEventEnvelope {
	if (typeof obj !== 'object' || obj === null) {
		return false;
	}

	const envelope = obj as Record<string, unknown>;

	return (
		typeof envelope['v'] === 'number' &&
		envelope['v'] >= 1 && // Accept current and future versions
		envelope['kind'] === 'hook_event' &&
		typeof envelope['request_id'] === 'string' &&
		envelope['request_id'].length > 0 &&
		typeof envelope['ts'] === 'number' &&
		typeof envelope['session_id'] === 'string' &&
		typeof envelope['hook_event_name'] === 'string' &&
		envelope['hook_event_name'].length > 0 && // Accept any non-empty event name
		typeof envelope['payload'] === 'object' &&
		envelope['payload'] !== null
	);
}
```

Key changes:

- `envelope['v'] === PROTOCOL_VERSION` → `envelope['v'] >= 1` (accept future versions)
- `VALID_HOOK_EVENT_NAMES.has(...)` → `envelope['hook_event_name'].length > 0` (accept unknown events)

**Do NOT remove** `VALID_HOOK_EVENT_NAMES` — it's still useful for documentation and for the handler dispatch chain to know which events it explicitly handles. Add a comment that it's now used for documentation, not validation.

### Step 4: Update the VALID_HOOK_EVENT_NAMES comment

```typescript
/**
 * Known hook event names (documentation reference).
 *
 * NOTE: Validation no longer rejects unknown event names for forward
 * compatibility. Unknown events are auto-passthroughed by the server.
 * This set is kept for documentation and for handlers that want to
 * check if an event is one they explicitly support.
 */
export const VALID_HOOK_EVENT_NAMES = new Set<string>([...]);
```

### Step 5: Update the existing test that expects unknown events to be rejected

In `source/types/hooks.test.ts`, update the test at ~line 109:

```typescript
it('should accept unknown hook_event_name for forward compatibility', () => {
	expect(
		isValidHookEventEnvelope({
			...validEnvelope,
			hook_event_name: 'InvalidEvent',
		}),
	).toBe(true);
});
```

And update the version test at ~line 91:

```typescript
it('should return false for missing or invalid version', () => {
	expect(isValidHookEventEnvelope({...validEnvelope, v: undefined})).toBe(
		false,
	);
	expect(isValidHookEventEnvelope({...validEnvelope, v: 0})).toBe(false);
});
```

### Step 6: Run tests to verify they pass

Run: `npx vitest run source/types/hooks.test.ts`
Expected: ALL PASS

### Step 7: Run lint and typecheck

Run: `npm run lint && npx tsc --noEmit`
Expected: PASS

### Step 8: Commit

```bash
git add source/types/hooks/envelope.ts source/types/hooks.test.ts
git commit -m "fix: accept unknown hook events and future protocol versions for forward compatibility"
```

---

## Task 2: Add MCP Server Name Collision Detection (register.ts)

Small, self-contained fix. Follows the existing pattern in command registry.

**Files:**

- Modify: `source/plugins/register.ts:27-34`
- Test: `source/plugins/__tests__/register.test.ts`

### Step 1: Write failing test for collision detection

Add to `source/plugins/__tests__/register.test.ts`:

```typescript
it('throws when multiple plugins define the same MCP server name', () => {
	addPlugin('/plugins/a', {
		mcpServers: {database: {command: 'pg-server'}},
	});
	addPlugin('/plugins/b', {
		mcpServers: {database: {command: 'mysql-server'}},
	});

	expect(() => registerPlugins(['/plugins/a', '/plugins/b'])).toThrow(
		/MCP server name collision.*"database"/,
	);
});

it('allows different MCP server names across plugins', () => {
	addPlugin('/plugins/a', {
		mcpServers: {serverA: {command: 'a'}},
	});
	addPlugin('/plugins/b', {
		mcpServers: {serverB: {command: 'b'}},
	});

	expect(() => registerPlugins(['/plugins/a', '/plugins/b'])).not.toThrow();
});
```

### Step 2: Run tests to verify the collision test fails

Run: `npx vitest run source/plugins/__tests__/register.test.ts`
Expected: 1 FAIL (collision test), 1 PASS (different names test)

### Step 3: Add collision detection to registerPlugins

In `source/plugins/register.ts`, replace lines 27-34:

```typescript
// Collect MCP configs
const mcpPath = path.join(dir, '.mcp.json');
if (fs.existsSync(mcpPath)) {
	const config = JSON.parse(fs.readFileSync(mcpPath, 'utf-8')) as {
		mcpServers?: Record<string, unknown>;
	};
	for (const serverName of Object.keys(config.mcpServers ?? {})) {
		if (serverName in mergedServers) {
			throw new Error(
				`MCP server name collision: "${serverName}" is defined by multiple plugins. ` +
					'Each MCP server must have a unique name across all plugins.',
			);
		}
	}
	Object.assign(mergedServers, config.mcpServers ?? {});
}
```

### Step 4: Run tests to verify they pass

Run: `npx vitest run source/plugins/__tests__/register.test.ts`
Expected: ALL PASS

### Step 5: Run lint and typecheck

Run: `npm run lint && npx tsc --noEmit`
Expected: PASS

### Step 6: Commit

```bash
git add source/plugins/register.ts source/plugins/__tests__/register.test.ts
git commit -m "fix: detect MCP server name collisions across plugins"
```

---

## Task 3: Extract usePermissionQueue hook

Begin the useHookServer decomposition. Start with the most self-contained concern.

**Files:**

- Create: `source/hooks/usePermissionQueue.ts`
- Create: `source/hooks/usePermissionQueue.test.ts`
- Modify: `source/hooks/useHookServer.ts` (consume new hook)
- Modify: `source/types/server.ts` (extract queue types if needed)

### Step 1: Write the test for usePermissionQueue

Create `source/hooks/usePermissionQueue.test.ts`:

```typescript
/** @vitest-environment jsdom */
import {describe, it, expect} from 'vitest';
import {renderHook, act} from '@testing-library/react';
import {usePermissionQueue} from './usePermissionQueue.js';
import type {HookEventDisplay} from '../types/hooks/display.js';

function makeEvent(requestId: string): HookEventDisplay {
	return {
		id: `id-${requestId}`,
		requestId,
		timestamp: new Date(),
		hookName: 'PreToolUse',
		toolName: 'Bash',
		payload: {} as HookEventDisplay['payload'],
		status: 'pending',
	};
}

describe('usePermissionQueue', () => {
	it('starts with empty queue', () => {
		const {result} = renderHook(() => usePermissionQueue([]));
		expect(result.current.currentPermissionRequest).toBeNull();
		expect(result.current.permissionQueueCount).toBe(0);
	});

	it('enqueue adds to queue and currentPermissionRequest returns first match', () => {
		const events = [makeEvent('req-1'), makeEvent('req-2')];
		const {result} = renderHook(() => usePermissionQueue(events));

		act(() => result.current.enqueue('req-1'));
		act(() => result.current.enqueue('req-2'));

		expect(result.current.permissionQueueCount).toBe(2);
		expect(result.current.currentPermissionRequest?.requestId).toBe('req-1');
	});

	it('dequeue removes from queue', () => {
		const events = [makeEvent('req-1'), makeEvent('req-2')];
		const {result} = renderHook(() => usePermissionQueue(events));

		act(() => result.current.enqueue('req-1'));
		act(() => result.current.enqueue('req-2'));
		act(() => result.current.dequeue('req-1'));

		expect(result.current.permissionQueueCount).toBe(1);
		expect(result.current.currentPermissionRequest?.requestId).toBe('req-2');
	});

	it('removeAll removes specified request IDs', () => {
		const events = [makeEvent('req-1'), makeEvent('req-2'), makeEvent('req-3')];
		const {result} = renderHook(() => usePermissionQueue(events));

		act(() => result.current.enqueue('req-1'));
		act(() => result.current.enqueue('req-2'));
		act(() => result.current.enqueue('req-3'));
		act(() => result.current.removeAll(['req-1', 'req-3']));

		expect(result.current.permissionQueueCount).toBe(1);
		expect(result.current.currentPermissionRequest?.requestId).toBe('req-2');
	});
});
```

### Step 2: Run tests to verify they fail (module not found)

Run: `npx vitest run source/hooks/usePermissionQueue.test.ts`
Expected: FAIL (Cannot find module)

### Step 3: Create usePermissionQueue hook

Create `source/hooks/usePermissionQueue.ts`:

```typescript
import {useState, useCallback} from 'react';
import type {HookEventDisplay} from '../types/hooks/display.js';

export type UsePermissionQueueResult = {
	/** Current permission request (first in queue) */
	currentPermissionRequest: HookEventDisplay | null;
	/** Number of queued permission requests */
	permissionQueueCount: number;
	/** Add a request ID to the queue */
	enqueue: (requestId: string) => void;
	/** Remove a request ID from the queue */
	dequeue: (requestId: string) => void;
	/** Remove multiple request IDs at once (e.g., on socket close) */
	removeAll: (requestIds: string[]) => void;
};

export function usePermissionQueue(
	events: HookEventDisplay[],
): UsePermissionQueueResult {
	const [queue, setQueue] = useState<string[]>([]);

	const enqueue = useCallback((requestId: string) => {
		setQueue(prev => [...prev, requestId]);
	}, []);

	const dequeue = useCallback((requestId: string) => {
		setQueue(prev => prev.filter(id => id !== requestId));
	}, []);

	const removeAll = useCallback((requestIds: string[]) => {
		setQueue(prev => prev.filter(id => !requestIds.includes(id)));
	}, []);

	const currentPermissionRequest =
		queue.length > 0
			? (events.find(e => e.requestId === queue[0]) ?? null)
			: null;

	return {
		currentPermissionRequest,
		permissionQueueCount: queue.length,
		enqueue,
		dequeue,
		removeAll,
	};
}
```

### Step 4: Run tests to verify they pass

Run: `npx vitest run source/hooks/usePermissionQueue.test.ts`
Expected: ALL PASS

### Step 5: Commit

```bash
git add source/hooks/usePermissionQueue.ts source/hooks/usePermissionQueue.test.ts
git commit -m "feat: extract usePermissionQueue from useHookServer"
```

---

## Task 4: Extract useQuestionQueue hook

Same pattern as usePermissionQueue — extract the question queue concern.

**Files:**

- Create: `source/hooks/useQuestionQueue.ts`
- Create: `source/hooks/useQuestionQueue.test.ts`

### Step 1: Write the test for useQuestionQueue

Create `source/hooks/useQuestionQueue.test.ts`:

```typescript
/** @vitest-environment jsdom */
import {describe, it, expect} from 'vitest';
import {renderHook, act} from '@testing-library/react';
import {useQuestionQueue} from './useQuestionQueue.js';
import type {HookEventDisplay} from '../types/hooks/display.js';

function makeEvent(requestId: string): HookEventDisplay {
	return {
		id: `id-${requestId}`,
		requestId,
		timestamp: new Date(),
		hookName: 'PreToolUse',
		toolName: 'AskUserQuestion',
		payload: {} as HookEventDisplay['payload'],
		status: 'pending',
	};
}

describe('useQuestionQueue', () => {
	it('starts with empty queue', () => {
		const {result} = renderHook(() => useQuestionQueue([]));
		expect(result.current.currentQuestionRequest).toBeNull();
		expect(result.current.questionQueueCount).toBe(0);
	});

	it('enqueue and dequeue work correctly', () => {
		const events = [makeEvent('req-1'), makeEvent('req-2')];
		const {result} = renderHook(() => useQuestionQueue(events));

		act(() => result.current.enqueue('req-1'));
		expect(result.current.currentQuestionRequest?.requestId).toBe('req-1');

		act(() => result.current.enqueue('req-2'));
		expect(result.current.questionQueueCount).toBe(2);

		act(() => result.current.dequeue('req-1'));
		expect(result.current.currentQuestionRequest?.requestId).toBe('req-2');
	});

	it('removeAll removes specified request IDs', () => {
		const events = [makeEvent('req-1'), makeEvent('req-2')];
		const {result} = renderHook(() => useQuestionQueue(events));

		act(() => result.current.enqueue('req-1'));
		act(() => result.current.enqueue('req-2'));
		act(() => result.current.removeAll(['req-1']));

		expect(result.current.questionQueueCount).toBe(1);
	});
});
```

### Step 2: Create useQuestionQueue hook

Create `source/hooks/useQuestionQueue.ts` — same structure as usePermissionQueue:

```typescript
import {useState, useCallback} from 'react';
import type {HookEventDisplay} from '../types/hooks/display.js';

export type UseQuestionQueueResult = {
	currentQuestionRequest: HookEventDisplay | null;
	questionQueueCount: number;
	enqueue: (requestId: string) => void;
	dequeue: (requestId: string) => void;
	removeAll: (requestIds: string[]) => void;
};

export function useQuestionQueue(
	events: HookEventDisplay[],
): UseQuestionQueueResult {
	const [queue, setQueue] = useState<string[]>([]);

	const enqueue = useCallback((requestId: string) => {
		setQueue(prev => [...prev, requestId]);
	}, []);

	const dequeue = useCallback((requestId: string) => {
		setQueue(prev => prev.filter(id => id !== requestId));
	}, []);

	const removeAll = useCallback((requestIds: string[]) => {
		setQueue(prev => prev.filter(id => !requestIds.includes(id)));
	}, []);

	const currentQuestionRequest =
		queue.length > 0
			? (events.find(e => e.requestId === queue[0]) ?? null)
			: null;

	return {
		currentQuestionRequest,
		questionQueueCount: queue.length,
		enqueue,
		dequeue,
		removeAll,
	};
}
```

### Step 3: Run tests

Run: `npx vitest run source/hooks/useQuestionQueue.test.ts`
Expected: ALL PASS

### Step 4: Commit

```bash
git add source/hooks/useQuestionQueue.ts source/hooks/useQuestionQueue.test.ts
git commit -m "feat: extract useQuestionQueue from useHookServer"
```

---

## Task 5: Extract useEventDispatcher (handler chain logic)

Extract the event handler functions and dispatch chain from the useEffect body into a standalone module. This is the core decomposition step.

**Files:**

- Create: `source/hooks/eventHandlers.ts`
- Create: `source/hooks/eventHandlers.test.ts`
- Modify: `source/hooks/useHookServer.ts` (import and use handlers)

### Step 1: Write tests for the event handler module

Create `source/hooks/eventHandlers.test.ts`:

```typescript
import {describe, it, expect, vi} from 'vitest';
import {
	handleSubagentStop,
	handlePermissionRequest,
	handleAskUserQuestion,
	handlePreToolUseRules,
	handlePermissionCheck,
	handleSessionTracking,
	dispatchEvent,
	type HandlerContext,
	type HandlerCallbacks,
} from './eventHandlers.js';
import type {HookEventEnvelope} from '../types/hooks/envelope.js';
import type {HookEventDisplay} from '../types/hooks/display.js';
import type {HookRule} from '../types/rules.js';

function makeCtx(
	hookEventName: string,
	payload: Record<string, unknown>,
	overrides?: Partial<HandlerContext>,
): HandlerContext {
	return {
		envelope: {
			v: 1,
			kind: 'hook_event',
			request_id: 'req-1',
			ts: Date.now(),
			session_id: 'sess-1',
			hook_event_name: hookEventName as HookEventEnvelope['hook_event_name'],
			payload: payload as HookEventEnvelope['payload'],
		},
		displayEvent: {
			id: 'evt-1',
			requestId: 'req-1',
			timestamp: new Date(),
			hookName: hookEventName,
			payload: payload as HookEventDisplay['payload'],
			status: 'pending',
		},
		receiveTimestamp: Date.now(),
		...overrides,
	};
}

function makeCallbacks(): HandlerCallbacks & {
	_rules: HookRule[];
} {
	return {
		_rules: [],
		getRules: function () {
			return this._rules;
		},
		storeWithAutoPassthrough: vi.fn(),
		storeWithoutPassthrough: vi.fn(),
		addEvent: vi.fn(),
		respond: vi.fn(),
		enqueuePermission: vi.fn(),
		enqueueQuestion: vi.fn(),
		setCurrentSessionId: vi.fn(),
		onTranscriptParsed: vi.fn(),
	};
}

describe('handlePermissionRequest', () => {
	it('returns false for non-PermissionRequest events', () => {
		const ctx = makeCtx('PreToolUse', {tool_name: 'Bash', tool_input: {}});
		const cb = makeCallbacks();
		expect(handlePermissionRequest(ctx, cb)).toBe(false);
	});

	it('blocks when a deny rule matches', () => {
		const ctx = makeCtx('PermissionRequest', {
			hook_event_name: 'PermissionRequest',
			tool_name: 'Bash',
			tool_input: {},
		});
		const cb = makeCallbacks();
		cb._rules = [{id: '1', toolName: 'Bash', action: 'deny', addedBy: 'test'}];

		expect(handlePermissionRequest(ctx, cb)).toBe(true);
		expect(cb.respond).toHaveBeenCalled();
		expect(cb.addEvent).toHaveBeenCalled();
	});

	it('auto-allows when no deny rule', () => {
		const ctx = makeCtx('PermissionRequest', {
			hook_event_name: 'PermissionRequest',
			tool_name: 'Bash',
			tool_input: {},
		});
		const cb = makeCallbacks();

		expect(handlePermissionRequest(ctx, cb)).toBe(true);
		expect(cb.respond).toHaveBeenCalled();
		// Should NOT addEvent (PermissionRequest is suppressed in UI)
		expect(cb.addEvent).not.toHaveBeenCalled();
	});
});

describe('handleAskUserQuestion', () => {
	it('returns false for non-AskUserQuestion PreToolUse events', () => {
		const ctx = makeCtx('PreToolUse', {
			hook_event_name: 'PreToolUse',
			tool_name: 'Bash',
			tool_input: {},
		});
		const cb = makeCallbacks();
		expect(handleAskUserQuestion(ctx, cb)).toBe(false);
	});

	it('enqueues AskUserQuestion events', () => {
		const ctx = makeCtx('PreToolUse', {
			hook_event_name: 'PreToolUse',
			tool_name: 'AskUserQuestion',
			tool_input: {},
		});
		const cb = makeCallbacks();

		expect(handleAskUserQuestion(ctx, cb)).toBe(true);
		expect(cb.enqueueQuestion).toHaveBeenCalledWith('req-1');
		expect(cb.addEvent).toHaveBeenCalled();
	});
});

describe('dispatchEvent', () => {
	it('falls through to default auto-passthrough when no handler matches', () => {
		const ctx = makeCtx('Notification', {
			hook_event_name: 'Notification',
			message: 'test',
		});
		const cb = makeCallbacks();

		dispatchEvent(ctx, cb);

		expect(cb.storeWithAutoPassthrough).toHaveBeenCalledWith(ctx);
		expect(cb.addEvent).toHaveBeenCalled();
	});
});
```

### Step 2: Create the eventHandlers module

Create `source/hooks/eventHandlers.ts`:

Extract the 5 handler functions + dispatch logic from useHookServer's useEffect body. Each handler becomes a pure function that takes `(ctx, callbacks)` instead of closing over React state.

```typescript
import type {HookEventEnvelope} from '../types/hooks/envelope.js';
import type {HookEventDisplay} from '../types/hooks/display.js';
import type {HookResultPayload} from '../types/hooks/result.js';
import type {HookRule} from '../types/rules.js';
import {matchRule} from '../types/rules.js';
import {
	isToolEvent,
	isSubagentStartEvent,
	isSubagentStopEvent,
	createPassthroughResult,
	createPreToolUseAllowResult,
	createPreToolUseDenyResult,
	createBlockResult,
	createPermissionRequestAllowResult,
} from '../types/hooks/index.js';
import {isPermissionRequired} from '../services/permissionPolicy.js';
import {parseTranscriptFile} from '../utils/transcriptParser.js';

/** Shared context passed to each handler in the dispatch chain. */
export type HandlerContext = {
	envelope: HookEventEnvelope;
	displayEvent: HookEventDisplay;
	receiveTimestamp: number;
};

/** Callbacks the handlers use to affect state (provided by useHookServer). */
export type HandlerCallbacks = {
	getRules: () => HookRule[];
	storeWithAutoPassthrough: (ctx: HandlerContext) => void;
	storeWithoutPassthrough: (ctx: HandlerContext) => void;
	addEvent: (event: HookEventDisplay) => void;
	respond: (requestId: string, result: HookResultPayload) => void;
	enqueuePermission: (requestId: string) => void;
	enqueueQuestion: (requestId: string) => void;
	setCurrentSessionId: (sessionId: string) => void;
	onTranscriptParsed: (
		eventId: string,
		summary: HookEventDisplay['transcriptSummary'],
	) => void;
};

/** Handle SubagentStop: add as first-class event and parse transcript. */
export function handleSubagentStop(
	ctx: HandlerContext,
	cb: HandlerCallbacks,
): boolean {
	const {envelope, displayEvent} = ctx;
	if (!isSubagentStopEvent(envelope.payload)) return false;

	cb.storeWithAutoPassthrough(ctx);
	cb.addEvent(displayEvent);

	const transcriptPath = envelope.payload.agent_transcript_path;
	if (transcriptPath) {
		parseTranscriptFile(transcriptPath)
			.then(summary => cb.onTranscriptParsed(displayEvent.id, summary))
			.catch(err =>
				console.error('[SubagentStop] Failed to parse transcript:', err),
			);
	}

	return true;
}

/** Auto-allow PermissionRequest events (deny rules still apply). */
export function handlePermissionRequest(
	ctx: HandlerContext,
	cb: HandlerCallbacks,
): boolean {
	const {envelope} = ctx;
	if (envelope.hook_event_name !== 'PermissionRequest') return false;
	if (!isToolEvent(envelope.payload)) return false;

	const matchedRule = matchRule(cb.getRules(), envelope.payload.tool_name);
	if (matchedRule?.action === 'deny') {
		cb.storeWithoutPassthrough(ctx);
		cb.addEvent(ctx.displayEvent);
		cb.respond(
			envelope.request_id,
			createBlockResult(`Blocked by rule: ${matchedRule.addedBy}`),
		);
		return true;
	}

	cb.storeWithoutPassthrough(ctx);
	cb.respond(envelope.request_id, createPermissionRequestAllowResult());
	return true;
}

/** Route AskUserQuestion events to the question queue. */
export function handleAskUserQuestion(
	ctx: HandlerContext,
	cb: HandlerCallbacks,
): boolean {
	const {envelope} = ctx;
	if (
		envelope.hook_event_name !== 'PreToolUse' ||
		!isToolEvent(envelope.payload) ||
		envelope.payload.tool_name !== 'AskUserQuestion'
	) {
		return false;
	}

	cb.storeWithoutPassthrough(ctx);
	cb.addEvent(ctx.displayEvent);
	cb.enqueueQuestion(envelope.request_id);
	return true;
}

/** Apply matching rules to PreToolUse events. */
export function handlePreToolUseRules(
	ctx: HandlerContext,
	cb: HandlerCallbacks,
): boolean {
	const {envelope} = ctx;
	if (
		envelope.hook_event_name !== 'PreToolUse' ||
		!isToolEvent(envelope.payload)
	) {
		return false;
	}

	const matchedRule = matchRule(cb.getRules(), envelope.payload.tool_name);
	if (!matchedRule) return false;

	const result =
		matchedRule.action === 'deny'
			? createPreToolUseDenyResult(`Blocked by rule: ${matchedRule.addedBy}`)
			: createPreToolUseAllowResult();

	cb.storeWithoutPassthrough(ctx);
	cb.addEvent(ctx.displayEvent);
	cb.respond(envelope.request_id, result);
	return true;
}

/** Route permission-required PreToolUse events to the permission queue. */
export function handlePermissionCheck(
	ctx: HandlerContext,
	cb: HandlerCallbacks,
): boolean {
	const {envelope} = ctx;
	if (
		envelope.hook_event_name !== 'PreToolUse' ||
		!isToolEvent(envelope.payload) ||
		!isPermissionRequired(
			envelope.payload.tool_name,
			cb.getRules(),
			envelope.payload.tool_input,
		)
	) {
		return false;
	}

	cb.storeWithoutPassthrough(ctx);
	cb.addEvent(ctx.displayEvent);
	cb.enqueuePermission(envelope.request_id);
	return true;
}

/** Capture session ID and enrich SessionEnd with transcript data. */
export function handleSessionTracking(
	ctx: HandlerContext,
	cb: HandlerCallbacks,
): void {
	const {envelope, displayEvent} = ctx;

	if (envelope.hook_event_name === 'SessionStart') {
		cb.setCurrentSessionId(envelope.session_id);
	}

	if (envelope.hook_event_name !== 'SessionEnd') return;

	const transcriptPath = (envelope.payload as Record<string, unknown>)
		.transcript_path as string | undefined;
	if (transcriptPath) {
		parseTranscriptFile(transcriptPath)
			.then(summary => cb.onTranscriptParsed(displayEvent.id, summary))
			.catch(err =>
				console.error('[SessionEnd] Failed to parse transcript:', err),
			);
	} else {
		cb.onTranscriptParsed(displayEvent.id, {
			lastAssistantText: null,
			lastAssistantTimestamp: null,
			messageCount: 0,
			toolCallCount: 0,
			error: 'No transcript path provided',
		});
	}
}

/** Track active subagents and tag child events. */
export function tagSubagentEvents(
	envelope: HookEventEnvelope,
	displayEvent: HookEventDisplay,
	activeSubagentStack: string[],
): string[] {
	if (isSubagentStartEvent(envelope.payload)) {
		return [...activeSubagentStack, envelope.payload.agent_id];
	}
	if (isSubagentStopEvent(envelope.payload)) {
		return activeSubagentStack.filter(id => id !== envelope.payload.agent_id);
	}
	if (activeSubagentStack.length > 0) {
		displayEvent.parentSubagentId =
			activeSubagentStack[activeSubagentStack.length - 1];
	}
	return activeSubagentStack;
}

/** Run the full dispatch chain: first match wins, then session tracking. */
export function dispatchEvent(ctx: HandlerContext, cb: HandlerCallbacks): void {
	const handled =
		handleSubagentStop(ctx, cb) ||
		handlePermissionRequest(ctx, cb) ||
		handleAskUserQuestion(ctx, cb) ||
		handlePreToolUseRules(ctx, cb) ||
		handlePermissionCheck(ctx, cb);

	if (!handled) {
		cb.storeWithAutoPassthrough(ctx);
		cb.addEvent(ctx.displayEvent);
	}

	handleSessionTracking(ctx, cb);
}
```

### Step 3: Run tests

Run: `npx vitest run source/hooks/eventHandlers.test.ts`
Expected: ALL PASS

### Step 4: Commit

```bash
git add source/hooks/eventHandlers.ts source/hooks/eventHandlers.test.ts
git commit -m "feat: extract event handler dispatch chain into standalone module"
```

---

## Task 6: Wire extracted hooks into useHookServer

Replace inline state/logic in useHookServer with the new hooks and eventHandlers module.

**Files:**

- Modify: `source/hooks/useHookServer.ts`
- Verify: existing `source/hooks/useHookServer.test.ts` still passes

### Step 1: Refactor useHookServer to use extracted modules

The refactored useHookServer should:

1. Use `usePermissionQueue(events)` instead of inline `permissionQueue` state
2. Use `useQuestionQueue(events)` instead of inline `questionQueue` state
3. Use `dispatchEvent()` + `tagSubagentEvents()` from eventHandlers instead of inline handler functions
4. The `rulesRef` workaround gets resolved naturally — `cb.getRules()` reads from the ref

The useEffect body shrinks from ~420 lines to ~130 lines (socket lifecycle + NDJSON parsing + calling dispatchEvent).

Key structure of the refactored hook:

```typescript
export function useHookServer(projectDir, instanceId): UseHookServerResult {
	// --- State ---
	const [events, setEvents] = useState<HookEventDisplay[]>([]);
	const [isServerRunning, setIsServerRunning] = useState(false);
	const [socketPath, setSocketPath] = useState<string | null>(null);
	const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
	const [rules, setRules] = useState<HookRule[]>([]);
	const rulesRef = useRef<HookRule[]>([]);
	rulesRef.current = rules;

	// --- Extracted queues ---
	const permissionQ = usePermissionQueue(events);
	const questionQ = useQuestionQueue(events);

	// --- Refs ---
	const serverRef = useRef<net.Server | null>(null);
	const pendingRequestsRef = useRef<Map<string, PendingRequest>>(new Map());
	const activeSubagentStackRef = useRef<string[]>([]);
	const isMountedRef = useRef(true);

	// ... respond, resolvePermission, resolveQuestion callbacks (same as before,
	//     but resolvePermission calls permissionQ.dequeue, resolveQuestion calls questionQ.dequeue)

	// --- useEffect: server lifecycle ---
	useEffect(() => {
		isMountedRef.current = true;
		// ... socket setup (same as before)

		// Build HandlerCallbacks from state setters + queue hooks
		const callbacks: HandlerCallbacks = {
			getRules: () => rulesRef.current,
			storeWithAutoPassthrough: ctx => {
				/* same logic */
			},
			storeWithoutPassthrough: ctx => {
				/* same logic */
			},
			addEvent: event =>
				setEvents(prev => {
					/* same logic */
				}),
			respond,
			enqueuePermission: permissionQ.enqueue,
			enqueueQuestion: questionQ.enqueue,
			setCurrentSessionId,
			onTranscriptParsed: (eventId, summary) => {
				if (isMountedRef.current) {
					setEvents(prev =>
						prev.map(e =>
							e.id === eventId ? {...e, transcriptSummary: summary} : e,
						),
					);
				}
			},
		};

		const server = net.createServer(socket => {
			let data = '';
			socket.on('data', chunk => {
				// ... NDJSON parsing (same as before)
				// Build HandlerContext
				const ctx = {envelope, displayEvent, receiveTimestamp: Date.now()};
				// Tag subagent events
				activeSubagentStackRef.current = tagSubagentEvents(
					envelope,
					ctx.displayEvent,
					activeSubagentStackRef.current,
				);
				// Dispatch
				dispatchEvent(ctx, callbacks);
			});
			socket.on('close', () => {
				// ... cleanup pending, remove from BOTH queues
				permissionQ.removeAll(closedRequestIds);
				questionQ.removeAll(closedRequestIds);
			});
		});

		// ... server.listen, cleanup return (same as before)
	}, [
		projectDir,
		instanceId,
		respond,
		permissionQ.enqueue,
		questionQ.enqueue,
		permissionQ.removeAll,
		questionQ.removeAll,
	]);

	return {
		events,
		isServerRunning,
		respond,
		pendingEvents,
		socketPath,
		currentSessionId,
		resetSession,
		rules,
		addRule,
		removeRule,
		clearRules,
		clearEvents,
		currentPermissionRequest: permissionQ.currentPermissionRequest,
		permissionQueueCount: permissionQ.permissionQueueCount,
		resolvePermission,
		currentQuestionRequest: questionQ.currentQuestionRequest,
		questionQueueCount: questionQ.questionQueueCount,
		resolveQuestion,
	};
}
```

### Step 2: Run the existing integration test

Run: `npx vitest run source/hooks/useHookServer.test.ts`
Expected: ALL PASS (the public API hasn't changed, so tests should still work)

### Step 3: Run the full test suite

Run: `npm test`
Expected: ALL PASS

### Step 4: Run lint and typecheck

Run: `npm run lint && npx tsc --noEmit`
Expected: PASS

### Step 5: Commit

```bash
git add source/hooks/useHookServer.ts
git commit -m "refactor: wire extracted queue hooks and event handlers into useHookServer"
```

---

## Task 7: Final verification

### Step 1: Run full test suite

Run: `npm test`
Expected: ALL PASS

### Step 2: Run lint and typecheck

Run: `npm run lint && npx tsc --noEmit`
Expected: ALL PASS

### Step 3: Build

Run: `npm run build`
Expected: PASS

### Step 4: Review line counts

Check that useHookServer is substantially shorter:

- Before: 632 lines
- After: ~250-300 lines (socket lifecycle + state + plumbing)
- eventHandlers.ts: ~200 lines (dispatch logic)
- usePermissionQueue.ts: ~40 lines
- useQuestionQueue.ts: ~40 lines
