# Ticket 6.2 Deferred ACs — Large Tool Results, Rate Limiting, Disk Space Monitoring

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

**Goal:** Implement the three acceptance criteria from ticket 6.2 that were deferred by the refactoring design doc (AC5, AC7, AC8): large tool result truncation with "show full" toggle, client message rate limiting with queuing, and disk space monitoring with cache eviction.

**Architecture:** AC5 truncates tool results >50KB at the event-translator layer, stores full content in an in-memory `ToolContentStore`, and adds a "Show full output" button to `ToolItem.svelte` that fetches on demand via a new `get_tool_content` WS message. AC7 adds a sliding-window rate limiter (5 messages/10s) on both client and server, queueing excess messages client-side with toast feedback. AC8 creates a `StorageMonitor` (EventEmitter + periodic `fs.statfs()` polling) wired into the Daemon lifecycle, with log warnings at 100MB free and optional MessageCache size-aware eviction.

**Tech Stack:** TypeScript, Svelte 5, Vitest, Node.js `fs.statfs()`

**Depends on:** The refactoring plan (`2026-02-27-refactoring-implementation.md`) Tasks 1-2 should be completed first (shared-types.ts exists with `RelayMessage`). If working on branch `refactor/type-safety-and-organization`, those are already done.

---

## AC5 — Large Tool Results Truncation

### Task 1: Create ToolContentStore with tests

**Files:**
- Create: `src/lib/tool-content-store.ts`
- Create: `test/unit/tool-content-store.test.ts`

**Step 1: Write the failing tests**

```typescript
// test/unit/tool-content-store.test.ts
import { describe, expect, it } from "vitest";
import { ToolContentStore } from "../../src/lib/tool-content-store.js";

describe("ToolContentStore", () => {
	it("stores and retrieves full content by tool ID", () => {
		const store = new ToolContentStore();
		store.store("tool-1", "full content here");
		expect(store.get("tool-1")).toBe("full content here");
	});

	it("returns undefined for unknown tool ID", () => {
		const store = new ToolContentStore();
		expect(store.get("unknown")).toBeUndefined();
	});

	it("clears content for a session", () => {
		const store = new ToolContentStore();
		store.store("tool-1", "content", "session-a");
		store.store("tool-2", "content", "session-a");
		store.store("tool-3", "content", "session-b");
		store.clearSession("session-a");
		expect(store.get("tool-1")).toBeUndefined();
		expect(store.get("tool-2")).toBeUndefined();
		expect(store.get("tool-3")).toBe("content");
	});

	it("evicts oldest entries when over capacity", () => {
		const store = new ToolContentStore({ maxEntries: 3 });
		store.store("t1", "a");
		store.store("t2", "b");
		store.store("t3", "c");
		store.store("t4", "d"); // should evict t1
		expect(store.get("t1")).toBeUndefined();
		expect(store.get("t4")).toBe("d");
	});

	it("reports entry count", () => {
		const store = new ToolContentStore();
		store.store("t1", "a");
		store.store("t2", "b");
		expect(store.size).toBe(2);
	});
});
```

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

Run: `pnpm vitest run test/unit/tool-content-store.test.ts`
Expected: FAIL — module not found

**Step 3: Implement ToolContentStore**

```typescript
// src/lib/tool-content-store.ts

/** In-memory store for full tool result content (before truncation). */
export interface ToolContentStoreOptions {
	/** Max entries before evicting oldest. Default: 500. */
	maxEntries?: number;
}

interface Entry {
	content: string;
	sessionId?: string;
	storedAt: number;
}

export class ToolContentStore {
	private readonly entries = new Map<string, Entry>();
	private readonly maxEntries: number;

	constructor(options?: ToolContentStoreOptions) {
		this.maxEntries = options?.maxEntries ?? 500;
	}

	/** Store full content for a tool ID. */
	store(toolId: string, content: string, sessionId?: string): void {
		this.entries.set(toolId, { content, sessionId, storedAt: Date.now() });
		this.evictIfNeeded();
	}

	/** Retrieve full content by tool ID. */
	get(toolId: string): string | undefined {
		return this.entries.get(toolId)?.content;
	}

	/** Clear all entries for a session. */
	clearSession(sessionId: string): void {
		for (const [id, entry] of this.entries) {
			if (entry.sessionId === sessionId) {
				this.entries.delete(id);
			}
		}
	}

	/** Number of stored entries. */
	get size(): number {
		return this.entries.size;
	}

	private evictIfNeeded(): void {
		while (this.entries.size > this.maxEntries) {
			const oldest = this.entries.keys().next().value;
			if (oldest !== undefined) this.entries.delete(oldest);
		}
	}
}
```

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

Run: `pnpm vitest run test/unit/tool-content-store.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add src/lib/tool-content-store.ts test/unit/tool-content-store.test.ts
git commit -m "feat: add ToolContentStore for full tool result content (ticket 6.2 AC5)"
```

---

### Task 2: Update shared types for tool result truncation

**Files:**
- Modify: `src/lib/shared-types.ts`

**Step 1: Add truncation fields to `tool_result` variant**

In `src/lib/shared-types.ts`, find the `tool_result` variant (~line 111-117):

```typescript
	| {
			type: "tool_result";
			id: string;
			content: string;
			is_error: boolean;
			messageId?: string;
	  }
```

Replace with:

```typescript
	| {
			type: "tool_result";
			id: string;
			content: string;
			is_error: boolean;
			messageId?: string;
			/** True if content was truncated to fit the wire limit. */
			isTruncated?: boolean;
			/** Original content length in characters (set when truncated). */
			fullContentLength?: number;
	  }
```

**Step 2: Add `tool_content` response variant to RelayMessage**

After the `tool_result` variant, add:

```typescript
	| {
			type: "tool_content";
			toolId: string;
			content: string;
	  }
```

**Step 3: Run type check**

Run: `pnpm check`
Expected: PASS

**Step 4: Commit**

```bash
git add src/lib/shared-types.ts
git commit -m "refactor: add truncation metadata to tool_result + tool_content variant (ticket 6.2 AC5)"
```

---

### Task 3: Add truncation utility + tests

**Files:**
- Create: `src/lib/truncate-content.ts`
- Create: `test/unit/truncate-content.test.ts`

**Step 1: Write the failing tests**

```typescript
// test/unit/truncate-content.test.ts
import { describe, expect, it } from "vitest";
import {
	truncateContent,
	TRUNCATION_THRESHOLD,
} from "../../src/lib/truncate-content.js";

describe("truncateContent", () => {
	it("returns content unchanged when under threshold", () => {
		const result = truncateContent("short content");
		expect(result).toEqual({
			content: "short content",
			isTruncated: false,
		});
	});

	it("truncates content at threshold boundary", () => {
		const big = "x".repeat(TRUNCATION_THRESHOLD + 1000);
		const result = truncateContent(big);
		expect(result.isTruncated).toBe(true);
		expect(result.content.length).toBe(TRUNCATION_THRESHOLD);
		expect(result.fullContentLength).toBe(big.length);
		expect(result.content).toBe(big.slice(0, TRUNCATION_THRESHOLD));
	});

	it("returns exact threshold length unchanged", () => {
		const exact = "y".repeat(TRUNCATION_THRESHOLD);
		const result = truncateContent(exact);
		expect(result.isTruncated).toBe(false);
		expect(result.content).toBe(exact);
	});

	it("handles empty string", () => {
		const result = truncateContent("");
		expect(result).toEqual({ content: "", isTruncated: false });
	});

	it("accepts custom threshold", () => {
		const result = truncateContent("abcdef", 3);
		expect(result).toEqual({
			content: "abc",
			isTruncated: true,
			fullContentLength: 6,
		});
	});
});
```

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

Run: `pnpm vitest run test/unit/truncate-content.test.ts`
Expected: FAIL — module not found

**Step 3: Implement**

```typescript
// src/lib/truncate-content.ts

/** Default truncation threshold: 50 KB (in characters). */
export const TRUNCATION_THRESHOLD = 50_000;

export interface TruncationResult {
	content: string;
	isTruncated: boolean;
	/** Original length, set only when truncated. */
	fullContentLength?: number;
}

/** Truncate content if it exceeds the threshold. */
export function truncateContent(
	content: string,
	threshold: number = TRUNCATION_THRESHOLD,
): TruncationResult {
	if (content.length <= threshold) {
		return { content, isTruncated: false };
	}
	return {
		content: content.slice(0, threshold),
		isTruncated: true,
		fullContentLength: content.length,
	};
}
```

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

Run: `pnpm vitest run test/unit/truncate-content.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add src/lib/truncate-content.ts test/unit/truncate-content.test.ts
git commit -m "feat: add truncateContent utility with 50KB threshold (ticket 6.2 AC5)"
```

---

### Task 4: Apply truncation in event-translator

**Files:**
- Modify: `src/lib/event-translator.ts`

The translator creates `tool_result` messages in `translateToolPartUpdated()` (~lines 119-137). This is the right place to truncate because it's upstream of both the cache and broadcast.

However, we need the full content stored in `ToolContentStore` BEFORE truncation. The translator is a pure function — it shouldn't hold a reference to the store. Instead, the truncation + store interaction should happen at the wiring layer.

**Step 1: Add truncation to the translator output**

In `src/lib/event-translator.ts`, add import at top:

```typescript
import { truncateContent } from "./truncate-content.js";
```

In `translateToolPartUpdated()`, find the `tool_result` creation for completed tools (~line 119-127). Replace the content assignment:

Before:
```typescript
content: part.state?.output ?? "",
```

After:
```typescript
...truncateToolContent(part.state?.output ?? ""),
```

And for error tools (~line 129-137):

Before:
```typescript
content: part.state?.error ?? "Unknown error",
```

After:
```typescript
...truncateToolContent(part.state?.error ?? "Unknown error"),
```

Add a helper function at the bottom of the file (or near the tool translation functions):

```typescript
function truncateToolContent(raw: string): {
	content: string;
	isTruncated?: boolean;
	fullContentLength?: number;
} {
	const result = truncateContent(raw);
	if (!result.isTruncated) return { content: raw };
	return {
		content: result.content,
		isTruncated: true,
		fullContentLength: result.fullContentLength,
	};
}
```

**Important:** We also need to return the full content so the caller (relay-stack) can store it. The translator returns `RelayMessage[]`. The cleanest approach: return the full content as a separate field on a wrapper. But that would change the translator interface.

**Alternative (simpler):** Keep the translator pure — it truncates and annotates the RelayMessage. The wiring layer in `relay-stack.ts` intercepts `tool_result` messages with `isTruncated: true`, extracts the tool ID, and re-fetches the full content from the original SSE event. But this is complex.

**Simplest approach:** Don't truncate in the translator. Truncate in the relay-stack wiring layer, between translation and broadcast. This keeps the translator pure and gives the wiring layer access to both the full content and the truncated message.

**Revised step 1: Revert translator changes. Instead, modify relay-stack wiring.**

Remove any changes to event-translator.ts. The truncation will happen in Task 5 at the relay-stack level.

**Step 2: Run type check**

Run: `pnpm check`
Expected: PASS (no changes made)

---

### Task 5: Wire truncation into relay-stack

**Files:**
- Modify: `src/lib/relay-stack.ts`

This is where SSE events are translated, cached, and broadcast. The flow at ~lines 330-360 is:

```
SSE event → translator.translate(event) → RelayMessage[] →
  for each message: cache.recordEvent() + wsHandler.broadcast()
```

**Step 1: Write the test**

In `test/unit/relay-stack.test.ts` (or create if it doesn't exist — check first), add a test:

```typescript
describe("tool result truncation (AC5)", () => {
	it("truncates tool_result content over 50KB and stores full content", () => {
		// This test verifies the wiring behavior.
		// Setup: create a tool_result message with >50KB content
		const bigContent = "x".repeat(60_000);
		const toolResultMsg: RelayMessage = {
			type: "tool_result",
			id: "tool-1",
			content: bigContent,
			is_error: false,
		};

		// Apply truncation (extract the logic into a testable function)
		const { truncated, fullContent } = truncateToolResult(toolResultMsg);

		expect(truncated.content.length).toBe(50_000);
		expect(truncated.isTruncated).toBe(true);
		expect(truncated.fullContentLength).toBe(60_000);
		expect(fullContent).toBe(bigContent);
	});

	it("passes through small tool_result unchanged", () => {
		const msg: RelayMessage = {
			type: "tool_result",
			id: "tool-2",
			content: "small",
			is_error: false,
		};
		const { truncated, fullContent } = truncateToolResult(msg);
		expect(truncated.content).toBe("small");
		expect(truncated.isTruncated).toBeUndefined();
		expect(fullContent).toBeUndefined();
	});
});
```

**Step 2: Extract a testable `truncateToolResult` function**

Create a helper (can live in `truncate-content.ts` or a new file) that takes a `tool_result` RelayMessage and returns the truncated version + optional full content:

Add to `src/lib/truncate-content.ts`:

```typescript
import type { RelayMessage } from "./types.js";

type ToolResultMessage = Extract<RelayMessage, { type: "tool_result" }>;

export interface ToolResultTruncation {
	/** The message to broadcast (truncated if needed). */
	truncated: ToolResultMessage;
	/** The original full content, if truncation occurred. */
	fullContent: string | undefined;
}

/** Truncate a tool_result message if content exceeds threshold. */
export function truncateToolResult(
	msg: ToolResultMessage,
	threshold: number = TRUNCATION_THRESHOLD,
): ToolResultTruncation {
	const result = truncateContent(msg.content, threshold);
	if (!result.isTruncated) {
		return { truncated: msg, fullContent: undefined };
	}
	return {
		truncated: {
			...msg,
			content: result.content,
			isTruncated: true,
			fullContentLength: result.fullContentLength,
		},
		fullContent: msg.content,
	};
}
```

**Step 3: Run the new test**

Run: `pnpm vitest run test/unit/truncate-content.test.ts`
Expected: PASS

**Step 4: Wire into relay-stack event processing**

In `src/lib/relay-stack.ts`, find where translated events are broadcast (~the SSE event handler). Add import:

```typescript
import { truncateToolResult } from "./truncate-content.js";
import { ToolContentStore } from "./tool-content-store.js";
```

Create the store alongside other components in `createProjectRelay()`:

```typescript
const toolContentStore = new ToolContentStore();
```

In the event processing loop, after translation but before cache + broadcast:

```typescript
for (const msg of translated) {
	let broadcastMsg = msg;

	// Truncate large tool results (Ticket 6.2 AC5)
	if (msg.type === "tool_result") {
		const { truncated, fullContent } = truncateToolResult(msg);
		if (fullContent !== undefined) {
			toolContentStore.store(msg.id, fullContent, activeSessionId);
		}
		broadcastMsg = truncated;
	}

	cache.recordEvent(sessionId, broadcastMsg);
	wsHandler.broadcast(broadcastMsg);
}
```

**Step 5: Add `get_tool_content` incoming message type to ws-router**

In `src/lib/ws-router.ts`, add `"get_tool_content"` to `VALID_MESSAGE_TYPES`.

**Step 6: Add handler in message-handlers**

In the appropriate handler file (or `src/lib/handlers/` if decomposed), add:

```typescript
async function handleGetToolContent(
	msg: { toolId: string },
	deps: HandlerDeps,
): Promise<void> {
	const content = deps.toolContentStore.get(msg.toolId);
	if (content !== undefined) {
		deps.sendTo({ type: "tool_content", toolId: msg.toolId, content });
	} else {
		deps.sendTo({
			type: "error",
			code: "NOT_FOUND",
			message: "Full tool content not available — it may have been evicted",
		});
	}
}
```

Register in the message handler dispatch table.

**Step 7: Run type check + tests**

Run: `pnpm check && pnpm test:unit`
Expected: PASS

**Step 8: Commit**

```bash
git add src/lib/truncate-content.ts src/lib/tool-content-store.ts src/lib/relay-stack.ts src/lib/ws-router.ts src/lib/message-handlers.ts test/unit/
git commit -m "feat: truncate large tool results at 50KB, store full content for on-demand retrieval (ticket 6.2 AC5)"
```

---

### Task 6: Frontend — show truncation indicator + "Show full output"

**Files:**
- Modify: `src/lib/public/types.ts` (add `isTruncated`, `fullContentLength` to `ToolMessage`)
- Modify: `src/lib/public/stores/chat.svelte.ts` (propagate truncation metadata in `handleToolResult`)
- Modify: `src/lib/public/stores/ws.svelte.ts` (handle `tool_content` response)
- Modify: `src/lib/public/components/chat/ToolItem.svelte` (add "Show full output" button)

**Step 1: Update `ToolMessage` type**

In `src/lib/public/types.ts`, find the `ToolMessage` interface (~lines 85-94). Add:

```typescript
export interface ToolMessage {
	type: "tool";
	uuid: string;
	id: string;
	name: string;
	status: "pending" | "running" | "completed" | "error";
	result?: string;
	isError?: boolean;
	messageId?: string;
	/** True if the result was server-truncated. */
	isTruncated?: boolean;
	/** Original content length before truncation. */
	fullContentLength?: number;
}
```

**Step 2: Update `handleToolResult` in chat store**

In `src/lib/public/stores/chat.svelte.ts`, find `handleToolResult` (~line 168-186). Update the message update to propagate truncation fields:

```typescript
messages[idx] = {
	...(messages[idx] as ToolMessage),
	status: is_error ? "error" : "completed",
	result: content ?? undefined,
	isError: is_error ?? false,
	isTruncated: msg.isTruncated ?? false,
	fullContentLength: msg.fullContentLength,
};
```

**Step 3: Add `tool_content` handler in ws dispatch**

In `src/lib/public/stores/ws.svelte.ts`, in the `handleMessage` switch, add a case for `tool_content`:

```typescript
case "tool_content": {
	// Update the ToolMessage with the full content
	const messages = [...chatState.messages];
	const idx = messages.findIndex(
		(m) => m.type === "tool" && m.id === msg.toolId,
	);
	if (idx >= 0) {
		messages[idx] = {
			...(messages[idx] as ToolMessage),
			result: msg.content,
			isTruncated: false,
			fullContentLength: undefined,
		};
		chatState.messages = messages;
	}
	break;
}
```

**Step 4: Update ToolItem.svelte**

In `src/lib/public/components/chat/ToolItem.svelte`, add a "Show full output" button when the result is truncated. After the result display section (~line 131-138):

```svelte
{#if message.isTruncated}
	<div class="mt-1 flex items-center gap-2 text-xs text-zinc-400">
		<span>
			Showing first {Math.round((message.result?.length ?? 0) / 1000)}KB
			of {Math.round((message.fullContentLength ?? 0) / 1000)}KB
		</span>
		<button
			class="text-blue-400 hover:text-blue-300 underline"
			onclick={() => {
				wsSend({ type: "get_tool_content", toolId: message.id });
			}}
		>
			Show full output
		</button>
	</div>
{/if}
```

Add the `wsSend` import at the top of the script:

```typescript
import { wsSend } from "../../stores/ws.svelte.js";
```

**Step 5: Run type check + tests**

Run: `pnpm check && pnpm test:unit`
Expected: PASS. If Svelte store tests reference `ToolMessage`, update them to include optional new fields.

**Step 6: Commit**

```bash
git add src/lib/public/
git commit -m "feat: frontend truncation indicator with 'Show full output' button (ticket 6.2 AC5)"
```

---

## AC7 — Rate Limiting on Message Sends

### Task 7: Create RateLimiter utility + tests

**Files:**
- Create: `src/lib/rate-limiter.ts`
- Create: `test/unit/rate-limiter.test.ts`

**Step 1: Write the failing tests**

```typescript
// test/unit/rate-limiter.test.ts
import { describe, expect, it } from "vitest";
import { RateLimiter } from "../../src/lib/rate-limiter.js";

describe("RateLimiter", () => {
	const fixedNow = 1_000_000;

	it("allows messages under the limit", () => {
		const limiter = new RateLimiter({
			maxMessages: 5,
			windowMs: 10_000,
			now: () => fixedNow,
		});
		for (let i = 0; i < 5; i++) {
			expect(limiter.check("client-1")).toEqual({ allowed: true });
		}
	});

	it("rejects messages over the limit", () => {
		const limiter = new RateLimiter({
			maxMessages: 5,
			windowMs: 10_000,
			now: () => fixedNow,
		});
		for (let i = 0; i < 5; i++) limiter.check("client-1");
		const result = limiter.check("client-1");
		expect(result.allowed).toBe(false);
		expect(result.retryAfterMs).toBeGreaterThan(0);
	});

	it("resets after the window expires", () => {
		let now = fixedNow;
		const limiter = new RateLimiter({
			maxMessages: 5,
			windowMs: 10_000,
			now: () => now,
		});
		for (let i = 0; i < 5; i++) limiter.check("client-1");

		// Advance past window
		now = fixedNow + 10_001;
		expect(limiter.check("client-1")).toEqual({ allowed: true });
	});

	it("tracks clients independently", () => {
		const limiter = new RateLimiter({
			maxMessages: 2,
			windowMs: 10_000,
			now: () => fixedNow,
		});
		limiter.check("a");
		limiter.check("a");
		expect(limiter.check("a").allowed).toBe(false);
		expect(limiter.check("b").allowed).toBe(true);
	});

	it("returns remaining count", () => {
		const limiter = new RateLimiter({
			maxMessages: 5,
			windowMs: 10_000,
			now: () => fixedNow,
		});
		limiter.check("c");
		limiter.check("c");
		expect(limiter.remaining("c")).toBe(3);
	});

	it("cleans up stale clients", () => {
		let now = fixedNow;
		const limiter = new RateLimiter({
			maxMessages: 5,
			windowMs: 10_000,
			now: () => now,
		});
		limiter.check("old-client");
		now = fixedNow + 20_000;
		limiter.cleanup();
		expect(limiter.remaining("old-client")).toBe(5); // fully reset
	});
});
```

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

Run: `pnpm vitest run test/unit/rate-limiter.test.ts`
Expected: FAIL — module not found

**Step 3: Implement RateLimiter**

```typescript
// src/lib/rate-limiter.ts

export interface RateLimiterOptions {
	/** Max messages per window. Default: 5. */
	maxMessages?: number;
	/** Window size in ms. Default: 10_000. */
	windowMs?: number;
	/** Injectable clock for testing. */
	now?: () => number;
}

export interface RateLimitResult {
	allowed: boolean;
	/** Milliseconds until the client can send again (set when rejected). */
	retryAfterMs?: number;
}

export class RateLimiter {
	private readonly maxMessages: number;
	private readonly windowMs: number;
	private readonly now: () => number;
	private readonly clients = new Map<string, number[]>();

	constructor(options?: RateLimiterOptions) {
		this.maxMessages = options?.maxMessages ?? 5;
		this.windowMs = options?.windowMs ?? 10_000;
		this.now = options?.now ?? (() => Date.now());
	}

	/** Check if a client can send. If allowed, records the timestamp. */
	check(clientId: string): RateLimitResult {
		const now = this.now();
		const cutoff = now - this.windowMs;
		let timestamps = this.clients.get(clientId);

		if (!timestamps) {
			timestamps = [];
			this.clients.set(clientId, timestamps);
		}

		// Prune timestamps outside the window
		const recent = timestamps.filter((t) => t > cutoff);
		this.clients.set(clientId, recent);

		if (recent.length >= this.maxMessages) {
			// Earliest timestamp in window — retry after it expires
			const earliest = recent[0]!;
			const retryAfterMs = earliest + this.windowMs - now;
			return { allowed: false, retryAfterMs: Math.max(1, retryAfterMs) };
		}

		recent.push(now);
		return { allowed: true };
	}

	/** Get remaining messages in current window. */
	remaining(clientId: string): number {
		const now = this.now();
		const cutoff = now - this.windowMs;
		const timestamps = this.clients.get(clientId);
		if (!timestamps) return this.maxMessages;
		const recent = timestamps.filter((t) => t > cutoff);
		return Math.max(0, this.maxMessages - recent.length);
	}

	/** Remove clients with no recent activity (call periodically). */
	cleanup(): void {
		const now = this.now();
		const cutoff = now - this.windowMs;
		for (const [id, timestamps] of this.clients) {
			const recent = timestamps.filter((t) => t > cutoff);
			if (recent.length === 0) {
				this.clients.delete(id);
			} else {
				this.clients.set(id, recent);
			}
		}
	}
}
```

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

Run: `pnpm vitest run test/unit/rate-limiter.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add src/lib/rate-limiter.ts test/unit/rate-limiter.test.ts
git commit -m "feat: add RateLimiter with sliding window + injectable clock (ticket 6.2 AC7)"
```

---

### Task 8: Wire server-side rate limiting

**Files:**
- Modify: `src/lib/ws-handler.ts` or `src/lib/relay-stack.ts` (where messages are routed)
- Modify: `src/lib/ws-router.ts` (add `RATE_LIMITED` to error codes, if using ErrorCode)

**Step 1: Write the test**

Add to `test/unit/ws-router.test.ts` (or the appropriate test file for the wiring layer):

```typescript
describe("server-side rate limiting (AC7)", () => {
	it("rejects message sends over the rate limit", () => {
		// Test that when a client sends >5 messages in 10s,
		// the 6th receives { type: "error", code: "RATE_LIMITED", message: "..." }
		// and the connection is NOT closed.
	});

	it("allows messages after the rate window expires", () => {
		// Test that after the window resets, messages are accepted again.
	});
});
```

**Step 2: Add rate limiting in the message processing pipeline**

In `src/lib/relay-stack.ts` (the `wsHandler.on("message", ...)` handler, ~line 364-376), add rate limit checking before dispatching:

```typescript
import { RateLimiter } from "./rate-limiter.js";

// In createProjectRelay():
const rateLimiter = new RateLimiter({ maxMessages: 5, windowMs: 10_000 });
```

In the message handler:

```typescript
wsHandler.on("message", (clientId, raw) => {
	const parsed = parseIncomingMessage(raw);
	if (!parsed) {
		wsHandler.sendTo(clientId, {
			type: "error",
			code: "INVALID_REQUEST",
			message: "Invalid message format",
		});
		return;
	}

	// Rate limit chat messages (not control messages like cancel, permission responses)
	if (parsed.type === "message") {
		const result = rateLimiter.check(clientId);
		if (!result.allowed) {
			wsHandler.sendTo(clientId, {
				type: "error",
				code: "RATE_LIMITED",
				message: `Rate limited. Try again in ${Math.ceil((result.retryAfterMs ?? 1000) / 1000)}s`,
			});
			return;
		}
	}

	// ... existing dispatch
});
```

**Step 3: Run tests**

Run: `pnpm check && pnpm test:unit`
Expected: PASS

**Step 4: Commit**

```bash
git add src/lib/relay-stack.ts src/lib/ws-router.ts test/unit/
git commit -m "feat: server-side rate limiting on chat messages — 5/10s per client (ticket 6.2 AC7)"
```

---

### Task 9: Client-side message queue + UI feedback

**Files:**
- Modify: `src/lib/public/stores/ws.svelte.ts` (add queue logic around `wsSend`)
- Modify: `src/lib/public/components/layout/InputArea.svelte` (show queue state)

**Step 1: Write the test**

Add to `test/unit/svelte-ws-store.test.ts` (or create if needed):

```typescript
describe("client-side message queue (AC7)", () => {
	it("queues messages over the rate limit", () => {
		// Simulate sending 6 messages rapidly
		// First 5 should be sent immediately
		// 6th should be queued
	});

	it("processes queued messages after the window resets", () => {
		// After enough time passes, queued messages are sent
	});

	it("preserves message order", () => {
		// Messages are sent in the order they were queued
	});
});
```

**Step 2: Add message queue to ws.svelte.ts**

Near the `wsSend` function (~line 107), add queue infrastructure:

```typescript
// ─── Rate Limiting (Ticket 6.2 AC7) ────────────────────────────────────────

const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_MS = 10_000;
const QUEUE_DRAIN_INTERVAL_MS = 1_000;

let sendTimestamps: number[] = [];
let messageQueue: Record<string, unknown>[] = [];
let drainTimer: ReturnType<typeof setInterval> | null = null;

/** Number of queued messages (reactive for UI). */
export function getQueuedCount(): number {
	return messageQueue.length;
}

function isUnderRateLimit(): boolean {
	const now = Date.now();
	sendTimestamps = sendTimestamps.filter((t) => t > now - RATE_LIMIT_WINDOW_MS);
	return sendTimestamps.length < RATE_LIMIT_MAX;
}

function rawSend(data: Record<string, unknown>): void {
	if (_ws && _ws.readyState === WebSocket.OPEN) {
		_ws.send(JSON.stringify(data));
		sendTimestamps.push(Date.now());
	}
}

function startDrainTimer(): void {
	if (drainTimer) return;
	drainTimer = setInterval(() => {
		while (messageQueue.length > 0 && isUnderRateLimit()) {
			const next = messageQueue.shift()!;
			rawSend(next);
		}
		if (messageQueue.length === 0 && drainTimer) {
			clearInterval(drainTimer);
			drainTimer = null;
		}
	}, QUEUE_DRAIN_INTERVAL_MS);
}

export function wsSend(data: Record<string, unknown>): void {
	// Only rate-limit chat messages — pass through control messages immediately
	if (data.type !== "message") {
		rawSend(data);
		return;
	}

	if (isUnderRateLimit()) {
		rawSend(data);
	} else {
		messageQueue.push(data);
		showToast("Message queued — sending shortly", { variant: "warn", duration: 2000 });
		startDrainTimer();
	}
}
```

Add import for `showToast`:

```typescript
import { showToast } from "./ui.svelte.js";
```

**Step 3: Run type check + tests**

Run: `pnpm check && pnpm test:unit`
Expected: PASS

**Step 4: Commit**

```bash
git add src/lib/public/stores/ws.svelte.ts src/lib/public/components/layout/InputArea.svelte test/unit/
git commit -m "feat: client-side message queue with rate limiting + toast feedback (ticket 6.2 AC7)"
```

---

## AC8 — Disk Space Monitoring

### Task 10: Create StorageMonitor + tests

**Files:**
- Create: `src/lib/storage-monitor.ts`
- Create: `test/unit/storage-monitor.test.ts`

**Step 1: Write the failing tests**

```typescript
// test/unit/storage-monitor.test.ts
import { describe, expect, it, vi } from "vitest";
import { StorageMonitor } from "../../src/lib/storage-monitor.js";

describe("StorageMonitor", () => {
	it("emits low_disk_space when free bytes < threshold", async () => {
		const handler = vi.fn();
		const monitor = new StorageMonitor({
			path: "/tmp",
			thresholdBytes: 100 * 1024 * 1024, // 100MB
			pollIntervalMs: 100,
			_statfs: async () => ({ bsize: 4096, bfree: 10_000 }), // ~40MB free
		});
		monitor.on("low_disk_space", handler);
		monitor.start();

		// Wait for one poll
		await new Promise((r) => setTimeout(r, 150));
		monitor.stop();

		expect(handler).toHaveBeenCalledWith(
			expect.objectContaining({
				freeBytes: 10_000 * 4096,
				thresholdBytes: 100 * 1024 * 1024,
			}),
		);
	});

	it("does NOT emit when free space is above threshold", async () => {
		const handler = vi.fn();
		const monitor = new StorageMonitor({
			path: "/tmp",
			thresholdBytes: 100 * 1024 * 1024,
			pollIntervalMs: 100,
			_statfs: async () => ({ bsize: 4096, bfree: 100_000_000 }), // ~381GB free
		});
		monitor.on("low_disk_space", handler);
		monitor.start();
		await new Promise((r) => setTimeout(r, 150));
		monitor.stop();

		expect(handler).not.toHaveBeenCalled();
	});

	it("emits disk_space_ok when recovering from low space", async () => {
		const handler = vi.fn();
		let free = 10_000; // starts low
		const monitor = new StorageMonitor({
			path: "/tmp",
			thresholdBytes: 100 * 1024 * 1024,
			pollIntervalMs: 100,
			_statfs: async () => ({ bsize: 4096, bfree: free }),
		});
		monitor.on("disk_space_ok", handler);
		monitor.start();

		await new Promise((r) => setTimeout(r, 150));
		// Recover
		free = 100_000_000;
		await new Promise((r) => setTimeout(r, 150));
		monitor.stop();

		expect(handler).toHaveBeenCalled();
	});

	it("stop clears the interval", () => {
		const monitor = new StorageMonitor({
			path: "/tmp",
			pollIntervalMs: 100,
			_statfs: async () => ({ bsize: 4096, bfree: 100_000_000 }),
		});
		monitor.start();
		monitor.stop();
		// Should not throw or leak
		monitor.stop(); // idempotent
	});
});
```

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

Run: `pnpm vitest run test/unit/storage-monitor.test.ts`
Expected: FAIL — module not found

**Step 3: Implement StorageMonitor**

```typescript
// src/lib/storage-monitor.ts
import { EventEmitter } from "node:events";
import { statfs as nodeStatfs } from "node:fs/promises";

export interface StorageMonitorOptions {
	/** Path to check disk space for. Typically the cache directory. */
	path: string;
	/** Free space threshold in bytes. Default: 100 * 1024 * 1024 (100MB). */
	thresholdBytes?: number;
	/** Poll interval in ms. Default: 300_000 (5 minutes). */
	pollIntervalMs?: number;
	/** Injectable statfs for testing. */
	_statfs?: (path: string) => Promise<{ bsize: number; bfree: number }>;
}

export interface DiskSpaceEvent {
	freeBytes: number;
	thresholdBytes: number;
	path: string;
}

export class StorageMonitor extends EventEmitter {
	private readonly path: string;
	private readonly thresholdBytes: number;
	private readonly pollIntervalMs: number;
	private readonly statfsFn: (
		path: string,
	) => Promise<{ bsize: number; bfree: number }>;
	private intervalHandle: ReturnType<typeof setInterval> | null = null;
	private wasLow = false;

	constructor(options: StorageMonitorOptions) {
		super();
		this.path = options.path;
		this.thresholdBytes = options.thresholdBytes ?? 100 * 1024 * 1024;
		this.pollIntervalMs = options.pollIntervalMs ?? 300_000;
		this.statfsFn =
			options._statfs ??
			(async (p: string) => {
				const stats = await nodeStatfs(p);
				return { bsize: stats.bsize, bfree: stats.bfree };
			});
	}

	start(): void {
		if (this.intervalHandle) return;
		this.runCheck();
		this.intervalHandle = setInterval(() => this.runCheck(), this.pollIntervalMs);
		if (
			this.intervalHandle &&
			typeof this.intervalHandle === "object" &&
			"unref" in this.intervalHandle
		) {
			this.intervalHandle.unref();
		}
	}

	stop(): void {
		if (this.intervalHandle !== null) {
			clearInterval(this.intervalHandle);
			this.intervalHandle = null;
		}
	}

	private async runCheck(): Promise<void> {
		try {
			const stats = await this.statfsFn(this.path);
			const freeBytes = stats.bsize * stats.bfree;
			const event: DiskSpaceEvent = {
				freeBytes,
				thresholdBytes: this.thresholdBytes,
				path: this.path,
			};

			if (freeBytes < this.thresholdBytes) {
				if (!this.wasLow) {
					this.wasLow = true;
					this.emit("low_disk_space", event);
				}
			} else if (this.wasLow) {
				this.wasLow = false;
				this.emit("disk_space_ok", event);
			}
		} catch (err) {
			this.emit("check_error", { error: err as Error });
		}
	}
}
```

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

Run: `pnpm vitest run test/unit/storage-monitor.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add src/lib/storage-monitor.ts test/unit/storage-monitor.test.ts
git commit -m "feat: add StorageMonitor with periodic disk space polling (ticket 6.2 AC8)"
```

---

### Task 11: Wire StorageMonitor into Daemon

**Files:**
- Modify: `src/lib/daemon.ts`

**Step 1: Write the test**

Add to `test/unit/daemon.test.ts`:

```typescript
describe("StorageMonitor wiring (AC8)", () => {
	it("creates StorageMonitor on start and stops on shutdown", async () => {
		// Create a daemon with test options
		// Verify StorageMonitor.start() is called during daemon.start()
		// Verify StorageMonitor.stop() is called during daemon.stop()
	});

	it("logs warning when low_disk_space is emitted", async () => {
		// Verify that the daemon's low_disk_space handler calls console.warn
	});
});
```

**Step 2: Add import and field**

In `src/lib/daemon.ts`, add:

```typescript
import { StorageMonitor } from "./storage-monitor.js";
```

Add field:

```typescript
private storageMonitor: StorageMonitor | null = null;
```

**Step 3: Start in `start()` method**

After the KeepAwake initialization (~line 267-269), add:

```typescript
// Start disk space monitor (Ticket 6.2 AC8)
this.storageMonitor = new StorageMonitor({
	path: this.configDir,
});
this.storageMonitor.on("low_disk_space", ({ freeBytes, thresholdBytes }) => {
	const freeMB = Math.round(freeBytes / (1024 * 1024));
	const threshMB = Math.round(thresholdBytes / (1024 * 1024));
	console.warn(
		`[daemon] Low disk space: ${freeMB}MB free (threshold: ${threshMB}MB). ` +
			"Session cache files may need cleanup.",
	);
});
this.storageMonitor.on("disk_space_ok", ({ freeBytes }) => {
	const freeMB = Math.round(freeBytes / (1024 * 1024));
	console.log(`[daemon] Disk space recovered: ${freeMB}MB free.`);
});
this.storageMonitor.start();
```

**Step 4: Stop in `stop()` method**

After the KeepAwake teardown (~line 285-286), add:

```typescript
this.storageMonitor?.stop();
this.storageMonitor = null;
```

**Step 5: Run tests**

Run: `pnpm vitest run test/unit/daemon.test.ts`
Expected: PASS

**Step 6: Commit**

```bash
git add src/lib/daemon.ts test/unit/daemon.test.ts
git commit -m "feat: wire StorageMonitor into daemon — warns at 100MB free (ticket 6.2 AC8)"
```

---

### Task 12: MessageCache size-aware eviction (optional enhancement)

**Files:**
- Modify: `src/lib/message-cache.ts`
- Modify: `test/unit/message-cache.test.ts`

This task extends MessageCache to track approximate byte usage and evict oldest sessions when disk space is low.

**Step 1: Write the test**

Add to `test/unit/message-cache.test.ts`:

```typescript
describe("size-aware eviction (AC8)", () => {
	it("reports approximate total byte size across sessions", () => {
		const cache = new MessageCache(testDir);
		cache.recordEvent("s1", { type: "status", status: "processing" });
		cache.recordEvent("s2", { type: "status", status: "idle" });
		expect(cache.approximateBytes()).toBeGreaterThan(0);
	});

	it("evictOldestSession removes the least recently accessed session", () => {
		const cache = new MessageCache(testDir);
		cache.recordEvent("old-session", { type: "status", status: "x" });
		cache.recordEvent("new-session", { type: "status", status: "y" });
		const evicted = cache.evictOldestSession();
		expect(evicted).toBe("old-session");
		expect(cache.has("old-session")).toBe(false);
		expect(cache.has("new-session")).toBe(true);
	});

	it("returns null when no sessions to evict", () => {
		const cache = new MessageCache(testDir);
		expect(cache.evictOldestSession()).toBeNull();
	});
});
```

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

Run: `pnpm vitest run test/unit/message-cache.test.ts`
Expected: FAIL — methods not found

**Step 3: Add approximate byte tracking**

In `src/lib/message-cache.ts`, update `SessionCache`:

```typescript
interface SessionCache {
	events: RelayMessage[];
	approxBytes: number;
	lastAccessedAt: number;
}
```

Update `ensureSession`:

```typescript
private ensureSession(sessionId: string): SessionCache {
	let session = this.sessions.get(sessionId);
	if (!session) {
		session = { events: [], approxBytes: 0, lastAccessedAt: Date.now() };
		this.sessions.set(sessionId, session);
	}
	session.lastAccessedAt = Date.now();
	return session;
}
```

Update `recordEvent` to track bytes:

```typescript
const serialized = JSON.stringify(event);
session.events.push(event);
session.approxBytes += serialized.length;
this.appendToFile(sessionId, serialized);
```

(Adjust `appendToFile` to accept the pre-serialized string to avoid double-serializing.)

Add public methods:

```typescript
/** Approximate total bytes across all sessions. */
approximateBytes(): number {
	let total = 0;
	for (const session of this.sessions.values()) {
		total += session.approxBytes;
	}
	return total;
}

/** Evict the oldest session (by last access time). Returns the evicted session ID, or null. */
evictOldestSession(): string | null {
	let oldestId: string | null = null;
	let oldestTime = Infinity;
	for (const [id, session] of this.sessions) {
		if (session.lastAccessedAt < oldestTime) {
			oldestTime = session.lastAccessedAt;
			oldestId = id;
		}
	}
	if (oldestId) {
		this.remove(oldestId);
	}
	return oldestId;
}
```

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

Run: `pnpm vitest run test/unit/message-cache.test.ts`
Expected: PASS

**Step 5: Wire eviction to StorageMonitor (in daemon)**

In `src/lib/daemon.ts`, update the `low_disk_space` handler to trigger cache eviction:

```typescript
this.storageMonitor.on("low_disk_space", ({ freeBytes, thresholdBytes }) => {
	const freeMB = Math.round(freeBytes / (1024 * 1024));
	const threshMB = Math.round(thresholdBytes / (1024 * 1024));
	console.warn(
		`[daemon] Low disk space: ${freeMB}MB free (threshold: ${threshMB}MB). Evicting old session caches.`,
	);
	// Evict oldest sessions from all project relays
	for (const relay of this.projectRelays.values()) {
		const evicted = relay.messageCache.evictOldestSession();
		if (evicted) {
			console.log(`[daemon] Evicted session cache: ${evicted}`);
		}
	}
});
```

**Step 6: Run full test suite**

Run: `pnpm check && pnpm test:unit`
Expected: PASS

**Step 7: Commit**

```bash
git add src/lib/message-cache.ts src/lib/daemon.ts test/unit/message-cache.test.ts
git commit -m "feat: size-aware MessageCache eviction triggered by low disk space (ticket 6.2 AC8)"
```

---

## Final Verification

### Task 13: Full verification pass

**Step 1: Run full suite**

```bash
pnpm check && pnpm lint && pnpm test:unit
```

**Step 2: Verify AC5 — Large tool results**

- Confirm `tool_result` in `shared-types.ts` has `isTruncated?` and `fullContentLength?` fields
- Confirm `tool_content` variant exists in `RelayMessage`
- Confirm `get_tool_content` is in `VALID_MESSAGE_TYPES` in ws-router.ts
- Confirm `ToolItem.svelte` has "Show full output" button

**Step 3: Verify AC7 — Rate limiting**

- Confirm `RateLimiter` is used in relay-stack.ts for chat messages
- Confirm `wsSend` in ws.svelte.ts has queue logic
- Confirm toast feedback appears when queued

**Step 4: Verify AC8 — Disk space monitoring**

- Confirm `StorageMonitor` is created and started in daemon.ts `start()`
- Confirm it's stopped in daemon.ts `stop()`
- Confirm low_disk_space triggers cache eviction

**Step 5: Commit any final fixups**

```bash
git add -A
git commit -m "test: final verification for ticket 6.2 AC5+AC7+AC8"
```
