import shelley

Philip Zeyliger created

git reset --hard $(git commit-tree $(git rev-parse exe/main:shelley) -p HEAD -m 'import sβ”‚
helley')

Change summary

.gitignore                                   |   59 
AGENT.md                                     |   28 
AGENT_TESTING.md                             |  245 +
ARCHITECTURE.md                              |   80 
Makefile                                     |  110 
README.md                                    |   70 
TODO                                         |   16 
claudetool/bash.go                           |  583 ++
claudetool/bash_test.go                      |  570 ++
claudetool/bashkit/bashkit.go                |  242 +
claudetool/bashkit/bashkit_test.go           |  484 ++
claudetool/bashkit/parsing.go                |   67 
claudetool/bashkit/parsing_test.go           |  146 
claudetool/browse/README.md                  |  113 
claudetool/browse/browse.go                  |  722 +++
claudetool/browse/browse_test.go             |  408 ++
claudetool/browse/browser_resize.go          |    2 
claudetool/browse/register.go                |   21 
claudetool/changedir.go                      |  102 
claudetool/changedir_test.go                 |  215 +
claudetool/editbuf/LICENSE                   |   27 
claudetool/editbuf/editbuf.go                |   92 
claudetool/keyword.go                        |  236 +
claudetool/keyword_system_prompt.txt         |   28 
claudetool/onstart/analyze.go                |  228 +
claudetool/onstart/analyze_test.go           |  238 +
claudetool/patch.go                          |  626 +++
claudetool/patch_test.go                     |  625 +++
claudetool/patchkit/patchkit.go              |  415 ++
claudetool/patchkit/patchkit_test.go         |  572 ++
claudetool/shared.go                         |   38 
claudetool/think.go                          |   39 
claudetool/toolset.go                        |  137 
cmd/go2ts.go                                 |   93 
cmd/shelley/main.go                          |  376 +
cmd/shelley/main_test.go                     |  234 +
cmd/shelley/prompt.go                        |   77 
cmd/shelley/prompt.txt                       |   12 
cmd/shelley/seccomp_test.go                  |  108 
db/README.md                                 |   36 
db/conversations_test.go                     |  409 ++
db/db.go                                     |  579 ++
db/db_test.go                                |  178 
db/generated/conversations.sql.go            |  398 ++
db/generated/db.go                           |   31 
db/generated/messages.sql.go                 |  334 +
db/generated/models.go                       |   37 
db/messages_test.go                          |  457 ++
db/pool.go                                   |  351 +
db/query/conversations.sql                   |   75 
db/query/messages.sql                        |   56 
db/schema/001-conversations.sql              |   22 
db/schema/002-messages.sql                   |   19 
db/schema/003-add-message-sequence.sql       |   42 
db/schema/004-add-error-message-type.sql     |   30 
db/schema/005-add-display-data.sql           |    4 
db/schema/006-add-cwd.sql                    |    4 
db/schema/007-add-archived.sql               |    5 
go.mod                                       |   97 
go.sum                                       |  272 +
llm/ant/ant.go                               |  623 +++
llm/ant/ant_image_test.go                    |   78 
llm/ant/content_fields_test.go               |   94 
llm/conversation/convo.go                    |  656 +++
llm/conversation/convo_test.go               |  299 +
llm/conversation/testdata/basic_convo.httprr |  118 
llm/gem/gem.go                               |  607 +++
llm/gem/gem_test.go                          |  366 +
llm/gem/gemini/gemini.go                     |  187 
llm/gem/gemini/gemini_test.go                |   33 
llm/image_content_test.go                    |   62 
llm/llm.go                                   |  367 +
llm/llm_string.go                            |   90 
llm/oai/oai.go                               |  918 ++++
llm/oai/oai_responses.go                     |  522 ++
llm/oai/oai_responses_test.go                |  415 ++
llm/oai/oai_test.go                          |  103 
llm/tool_content_test.go                     |   37 
llm/usage_test.go                            |  100 
loop/README.md                               |   71 
loop/claude_integration_test.go              |   69 
loop/example_test.go                         |   80 
loop/integration_test.go                     |  132 
loop/loop.go                                 |  509 ++
loop/loop_test.go                            |  843 ++++
loop/predictable.go                          |  555 ++
models/models.go                             |  477 ++
models/models_test.go                        |  172 
seccomp/arch_linux_amd64.go                  |   13 
seccomp/arch_linux_arm64.go                  |   13 
seccomp/seccomp_linux.go                     |  132 
seccomp/seccomp_linux_test.go                |  180 
seccomp/seccomp_other.go                     |    9 
server/cancel_claude_test.go                 |  997 +++++
server/cancel_test.go                        |  376 +
server/context_window_test.go                |  163 
server/conversation_by_slug_test.go          |   92 
server/conversation_flow_test.go             |  292 +
server/convo.go                              |  483 ++
server/cwd_test.go                           |  296 +
server/duplicate_tool_result_test.go         |  209 +
server/git_handlers.go                       |  329 +
server/handlers.go                           | 1130 +++++
server/llmconfig.go                          |   33 
server/message_bandwidth_test.go             |  292 +
server/middleware.go                         |   57 
server/middleware_test.go                    |  144 
server/orphan_tool_result_test.go            |  339 +
server/server.go                             |  799 ++++
server/sse_immediacy_test.go                 |  395 ++
server/system_prompt.go                      |  287 +
server/system_prompt.txt                     |   78 
server/testharness_test.go                   |  251 +
server/upload_test.go                        |  264 +
slug/slug.go                                 |  167 
slug/slug_test.go                            |  178 
sqlc.yaml                                    |   13 
subpub/subpub.go                             |  108 
subpub/subpub_test.go                        |  262 +
templates/go/AGENT.md                        |    5 
templates/go/Makefile                        |   10 
templates/go/README.md                       |   57 
templates/go/cmd/srv/main.go                 |   30 
templates/go/db/db.go                        |  115 
templates/go/db/dbgen/db.go                  |   31 
templates/go/db/dbgen/models.go              |   22 
templates/go/db/dbgen/visitors.sql.go        |   54 
templates/go/db/migrations/001-base.sql      |   22 
templates/go/db/queries/visitors.sql         |   17 
templates/go/db/sqlc.yaml                    |   14 
templates/go/go.mod                          |   60 
templates/go/go.sum                          |  209 +
templates/go/srv.service                     |   18 
templates/go/srv/server.go                   |  187 
templates/go/srv/server_test.go              |  117 
templates/go/srv/static/script.js            |   16 
templates/go/srv/static/style.css            |  501 ++
templates/go/srv/templates/welcome.html      |  149 
templates/templates.go                       |  111 
test/anthropic_test.go                       |  309 +
test/server_test.go                          | 1011 +++++
test_ci.sh                                   |   44 
test_manual.sh                               |   72 
ui/.nvmrc                                    |    1 
ui/.prettierrc                               |    5 
ui/e2e/README.md                             |  101 
ui/e2e/cancellation.spec.ts                  |  141 
ui/e2e/conversation.spec.ts                  |  464 ++
ui/e2e/file-upload.spec.ts                   |  198 +
ui/e2e/screenshots/.gitkeep                  |    0 
ui/e2e/scroll-behavior.spec.ts               |   63 
ui/e2e/smoke.spec.ts                         |   74 
ui/e2e/tool-components.spec.ts               |  178 
ui/embedfs.go                                |  102 
ui/eslint.config.js                          |   29 
ui/package-lock.json                         | 4293 ++++++++++++++++++++++
ui/package.json                              |   41 
ui/playwright.config.ts                      |   45 
ui/scripts/build-info.js                     |   23 
ui/scripts/build.js                          |   76 
ui/scripts/show-screenshots.sh               |   33 
ui/scripts/test-server.cjs                   |   91 
ui/scripts/verify-gitignore.sh               |   59 
ui/src/App.tsx                               |  257 +
ui/src/assets/apple-touch-icon.png           |    0 
ui/src/assets/icon-192.png                   |    0 
ui/src/assets/icon-512.png                   |    0 
ui/src/assets/manifest.json                  |   23 
ui/src/components/AGENT.md                   |   12 
ui/src/components/BashTool.tsx               |  109 
ui/src/components/BrowserConsoleLogsTool.tsx |   94 
ui/src/components/BrowserEvalTool.tsx        |  108 
ui/src/components/BrowserNavigateTool.tsx    |  107 
ui/src/components/BrowserResizeTool.tsx      |  105 
ui/src/components/ChangeDirTool.tsx          |  101 
ui/src/components/ChatInterface.tsx          | 1283 ++++++
ui/src/components/ContextMenu.tsx            |  105 
ui/src/components/ConversationDrawer.tsx     |  451 ++
ui/src/components/DiffViewer.tsx             |  757 +++
ui/src/components/DirectoryPickerModal.tsx   |  336 +
ui/src/components/GenericTool.tsx            |  115 
ui/src/components/KeywordSearchTool.tsx      |  124 
ui/src/components/Message.tsx                |  855 ++++
ui/src/components/MessageInput.tsx           |  428 ++
ui/src/components/Modal.tsx                  |   44 
ui/src/components/PatchTool.tsx              |  165 
ui/src/components/ReadImageTool.tsx          |  158 
ui/src/components/ScreenshotTool.tsx         |  175 
ui/src/components/ThinkTool.tsx              |   94 
ui/src/components/UsageDetailModal.tsx       |  166 
ui/src/generated-types.ts                    |   46 
ui/src/index.html                            |   22 
ui/src/main.tsx                              |   10 
ui/src/services/api.ts                       |  192 
ui/src/styles.css                            | 3037 +++++++++++++++
ui/src/types.ts                              |  125 
ui/tsconfig.json                             |   20 
ui/tsconfig.node.json                        |   10 
version/version.go                           |   35 
199 files changed, 47,751 insertions(+)

Detailed changes

.gitignore πŸ”—

@@ -0,0 +1,59 @@
+# Database files
+shelley.db
+shelley.db*
+test-playwright.db
+test-playwright.db*
+test-e2e.db
+test-e2e.db*
+test*.db
+*.db-shm
+*.db-wal
+
+# Build outputs
+/shelley
+bin/
+templates/*.tar.gz
+
+# UI build outputs
+ui/dist/
+ui/node_modules/
+ui/.next/
+ui/out/
+
+# Playwright test outputs
+ui/test-results/
+ui/playwright-report/
+
+# Screenshots (keep only example ones)
+ui/e2e/screenshots/*
+!ui/e2e/screenshots/.gitkeep
+
+# Logs
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Coverage directory used by tools like istanbul
+coverage/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db

AGENT.md πŸ”—

@@ -0,0 +1,28 @@
+1. Never add sleeps to tests.
+2. Brevity, brevity, brevity! Do not do weird defaults; have only one way of doing things; refactor relentlessly as necessary.
+3. If something doesn't work, propagate the error or exit or crash. Do not have "fallbacks".
+4. Do not keep old methods around for "compatibility"; this is a new project and there
+   are no compatibility concerns yet.
+5. The "predictable" model is a test fixture that lets you specify what a model would say if you said
+   a thing. This is useful for interactive testing with a browser, since you don't rely on a model,
+   and can fabricate some inputs and outputs. To test things, launch shelley with the relevant flag
+   to only expose this model, and use shelley with a browser.
+6. Build the UI (`make ui` or `cd ui && npm install && npm run build`) before running Go tests so `ui/dist` exists for the embed.
+7. Run Go unit tests with `go test ./server` (or narrower packages while iterating) once the UI bundle is built.
+8. To programmatically type into the React message input (e.g., in browser automation), you must use React's internal setter:
+   ```javascript
+   const input = document.querySelector('[data-testid="message-input"]');
+   const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value").set;
+   nativeInputValueSetter.call(input, 'your message');
+   input.dispatchEvent(new Event('input', { bubbles: true }));
+   ```
+   Simply setting `input.value = '...'` won't work because React won't detect the change.
+9. Commit your changes before finishing your turn.
+10. If you are testing Shelley itself, be aware that you might be running "under" shelley,
+  and indiscrimenantly running pkill -f shelley may break things.
+11. To test the Shelley UI in a separate instance, build with `make build`, then run on a
+    different port with a separate database:
+    ```
+    ./bin/shelley -config /exe.dev/shelley.json -db /tmp/shelley-test.db serve -port 8002
+    ```
+    Then use browser tools to navigate to http://localhost:8002/ and interact with the UI.

AGENT_TESTING.md πŸ”—

@@ -0,0 +1,245 @@
+# Shelley Agent Testing Guide
+
+This document provides instructions for automated testing of the Shelley coding agent product.
+
+## Prerequisites
+
+- `ANTHROPIC_API_KEY` environment variable set
+- Node.js and npm installed
+- Go installed
+- `headless` browser tool available (check with `which headless`)
+
+## Setup Instructions
+
+### 1. Build Shelley
+
+```bash
+cd /path/to/shelley
+make build
+```
+
+This will:
+- Build the UI (`npm install && npm run build`)
+- Create template tarballs
+- Build the Go binary to `bin/shelley`
+
+### 2. Install Playwright for E2E Tests
+
+```bash
+cd ui
+npm install
+npx playwright install chromium
+```
+
+### 3. Start Shelley Server
+
+For testing with Claude:
+```bash
+./bin/shelley --model claude-sonnet-4.5 --db test.db serve --port 9001
+```
+
+For testing with predictable model (no API key needed):
+```bash
+./bin/shelley --model predictable --db test.db serve --port 9001
+```
+
+### 4. Start Headless Browser (if using headless tool)
+
+```bash
+headless start
+```
+
+## Test Categories
+
+### CLI Tests
+
+Test these commands manually:
+
+```bash
+# List available models
+./bin/shelley models
+```
+
+### E2E Tests (Automated)
+
+Run the full E2E test suite:
+
+```bash
+cd ui
+npm run test:e2e
+```
+
+Run specific test files:
+```bash
+npm run test:e2e -- --grep "smoke"
+npm run test:e2e -- --grep "conversation"
+npm run test:e2e -- --grep "cancellation"
+```
+
+### Headless Browser Testing
+
+```bash
+# Navigate to Shelley
+headless navigate http://localhost:9001
+
+# Check page title
+headless eval 'document.title'
+
+# Get page content
+headless eval 'document.body.innerText.slice(0, 2000)'
+
+# Take screenshot
+headless screenshot screenshot.png
+
+# Set input value (React-compatible method)
+headless eval '(() => {
+  const input = document.querySelector("[data-testid=\"message-input\"]");
+  const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value").set;
+  setter.call(input, "Your message here");
+  input.dispatchEvent(new Event("input", { bubbles: true }));
+  return "done";
+})()'
+
+# Click send button
+headless eval 'document.querySelector("[data-testid=\"send-button\"]").click()'
+
+# Check if agent is thinking
+headless eval 'document.querySelector("[data-testid=\"agent-thinking\"]")?.innerText || "not thinking"'
+
+# Check for errors
+headless eval 'document.querySelector("[role=\"alert\"]")?.innerText || "no errors"'
+```
+
+## Test Checklist
+
+### Things That Work Well (Regression Tests)
+
+- [ ] **Page loads correctly** - Title is "Shelley", message input visible
+- [ ] **Send button state** - Disabled when empty, enabled when text entered
+- [ ] **Claude integration** - Messages send and receive responses (~2-3 seconds)
+- [ ] **Prompt caching** - Check server logs for `cache_read_input_tokens`
+- [ ] **Tool execution - bash** - Ask to run `echo hello`, verify tool output
+- [ ] **Tool execution - think** - Send `think: analyzing...`, verify think tool appears
+- [ ] **Tool execution - patch** - Send `patch: test.txt`, verify patch tool appears
+- [ ] **Conversation persistence** - Multiple messages in same conversation work
+- [ ] **Enter key sends** - Press Enter in textarea to send message
+- [ ] **Model selector** - Shows available models in UI
+- [ ] **Working directory** - Shows current directory path
+- [ ] **Accessibility labels** - Input has `aria-label="Message input"`, button has `aria-label="Send message"`
+
+### Known Issues (Need Fixing/Re-checking)
+
+- [ ] **Empty message bug (CRITICAL)** - Rapid sequential messages cause 400 errors
+  - Test: Send 5+ messages quickly in succession
+  - Expected: All should succeed
+  - Actual: API returns `messages.N: all messages must have non-empty content`
+
+- [ ] **Cancellation state after reload** - Cancelled operations don't show "cancelled" text
+  - Test: Start `bash: sleep 100`, cancel it, reload page
+  - Expected: Should show "cancelled" or "[Operation cancelled]"
+  - Actual: Shows tool with `x` but no cancelled text
+
+- [ ] **Thinking indicator stuck on error** - Indicator doesn't hide when LLM fails
+  - Test: Trigger an LLM error (e.g., via rapid messages)
+  - Expected: Indicator should hide, error should display
+  - Actual: "Agent working..." stays visible indefinitely
+
+- [ ] **Menu button outside viewport** - Hamburger menu not clickable on mobile
+  - Test: On mobile viewport, try clicking menu button
+  - Expected: Menu should open
+  - Actual: Button reported as "outside of the viewport"
+
+- [ ] **Programmatic input filling** - Direct `.value` assignment doesn't enable send button
+  - Test: Use browser automation to set input value
+  - Expected: Send button should enable
+  - Actual: Button stays disabled (need to use native setter method)
+
+## Screenshots to Capture
+
+When testing, capture these screenshots for the report:
+
+1. `initial-load.png` - Fresh page load
+2. `message-typed.png` - Message in input field
+3. `agent-thinking.png` - Thinking indicator visible
+4. `response-received.png` - After Claude responds
+5. `tool-execution.png` - After a tool (bash/think/patch) runs
+6. `error-state.png` - If any errors occur
+7. `menu-open.png` - Sidebar/conversation list open
+
+## Report Template
+
+Create `test-report/SHELLEY_TEST_REPORT.md` with:
+
+1. **Executive Summary** - Overall pass/fail, key issues
+2. **Test Environment** - Platform, models tested, browser
+3. **Test Results Summary** - Table of categories and pass/fail counts
+4. **Issues Found** - Detailed description of each issue with:
+   - File/location
+   - Description
+   - Expected vs Actual
+   - Screenshot
+   - Impact
+5. **What's Working Well** - Positive findings
+6. **Recommendations** - Prioritized fixes (Critical/High/Medium/Low)
+7. **Screenshots Index** - List of captured screenshots
+
+## Common Issues & Solutions
+
+### Build fails with "no matching files found"
+```bash
+# Templates need to be built first
+make templates
+# Then build
+make build
+```
+
+### Playwright not finding chromium
+```bash
+cd ui
+npx playwright install chromium
+```
+
+### Server already running
+```bash
+# Find and kill existing process
+lsof -i :9001 | grep LISTEN | awk '{print $2}' | xargs kill
+```
+
+### Headless browser already running
+```bash
+headless stop
+headless start
+```
+
+## API Endpoints for Manual Testing
+
+```bash
+# List conversations
+curl http://localhost:9001/api/conversations
+
+# Get specific conversation
+curl http://localhost:9001/api/conversation/<id>
+
+# Create new conversation (POST)
+curl -X POST http://localhost:9001/api/conversations/new \
+  -H "Content-Type: application/json" \
+  -d '{"model":"claude-sonnet-4.5","cwd":"/path/to/dir"}'
+
+# Send message (POST)
+curl -X POST http://localhost:9001/api/conversation/<id>/chat \
+  -H "Content-Type: application/json" \
+  -d '{"content":"Hello!"}'
+
+# Stream conversation (SSE)
+curl http://localhost:9001/api/conversation/<id>/stream
+```
+
+## Server Logs to Watch
+
+When testing, monitor server output for:
+
+- `LLM request completed` - Shows model, duration, token usage, cost
+- `cache_creation_input_tokens` / `cache_read_input_tokens` - Prompt caching
+- `Generated slug for conversation` - Conversation naming
+- `400 Bad Request` or other errors - API failures
+- `Agent message` with `end_of_turn=true` - Conversation turns completing

ARCHITECTURE.md πŸ”—

@@ -0,0 +1,80 @@
+Shelley is an agentic loop with tool use. See
+https://sketch.dev/blog/agent-loop for an example of the idea.
+
+When Shelley is started with "go run ./cmd/shelley" it starts a web server and
+opens a sqlite database, and users interact with the ui built in ui/. (The
+server itself is implemented in server/; cmd/shelley is a very thing shim.)
+
+## Components
+
+### ui/
+
+TODO: A mobile-first UI.
+Infrastructure:
+  * pnpm
+  * Typescript
+  * esbuild
+  * ESLint and eslint-typescript
+  * VueJS
+  * Jest
+
+### db/
+
+conversation(conversation_id, slug, user_initiated):
+  
+  Represents a single conversation.
+
+message(conversation_id, message_id, type (agent/user/tool), llm_data (json), user_data (json), usage (json))
+
+  Messages are visible in the UI and sent to the LLM as part of the 
+  conversation. There may be both user-visible and llm-visible representations
+  of messages.
+
+The database is sqlite. We use sqlc to define queries and schema.
+
+TODOX: Subagent/tool conversations are done with user_initiated=false.
+
+### server/
+
+The server serves the agent HTTP API and maintains active
+conversations. The HTTP API is:
+
+/conversations?limit=5000&offset=0
+/conversations?q=search_term
+
+  Returns conversations, either matching a query, or matching
+  the paging requirements.
+
+/conversation/<id>
+
+  Returns all the messages within a conversation.
+
+/conversation/<id>/stream
+
+  Returns all the messages within a conversation and
+  uses SSE to wait for updates.
+
+/conversation/<id>/chat (POST)
+
+  Injects a user message into the conversation
+
+
+When a conversation is active (because it's had a message sent to it, or there
+are stream subscribers), a Conversation struct is instantiated from the data,
+and the server keeps a map of these. Each of these has a Loop struct to keep
+track of the interaction with the llm.
+
+## loop/
+
+The core agentic loop.
+
+## claudetool/
+
+Various tools for the LLM.
+
+
+## Other
+
+Shelley talks to the LLMs using the llm/ library.
+
+Logging happens with slog and the tint library.

Makefile πŸ”—

@@ -0,0 +1,110 @@
+# Shelley Makefile
+
+.PHONY: build build-linux-aarch64 build-linux-x86 test test-go test-e2e ui serve clean help templates
+
+# Default target
+all: build
+
+# Build templates into tarballs
+templates:
+	@echo "Building template tarballs..."
+	@for dir in templates/*/; do \
+		name=$$(basename "$$dir"); \
+		echo "  Creating $$name.tar.gz..."; \
+		tar -czf "templates/$$name.tar.gz" -C "templates/$$name" --exclude='.DS_Store' .; \
+	done
+
+# Build the UI and Go binary
+build: ui templates
+	@echo "Building Shelley..."
+	go build -o bin/shelley ./cmd/shelley
+
+# Build for Linux (auto-detect architecture)
+build-linux: ui templates
+	@echo "Building Shelley for Linux..."
+	@ARCH=$$(uname -m); \
+	case $$ARCH in \
+		x86_64) GOARCH=amd64 ;; \
+		aarch64|arm64) GOARCH=arm64 ;; \
+		*) echo "Unsupported architecture: $$ARCH" && exit 1 ;; \
+	esac; \
+	GOOS=linux GOARCH=$$GOARCH go build -o bin/shelley-linux ./cmd/shelley
+
+# Build for Linux ARM64
+build-linux-aarch64: ui templates
+	@echo "Building Shelley for Linux ARM64..."
+	GOOS=linux GOARCH=arm64 go build -o bin/shelley-linux-aarch64 ./cmd/shelley
+
+# Build for Linux x86_64
+build-linux-x86: ui templates
+	@echo "Building Shelley for Linux x86_64..."
+	GOOS=linux GOARCH=amd64 go build -o bin/shelley-linux-x86 ./cmd/shelley
+
+# Build UI
+ui:
+	@echo "Building UI..."
+	cd ui && npm ci && npm run build
+
+# Run Go tests
+test-go: ui
+	@echo "Running Go tests..."
+	go test -v ./...
+
+# Run end-to-end tests
+test-e2e: ui
+	@echo "Running E2E tests..."
+	cd ui && npm run test:e2e
+
+# Run E2E tests in headed mode (with visible browser)
+test-e2e-headed: ui
+	@echo "Running E2E tests (headed)..."
+	cd ui && npm run test:e2e:headed
+
+# Run E2E tests in UI mode
+test-e2e-ui: ui
+	@echo "Opening E2E test UI..."
+	cd ui && npm run test:e2e:ui
+
+# Run all tests
+test: test-go test-e2e
+
+# Serve Shelley with predictable model for testing
+serve-test: ui
+	@echo "Starting Shelley with predictable model..."
+	go run ./cmd/shelley --model predictable --db test.db serve
+
+# Serve Shelley normally
+serve: ui
+	@echo "Starting Shelley..."
+	go run ./cmd/shelley serve
+
+# Clean build artifacts
+clean:
+	@echo "Cleaning..."
+	rm -rf bin/
+	rm -rf ui/dist/
+	rm -rf ui/node_modules/
+	rm -rf ui/test-results/
+	rm -rf ui/playwright-report/
+	rm -f *.db
+	rm -f templates/*.tar.gz
+
+# Show help
+help:
+	@echo "Shelley Build Commands:"
+	@echo ""
+	@echo "  build         Build UI, templates, and Go binary"
+	@echo "  build-linux-aarch64  Build for Linux ARM64"
+	@echo "  build-linux-x86      Build for Linux x86_64"
+	@echo "  ui            Build UI only"
+	@echo "  templates     Build template tarballs"
+	@echo "  test          Run all tests (Go + E2E)"
+	@echo "  test-go       Run Go tests only"
+	@echo "  test-e2e      Run E2E tests (headless)"
+	@echo "  test-e2e-headed  Run E2E tests (visible browser)"
+	@echo "  test-e2e-ui   Open E2E test UI"
+	@echo "  serve         Start Shelley server"
+	@echo "  serve-test    Start Shelley with predictable model"
+	@echo "  clean         Clean build artifacts"
+	@echo "  help          Show this help"
+

README.md πŸ”—

@@ -0,0 +1,70 @@
+# Shelley - A Coding Agent for exe.dev
+
+Shelley is a conversational AI coding agent that provides a web interface
+for AI-powered coding assistance.
+
+See also ARCHITECTURE.md for architectural details.
+
+## Dev Tricks
+
+If you want to see how mobile looks, and you're on your home
+network where you've got mDNS working fine, you can
+run 
+  socat TCP-LISTEN:9001,fork TCP:localhost:9000
+and then have your phone go to http://hostname.local:9001/
+
+## CLI Usage
+
+Shelley can be used as a command-line tool with the following commands:
+
+### Global Flags
+
+- `--db <path>`: Path to SQLite database file (default: "shelley.db")
+- `--model <model>`: LLM model to use (use `predictable` for testing). Run `shelley models` to see available models.
+- `--debug`: Enable debug logging
+
+### Commands
+
+#### `serve` - Start Web Server
+
+Starts the web server for the browser-based interface.
+
+```bash
+shelley serve --port 9000
+```
+
+Flags:
+- `--port <port>`: Port to listen on (default: 9000)
+
+#### `models` - List Supported Models
+
+Lists all supported models and their required environment variables.
+
+```bash
+shelley models
+```
+
+### Examples
+
+```bash
+# Start the web server
+shelley serve --port 8080
+
+# List supported models
+shelley models
+
+## Models and API Keys
+
+Use `shelley models` to see supported models, whether they are ready, and the environment variables required for each.
+
+Common env vars:
+
+- `ANTHROPIC_API_KEY`: Required for Claude models.
+- `OPENAI_API_KEY`: Required for OpenAI models.
+- `FIREWORKS_API_KEY`: Required for Fireworks models.
+
+Notes:
+
+- Run `shelley models` to see which model is the default and which are available.
+- `predictable` is a built-in test model and requires no API keys.
+```

TODO πŸ”—

@@ -0,0 +1,16 @@
+UI for tools is all shitty; need to collapse tool request and tool call (or maybe
+hide everything)
+
+need to hook up end to end?
+
+main_test is wild; just execute the underlying code dude
+? slugs don't show up when they are generated still
+
+things go too wide out of their box all the time
+
+how does the system prompt work? Is it in the DB?
+
+
+system promopt should include agent.md
+
+UI should be embedded

claudetool/bash.go πŸ”—

@@ -0,0 +1,583 @@
+package claudetool
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log/slog"
+	"math"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"slices"
+	"strings"
+	"sync"
+	"syscall"
+	"time"
+
+	"shelley.exe.dev/claudetool/bashkit"
+	"shelley.exe.dev/llm"
+)
+
+// PermissionCallback is a function type for checking if a command is allowed to run
+type PermissionCallback func(command string) error
+
+// BashTool specifies an llm.Tool for executing shell commands.
+type BashTool struct {
+	// CheckPermission is called before running any command, if set
+	CheckPermission PermissionCallback
+	// EnableJITInstall enables just-in-time tool installation for missing commands
+	EnableJITInstall bool
+	// Timeouts holds the configurable timeout values (uses defaults if nil)
+	Timeouts *Timeouts
+	// WorkingDir is the shared mutable working directory.
+	WorkingDir *MutableWorkingDir
+	// LLMProvider provides access to LLM services for tool validation
+	LLMProvider LLMServiceProvider
+}
+
+const (
+	EnableBashToolJITInstall = true
+	NoBashToolJITInstall     = false
+
+	DefaultFastTimeout       = 30 * time.Second
+	DefaultSlowTimeout       = 15 * time.Minute
+	DefaultBackgroundTimeout = 24 * time.Hour
+)
+
+// Timeouts holds the configurable timeout values for bash commands.
+type Timeouts struct {
+	Fast       time.Duration // regular commands (e.g., ls, echo, simple scripts)
+	Slow       time.Duration // commands that may reasonably take longer (e.g., downloads, builds, tests)
+	Background time.Duration // background commands (e.g., servers, long-running processes)
+}
+
+// Fast returns t's fast timeout, or DefaultFastTimeout if t is nil.
+func (t *Timeouts) fast() time.Duration {
+	if t == nil {
+		return DefaultFastTimeout
+	}
+	return t.Fast
+}
+
+// Slow returns t's slow timeout, or DefaultSlowTimeout if t is nil.
+func (t *Timeouts) slow() time.Duration {
+	if t == nil {
+		return DefaultSlowTimeout
+	}
+	return t.Slow
+}
+
+// Background returns t's background timeout, or DefaultBackgroundTimeout if t is nil.
+func (t *Timeouts) background() time.Duration {
+	if t == nil {
+		return DefaultBackgroundTimeout
+	}
+	return t.Background
+}
+
+// Tool returns an llm.Tool based on b.
+func (b *BashTool) Tool() *llm.Tool {
+	return &llm.Tool{
+		Name:        bashName,
+		Description: fmt.Sprintf(strings.TrimSpace(bashDescription), b.getWorkingDir()),
+		InputSchema: llm.MustSchema(bashInputSchema),
+		Run:         b.Run,
+	}
+}
+
+// getWorkingDir returns the current working directory.
+func (b *BashTool) getWorkingDir() string {
+	return b.WorkingDir.Get()
+}
+
+const (
+	bashName        = "bash"
+	bashDescription = `
+Executes shell commands via bash -c, returning combined stdout/stderr.
+Bash state changes (working dir, variables, aliases) don't persist between calls.
+
+With background=true, returns immediately, with output redirected to a file.
+Use background for servers/demos that need to stay running.
+
+MUST set slow_ok=true for potentially slow commands: builds, downloads,
+installs, tests, or any other substantive operation.
+
+To change the working directory persistently, use the change_dir tool.
+
+<pwd>%s</pwd>
+`
+	// If you modify this, update the termui template for prettier rendering.
+	bashInputSchema = `
+{
+  "type": "object",
+  "required": ["command"],
+  "properties": {
+    "command": {
+      "type": "string",
+      "description": "Shell to execute"
+    },
+    "slow_ok": {
+      "type": "boolean",
+      "description": "Use extended timeout"
+    },
+    "background": {
+      "type": "boolean",
+      "description": "Execute in background"
+    }
+  }
+}
+`
+)
+
+type bashInput struct {
+	Command    string `json:"command"`
+	SlowOK     bool   `json:"slow_ok,omitempty"`
+	Background bool   `json:"background,omitempty"`
+}
+
+type BackgroundResult struct {
+	PID     int
+	OutFile string
+}
+
+func (r *BackgroundResult) XMLish() string {
+	return fmt.Sprintf("<pid>%d</pid>\n<output_file>%s</output_file>\n<reminder>To stop the process: `kill -9 -%d`</reminder>\n",
+		r.PID, r.OutFile, r.PID)
+}
+
+func (i *bashInput) timeout(t *Timeouts) time.Duration {
+	switch {
+	case i.Background:
+		return t.background()
+	case i.SlowOK:
+		return t.slow()
+	default:
+		return t.fast()
+	}
+}
+
+func (b *BashTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
+	var req bashInput
+	if err := json.Unmarshal(m, &req); err != nil {
+		return llm.ErrorfToolOut("failed to unmarshal bash command input: %w", err)
+	}
+
+	// Check that the working directory exists
+	wd := b.getWorkingDir()
+	if _, err := os.Stat(wd); err != nil {
+		if os.IsNotExist(err) {
+			return llm.ErrorfToolOut("working directory does not exist: %s (use change_dir to switch to a valid directory)", wd)
+		}
+		return llm.ErrorfToolOut("cannot access working directory %s: %w", wd, err)
+	}
+
+	// do a quick permissions check (NOT a security barrier)
+	err := bashkit.Check(req.Command)
+	if err != nil {
+		return llm.ErrorToolOut(err)
+	}
+
+	// Custom permission callback if set
+	if b.CheckPermission != nil {
+		if err := b.CheckPermission(req.Command); err != nil {
+			return llm.ErrorToolOut(err)
+		}
+	}
+
+	// Check for missing tools and try to install them if needed, best effort only
+	if b.EnableJITInstall {
+		err := b.checkAndInstallMissingTools(ctx, req.Command)
+		if err != nil {
+			slog.DebugContext(ctx, "failed to auto-install missing tools", "error", err)
+		}
+	}
+
+	timeout := req.timeout(b.Timeouts)
+
+	// If Background is set to true, use executeBackgroundBash
+	if req.Background {
+		result, err := b.executeBackgroundBash(ctx, req, timeout)
+		if err != nil {
+			return llm.ErrorToolOut(err)
+		}
+		return llm.ToolOut{LLMContent: llm.TextContent(result.XMLish())}
+	}
+
+	// For foreground commands, use executeBash
+	out, execErr := b.executeBash(ctx, req, timeout)
+	if execErr != nil {
+		return llm.ErrorToolOut(execErr)
+	}
+	return llm.ToolOut{LLMContent: llm.TextContent(out)}
+}
+
+const maxBashOutputLength = 131072
+
+func (b *BashTool) makeBashCommand(ctx context.Context, command string, out io.Writer) *exec.Cmd {
+	cmd := exec.CommandContext(ctx, "bash", "-c", command)
+	// Use shared WorkingDir if available, then context, then Pwd fallback
+	cmd.Dir = b.getWorkingDir()
+	cmd.Stdin = nil
+	cmd.Stdout = out
+	cmd.Stderr = out
+	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // set up for killing the process group
+	cmd.Cancel = func() error {
+		if cmd.Process == nil {
+			// Process hasn't started yet.
+			// Not sure whether this is possible in practice,
+			// but it is possible in theory, and it doesn't hurt to handle it gracefully.
+			return nil
+		}
+		return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // kill entire process group
+	}
+	cmd.WaitDelay = 15 * time.Second // prevent indefinite hangs when child processes keep pipes open
+	// Remove SKETCH_MODEL_URL, SKETCH_PUB_KEY, SKETCH_MODEL_API_KEY,
+	// and any other future SKETCH_ goodies from the environment.
+	// ...except for SKETCH_PROXY_ID, which is intentionally available.
+	env := slices.DeleteFunc(os.Environ(), func(s string) bool {
+		return strings.HasPrefix(s, "SKETCH_") && s != "SKETCH_PROXY_ID"
+	})
+	env = append(env, "SKETCH=1")          // signal that this has been run by Sketch, sometimes useful for scripts
+	env = append(env, "EDITOR=/bin/false") // interactive editors won't work
+	cmd.Env = env
+	return cmd
+}
+
+func cmdWait(cmd *exec.Cmd) error {
+	err := cmd.Wait()
+	// We used to kill the process group here, but it's not clear that
+	// this is correct in the case of self-daemonizing processes,
+	// and I encountered issues where daemons that I tried to run
+	// as background tasks would mysteriously exit.
+	return err
+}
+
+func (b *BashTool) executeBash(ctx context.Context, req bashInput, timeout time.Duration) (string, error) {
+	execCtx, cancel := context.WithTimeout(ctx, timeout)
+	defer cancel()
+
+	output := new(bytes.Buffer)
+	cmd := b.makeBashCommand(execCtx, req.Command, output)
+	// TODO: maybe detect simple interactive git rebase commands and auto-background them?
+	// Would need to hint to the agent what is happening.
+	// We might also be able to do this for other simple interactive commands that use EDITOR.
+	cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=echo "To do an interactive rebase, run it as a background task and check the output file." && exit 1`)
+	if err := cmd.Start(); err != nil {
+		return "", fmt.Errorf("command failed: %w", err)
+	}
+
+	err := cmdWait(cmd)
+
+	out := output.String()
+	out = formatForegroundBashOutput(out)
+
+	if execCtx.Err() == context.DeadlineExceeded {
+		return "", fmt.Errorf("[command timed out after %s, showing output until timeout]\n%s", timeout, out)
+	}
+	if err != nil {
+		return "", fmt.Errorf("[command failed: %w]\n%s", err, out)
+	}
+
+	return out, nil
+}
+
+// formatForegroundBashOutput formats the output of a foreground bash command for display to the agent.
+func formatForegroundBashOutput(out string) string {
+	if len(out) > maxBashOutputLength {
+		const snipSize = 4096
+		out = fmt.Sprintf("[output truncated in middle: got %v, max is %v]\n%s\n\n[snip]\n\n%s",
+			humanizeBytes(len(out)), humanizeBytes(maxBashOutputLength),
+			out[:snipSize], out[len(out)-snipSize:],
+		)
+	}
+	return out
+}
+
+func humanizeBytes(bytes int) string {
+	switch {
+	case bytes < 4*1024:
+		return fmt.Sprintf("%dB", bytes)
+	case bytes < 1024*1024:
+		kb := int(math.Round(float64(bytes) / 1024.0))
+		return fmt.Sprintf("%dkB", kb)
+	case bytes < 1024*1024*1024:
+		mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
+		return fmt.Sprintf("%dMB", mb)
+	}
+	return "more than 1GB"
+}
+
+// executeBackgroundBash executes a command in the background and returns the pid and output file locations
+func (b *BashTool) executeBackgroundBash(ctx context.Context, req bashInput, timeout time.Duration) (*BackgroundResult, error) {
+	// Create temp output files
+	tmpDir, err := os.MkdirTemp("", "sketch-bg-")
+	if err != nil {
+		return nil, fmt.Errorf("failed to create temp directory: %w", err)
+	}
+	// We can't really clean up tempDir, because we have no idea
+	// how far into the future the agent might want to read the output.
+
+	outFile := filepath.Join(tmpDir, "output")
+	out, err := os.Create(outFile)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create output file: %w", err)
+	}
+
+	execCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) // detach from tool use context
+	cmd := b.makeBashCommand(execCtx, req.Command, out)
+	cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=python3 -c "import os, sys, signal, threading; print(f\"Send USR1 to pid {os.getpid()} after editing {sys.argv[1]}\", flush=True); signal.signal(signal.SIGUSR1, lambda *_: sys.exit(0)); threading.Event().wait()"`)
+
+	if err := cmd.Start(); err != nil {
+		cancel()
+		out.Close()
+		os.RemoveAll(tmpDir) // clean up temp dir -- didn't start means we don't need the output
+		return nil, fmt.Errorf("failed to start background command: %w", err)
+	}
+
+	// Wait for completion in the background, then do cleanup.
+	go func() {
+		err := cmdWait(cmd)
+		// Leave a note to the agent so that it knows that the process has finished.
+		if err != nil {
+			fmt.Fprintf(out, "\n\n[background process failed: %v]\n", err)
+		} else {
+			fmt.Fprintf(out, "\n\n[background process completed]\n")
+		}
+		out.Close()
+		cancel()
+	}()
+
+	return &BackgroundResult{
+		PID:     cmd.Process.Pid,
+		OutFile: outFile,
+	}, nil
+}
+
+// checkAndInstallMissingTools analyzes a bash command and attempts to automatically install any missing tools.
+func (b *BashTool) checkAndInstallMissingTools(ctx context.Context, command string) error {
+	commands, err := bashkit.ExtractCommands(command)
+	if err != nil {
+		return err
+	}
+
+	autoInstallMu.Lock()
+	defer autoInstallMu.Unlock()
+
+	var missing []string
+	for _, cmd := range commands {
+		if doNotAttemptToolInstall[cmd] {
+			continue
+		}
+		_, err := exec.LookPath(cmd)
+		if err == nil {
+			doNotAttemptToolInstall[cmd] = true // spare future LookPath calls
+			continue
+		}
+		missing = append(missing, cmd)
+	}
+
+	if len(missing) == 0 {
+		return nil
+	}
+
+	for _, cmd := range missing {
+		err := b.installTool(ctx, cmd)
+		if err != nil {
+			slog.WarnContext(ctx, "failed to install tool", "tool", cmd, "error", err)
+		}
+		doNotAttemptToolInstall[cmd] = true // either it's installed or it's not--either way, we're done with it
+	}
+	return nil
+}
+
+// Command safety check cache to avoid repeated LLM calls
+var (
+	autoInstallMu           sync.Mutex
+	doNotAttemptToolInstall = make(map[string]bool) // set to true if the tool should not be auto-installed
+)
+
+// autodetectPackageManager returns the first package‑manager binary
+// found in PATH, or an empty string if none are present.
+func autodetectPackageManager() string {
+	// TODO: cache this result with a sync.OnceValue
+
+	managers := []string{
+		"apt", "apt-get", // Debian/Ubuntu
+		"brew", "port", // macOS (Homebrew / MacPorts)
+		"apk",        // Alpine
+		"yum", "dnf", // RHEL/Fedora
+		"pacman",          // Arch
+		"zypper",          // openSUSE
+		"xbps-install",    // Void
+		"emerge",          // Gentoo
+		"nix-env", "guix", // NixOS / Guix
+		"pkg",      // FreeBSD
+		"slackpkg", // Slackware
+	}
+
+	for _, m := range managers {
+		if _, err := exec.LookPath(m); err == nil {
+			return m
+		}
+	}
+	return ""
+}
+
+// installTool attempts to install a single missing tool using LLM validation and system package manager.
+func (b *BashTool) installTool(ctx context.Context, cmd string) error {
+	slog.InfoContext(ctx, "attempting to install tool", "tool", cmd)
+
+	packageManager := autodetectPackageManager()
+	if packageManager == "" {
+		return fmt.Errorf("no known package manager found in PATH")
+	}
+	// Use LLM to validate and get package name
+	if b.LLMProvider == nil {
+		return fmt.Errorf("no LLM provider available for tool validation")
+	}
+	llmService, err := b.selectBestLLM()
+	if err != nil {
+		return fmt.Errorf("failed to get LLM service for tool validation: %w", err)
+	}
+
+	query := fmt.Sprintf(`Do you know this command/package/tool? Is it legitimate, clearly non-harmful, and commonly used? Can it be installed with package manager %s?
+
+Command: %s
+
+- YES: Respond ONLY with the package name used to install it
+- NO or UNSURE: Respond ONLY with the word NO`, packageManager, cmd)
+
+	req := &llm.Request{
+		Messages: []llm.Message{{
+			Role:    llm.MessageRoleUser,
+			Content: []llm.Content{llm.StringContent(query)},
+		}},
+		System: []llm.SystemContent{{
+			Type: "text",
+			Text: "You are an expert in software developer tools.",
+		}},
+	}
+
+	resp, err := llmService.Do(ctx, req)
+	if err != nil {
+		return fmt.Errorf("failed to validate tool with LLM: %w", err)
+	}
+
+	if len(resp.Content) == 0 {
+		return fmt.Errorf("empty response from LLM for tool validation")
+	}
+
+	response := strings.TrimSpace(resp.Content[0].Text)
+	if response == "NO" || response == "UNSURE" {
+		slog.InfoContext(ctx, "tool installation declined by LLM", "tool", cmd, "response", response)
+		return fmt.Errorf("tool %s not approved for installation", cmd)
+	}
+
+	packageName := strings.TrimSpace(response)
+	if packageName == "" {
+		return fmt.Errorf("no package name provided for tool %s", cmd)
+	}
+
+	return b.installPackage(ctx, cmd, packageName, packageManager)
+}
+
+// installPackage handles the actual package installation
+func (b *BashTool) installPackage(ctx context.Context, cmd, packageName, packageManager string) error {
+	// Install the package (with update command first if needed)
+	// TODO: these invocations create zombies when we are PID 1.
+	// We should give them the same zombie-reaping treatment as above,
+	// if/when we care enough to put in the effort. Not today.
+	var updateCmd, installCmd string
+	switch packageManager {
+	case "apt", "apt-get":
+		updateCmd = fmt.Sprintf("sudo %s update", packageManager)
+		installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
+	case "brew":
+		// brew handles updates automatically, no explicit update needed
+		installCmd = fmt.Sprintf("brew install %s", packageName)
+	case "apk":
+		updateCmd = "sudo apk update"
+		installCmd = fmt.Sprintf("sudo apk add %s", packageName)
+	case "yum", "dnf":
+		// For yum/dnf, we don't need a separate update command as the package cache is usually fresh enough
+		// and install will fetch the latest available packages
+		installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
+	case "pacman":
+		updateCmd = "sudo pacman -Sy"
+		installCmd = fmt.Sprintf("sudo pacman -S --noconfirm %s", packageName)
+	case "zypper":
+		updateCmd = "sudo zypper refresh"
+		installCmd = fmt.Sprintf("sudo zypper install -y %s", packageName)
+	case "xbps-install":
+		updateCmd = "sudo xbps-install -S"
+		installCmd = fmt.Sprintf("sudo xbps-install -y %s", packageName)
+	case "emerge":
+		// Note: emerge --sync is expensive, so we skip it for JIT installs
+		// Users should manually sync if needed
+		installCmd = fmt.Sprintf("sudo emerge %s", packageName)
+	case "nix-env":
+		// nix-env doesn't require explicit updates for JIT installs
+		installCmd = fmt.Sprintf("nix-env -i %s", packageName)
+	case "guix":
+		// guix doesn't require explicit updates for JIT installs
+		installCmd = fmt.Sprintf("guix install %s", packageName)
+	case "pkg":
+		updateCmd = "sudo pkg update"
+		installCmd = fmt.Sprintf("sudo pkg install -y %s", packageName)
+	case "slackpkg":
+		updateCmd = "sudo slackpkg update"
+		installCmd = fmt.Sprintf("sudo slackpkg install %s", packageName)
+	default:
+		return fmt.Errorf("unsupported package manager: %s", packageManager)
+	}
+
+	slog.InfoContext(ctx, "installing tool", "tool", cmd, "package", packageName, "update_command", updateCmd, "install_command", installCmd)
+
+	// Execute the update command first if needed
+	if updateCmd != "" {
+		slog.InfoContext(ctx, "updating package cache", "command", updateCmd)
+		updateCmdExec := exec.CommandContext(ctx, "sh", "-c", updateCmd)
+		updateOutput, err := updateCmdExec.CombinedOutput()
+		if err != nil {
+			slog.WarnContext(ctx, "package cache update failed, proceeding with install anyway", "error", err, "output", string(updateOutput))
+		}
+	}
+
+	// Execute the install command
+	cmdExec := exec.CommandContext(ctx, "sh", "-c", installCmd)
+	output, err := cmdExec.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("failed to install %s: %w\nOutput: %s", packageName, err, string(output))
+	}
+
+	slog.InfoContext(ctx, "tool installation successful", "tool", cmd, "package", packageName)
+	return nil
+}
+
+// selectBestLLM selects the best available LLM service for bash tool validation
+func (b *BashTool) selectBestLLM() (llm.Service, error) {
+	if b.LLMProvider == nil {
+		return nil, fmt.Errorf("no LLM provider available")
+	}
+
+	// Preferred models in order of preference for tool validation (fast, cheap models preferred)
+	preferredModels := []string{"qwen3-coder-fireworks", "gpt-5-thinking-mini", "gpt5-mini", "claude-sonnet-4.5", "predictable"}
+
+	for _, model := range preferredModels {
+		svc, err := b.LLMProvider.GetService(model)
+		if err == nil {
+			return svc, nil
+		}
+	}
+
+	// If no preferred model is available, try any available model
+	available := b.LLMProvider.GetAvailableModels()
+	if len(available) > 0 {
+		return b.LLMProvider.GetService(available[0])
+	}
+
+	return nil, fmt.Errorf("no LLM services available")
+}

claudetool/bash_test.go πŸ”—

@@ -0,0 +1,570 @@
+package claudetool
+
+import (
+	"context"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
+	"syscall"
+	"testing"
+	"time"
+)
+
+func TestBashSlowOk(t *testing.T) {
+	// Test that slow_ok flag is properly handled
+	t.Run("SlowOk Flag", func(t *testing.T) {
+		input := json.RawMessage(`{"command":"echo 'slow test'","slow_ok":true}`)
+
+		bashTool := (&BashTool{WorkingDir: NewMutableWorkingDir("/")}).Tool()
+		toolOut := bashTool.Run(context.Background(), input)
+		if toolOut.Error != nil {
+			t.Fatalf("Unexpected error: %v", toolOut.Error)
+		}
+		result := toolOut.LLMContent
+
+		expected := "slow test\n"
+		if len(result) == 0 || result[0].Text != expected {
+			t.Errorf("Expected %q, got %q", expected, result[0].Text)
+		}
+	})
+
+	// Test that slow_ok with background works
+	t.Run("SlowOk with Background", func(t *testing.T) {
+		input := json.RawMessage(`{"command":"echo 'slow background test'","slow_ok":true,"background":true}`)
+
+		bashTool := (&BashTool{WorkingDir: NewMutableWorkingDir("/")}).Tool()
+		toolOut := bashTool.Run(context.Background(), input)
+		if toolOut.Error != nil {
+			t.Fatalf("Unexpected error: %v", toolOut.Error)
+		}
+		result := toolOut.LLMContent
+
+		// Should return background result XML-ish format
+		resultStr := result[0].Text
+		if !strings.Contains(resultStr, "<pid>") || !strings.Contains(resultStr, "<output_file>") {
+			t.Errorf("Expected XML-ish background result format, got: %s", resultStr)
+		}
+
+		// Extract PID and output file from XML-ish format for cleanup
+		// This is a simple extraction for test cleanup - in real usage the agent would parse this
+		lines := strings.Split(resultStr, "\n")
+		var outFile string
+		for _, line := range lines {
+			if strings.Contains(line, "<output_file>") {
+				start := strings.Index(line, "<output_file>") + len("<output_file>")
+				end := strings.Index(line, "</output_file>")
+				if end > start {
+					outFile = line[start:end]
+				}
+				break
+			}
+		}
+
+		if outFile != "" {
+			// Clean up
+			os.Remove(outFile)
+			os.Remove(filepath.Dir(outFile))
+		}
+	})
+}
+
+func TestBashTool(t *testing.T) {
+	bashTool := &BashTool{WorkingDir: NewMutableWorkingDir("/")}
+	tool := bashTool.Tool()
+
+	// Test basic functionality
+	t.Run("Basic Command", func(t *testing.T) {
+		input := json.RawMessage(`{"command":"echo 'Hello, world!'"}`)
+
+		toolOut := tool.Run(context.Background(), input)
+		if toolOut.Error != nil {
+			t.Fatalf("Unexpected error: %v", toolOut.Error)
+		}
+		result := toolOut.LLMContent
+
+		expected := "Hello, world!\n"
+		if len(result) == 0 || result[0].Text != expected {
+			t.Errorf("Expected %q, got %q", expected, result[0].Text)
+		}
+	})
+
+	// Test with arguments
+	t.Run("Command With Arguments", func(t *testing.T) {
+		input := json.RawMessage(`{"command":"echo -n foo && echo -n bar"}`)
+
+		toolOut := tool.Run(context.Background(), input)
+		if toolOut.Error != nil {
+			t.Fatalf("Unexpected error: %v", toolOut.Error)
+		}
+		result := toolOut.LLMContent
+
+		expected := "foobar"
+		if len(result) == 0 || result[0].Text != expected {
+			t.Errorf("Expected %q, got %q", expected, result[0].Text)
+		}
+	})
+
+	// Test with slow_ok parameter
+	t.Run("With SlowOK", func(t *testing.T) {
+		inputObj := struct {
+			Command string `json:"command"`
+			SlowOK  bool   `json:"slow_ok"`
+		}{
+			Command: "sleep 0.1 && echo 'Completed'",
+			SlowOK:  true,
+		}
+		inputJSON, err := json.Marshal(inputObj)
+		if err != nil {
+			t.Fatalf("Failed to marshal input: %v", err)
+		}
+
+		toolOut := tool.Run(context.Background(), inputJSON)
+		if toolOut.Error != nil {
+			t.Fatalf("Unexpected error: %v", toolOut.Error)
+		}
+		result := toolOut.LLMContent
+
+		expected := "Completed\n"
+		if len(result) == 0 || result[0].Text != expected {
+			t.Errorf("Expected %q, got %q", expected, result[0].Text)
+		}
+	})
+
+	// Test command timeout with custom timeout config
+	t.Run("Command Timeout", func(t *testing.T) {
+		// Use a custom BashTool with very short timeout
+		customTimeouts := &Timeouts{
+			Fast:       100 * time.Millisecond,
+			Slow:       100 * time.Millisecond,
+			Background: 100 * time.Millisecond,
+		}
+		customBash := &BashTool{
+			WorkingDir: NewMutableWorkingDir("/"),
+			Timeouts:   customTimeouts,
+		}
+		tool := customBash.Tool()
+
+		input := json.RawMessage(`{"command":"sleep 0.5 && echo 'Should not see this'"}`)
+
+		toolOut := tool.Run(context.Background(), input)
+		if toolOut.Error == nil {
+			t.Errorf("Expected timeout error, got none")
+		} else if !strings.Contains(toolOut.Error.Error(), "timed out") {
+			t.Errorf("Expected timeout error, got: %v", toolOut.Error)
+		}
+	})
+
+	// Test command that fails
+	t.Run("Failed Command", func(t *testing.T) {
+		input := json.RawMessage(`{"command":"exit 1"}`)
+
+		toolOut := tool.Run(context.Background(), input)
+		if toolOut.Error == nil {
+			t.Errorf("Expected error for failed command, got none")
+		}
+	})
+
+	// Test invalid input
+	t.Run("Invalid JSON Input", func(t *testing.T) {
+		input := json.RawMessage(`{"command":123}`) // Invalid JSON (command must be string)
+
+		toolOut := tool.Run(context.Background(), input)
+		if toolOut.Error == nil {
+			t.Errorf("Expected error for invalid input, got none")
+		}
+	})
+}
+
+func TestExecuteBash(t *testing.T) {
+	ctx := context.Background()
+	bashTool := &BashTool{WorkingDir: NewMutableWorkingDir("/")}
+
+	// Test successful command
+	t.Run("Successful Command", func(t *testing.T) {
+		req := bashInput{
+			Command: "echo 'Success'",
+		}
+
+		output, err := bashTool.executeBash(ctx, req, 5*time.Second)
+		if err != nil {
+			t.Fatalf("Unexpected error: %v", err)
+		}
+
+		want := "Success\n"
+		if output != want {
+			t.Errorf("Expected %q, got %q", want, output)
+		}
+	})
+
+	// Test SKETCH=1 environment variable is set
+	t.Run("SKETCH Environment Variable", func(t *testing.T) {
+		req := bashInput{
+			Command: "echo $SKETCH",
+		}
+
+		output, err := bashTool.executeBash(ctx, req, 5*time.Second)
+		if err != nil {
+			t.Fatalf("Unexpected error: %v", err)
+		}
+
+		want := "1\n"
+		if output != want {
+			t.Errorf("Expected SKETCH=1, got %q", output)
+		}
+	})
+
+	// Test command with output to stderr
+	t.Run("Command with stderr", func(t *testing.T) {
+		req := bashInput{
+			Command: "echo 'Error message' >&2 && echo 'Success'",
+		}
+
+		output, err := bashTool.executeBash(ctx, req, 5*time.Second)
+		if err != nil {
+			t.Fatalf("Unexpected error: %v", err)
+		}
+
+		want := "Error message\nSuccess\n"
+		if output != want {
+			t.Errorf("Expected %q, got %q", want, output)
+		}
+	})
+
+	// Test command that fails with stderr
+	t.Run("Failed Command with stderr", func(t *testing.T) {
+		req := bashInput{
+			Command: "echo 'Error message' >&2 && exit 1",
+		}
+
+		_, err := bashTool.executeBash(ctx, req, 5*time.Second)
+		if err == nil {
+			t.Errorf("Expected error for failed command, got none")
+		} else if !strings.Contains(err.Error(), "Error message") {
+			t.Errorf("Expected stderr in error message, got: %v", err)
+		}
+	})
+
+	// Test timeout
+	t.Run("Command Timeout", func(t *testing.T) {
+		req := bashInput{
+			Command: "sleep 1 && echo 'Should not see this'",
+		}
+
+		start := time.Now()
+		_, err := bashTool.executeBash(ctx, req, 100*time.Millisecond)
+		elapsed := time.Since(start)
+
+		// Command should time out after ~100ms, not wait for full 1 second
+		if elapsed >= 1*time.Second {
+			t.Errorf("Command did not respect timeout, took %v", elapsed)
+		}
+
+		if err == nil {
+			t.Errorf("Expected timeout error, got none")
+		} else if !strings.Contains(err.Error(), "timed out") {
+			t.Errorf("Expected timeout error, got: %v", err)
+		}
+	})
+}
+
+func TestBackgroundBash(t *testing.T) {
+	bashTool := &BashTool{WorkingDir: NewMutableWorkingDir("/")}
+	tool := bashTool.Tool()
+
+	// Test basic background execution
+	t.Run("Basic Background Command", func(t *testing.T) {
+		inputObj := struct {
+			Command    string `json:"command"`
+			Background bool   `json:"background"`
+		}{
+			Command:    "echo 'Hello from background' $SKETCH",
+			Background: true,
+		}
+		inputJSON, err := json.Marshal(inputObj)
+		if err != nil {
+			t.Fatalf("Failed to marshal input: %v", err)
+		}
+
+		toolOut := tool.Run(context.Background(), inputJSON)
+		if toolOut.Error != nil {
+			t.Fatalf("Unexpected error: %v", toolOut.Error)
+		}
+		result := toolOut.LLMContent
+
+		// Parse the returned XML-ish format
+		resultStr := result[0].Text
+		if !strings.Contains(resultStr, "<pid>") || !strings.Contains(resultStr, "<output_file>") {
+			t.Fatalf("Expected XML-ish background result format, got: %s", resultStr)
+		}
+
+		// Extract PID and output file from XML-ish format
+		lines := strings.Split(resultStr, "\n")
+		var pidStr, outFile string
+		for _, line := range lines {
+			if strings.Contains(line, "<pid>") {
+				start := strings.Index(line, "<pid>") + len("<pid>")
+				end := strings.Index(line, "</pid>")
+				if end > start {
+					pidStr = line[start:end]
+				}
+			} else if strings.Contains(line, "<output_file>") {
+				start := strings.Index(line, "<output_file>") + len("<output_file>")
+				end := strings.Index(line, "</output_file>")
+				if end > start {
+					outFile = line[start:end]
+				}
+			}
+		}
+
+		// Verify we got valid values
+		if pidStr == "" || outFile == "" {
+			t.Errorf("Failed to extract PID or output file from result: %s", resultStr)
+			return
+		}
+
+		// Verify output file exists
+		if _, err := os.Stat(outFile); os.IsNotExist(err) {
+			t.Errorf("Output file doesn't exist: %s", outFile)
+		}
+
+		// Wait for the command output to be written to file
+		waitForFile(t, outFile)
+
+		// Check file contents
+		outputContent, err := os.ReadFile(outFile)
+		if err != nil {
+			t.Fatalf("Failed to read output file: %v", err)
+		}
+		// The implementation appends a completion message to the output
+		outputStr := string(outputContent)
+		if !strings.Contains(outputStr, "Hello from background 1") {
+			t.Errorf("Expected output to contain 'Hello from background 1', got %q", outputStr)
+		}
+		if !strings.Contains(outputStr, "[background process completed]") {
+			t.Errorf("Expected output to contain completion message, got %q", outputStr)
+		}
+
+		// Clean up
+		os.Remove(outFile)
+		os.Remove(filepath.Dir(outFile))
+	})
+
+	// Test background command with stderr output
+	t.Run("Background Command with stderr", func(t *testing.T) {
+		inputObj := struct {
+			Command    string `json:"command"`
+			Background bool   `json:"background"`
+		}{
+			Command:    "echo 'Output to stdout' && echo 'Output to stderr' >&2",
+			Background: true,
+		}
+		inputJSON, err := json.Marshal(inputObj)
+		if err != nil {
+			t.Fatalf("Failed to marshal input: %v", err)
+		}
+
+		toolOut := tool.Run(context.Background(), inputJSON)
+		if toolOut.Error != nil {
+			t.Fatalf("Unexpected error: %v", toolOut.Error)
+		}
+		result := toolOut.LLMContent
+
+		// Parse the returned XML-ish format
+		resultStr := result[0].Text
+		lines := strings.Split(resultStr, "\n")
+		var outFile string
+		for _, line := range lines {
+			if strings.Contains(line, "<output_file>") {
+				start := strings.Index(line, "<output_file>") + len("<output_file>")
+				end := strings.Index(line, "</output_file>")
+				if end > start {
+					outFile = line[start:end]
+				}
+				break
+			}
+		}
+
+		// Wait for the command output to be written to file
+		waitForFile(t, outFile)
+
+		// Check output content (stdout and stderr are combined in implementation)
+		outputContent, err := os.ReadFile(outFile)
+		if err != nil {
+			t.Fatalf("Failed to read output file: %v", err)
+		}
+		// Implementation combines stdout and stderr into one file
+		outputStr := string(outputContent)
+		if !strings.Contains(outputStr, "Output to stdout") || !strings.Contains(outputStr, "Output to stderr") {
+			t.Errorf("Expected both stdout and stderr content, got %q", outputStr)
+		}
+
+		// Clean up
+		os.Remove(outFile)
+		os.Remove(filepath.Dir(outFile))
+	})
+
+	// Test background command running without waiting
+	t.Run("Background Command Running", func(t *testing.T) {
+		// Create a script that will continue running after we check
+		inputObj := struct {
+			Command    string `json:"command"`
+			Background bool   `json:"background"`
+		}{
+			Command:    "echo 'Running in background' && sleep 5",
+			Background: true,
+		}
+		inputJSON, err := json.Marshal(inputObj)
+		if err != nil {
+			t.Fatalf("Failed to marshal input: %v", err)
+		}
+
+		// Start the command in the background
+		toolOut := tool.Run(context.Background(), inputJSON)
+		if toolOut.Error != nil {
+			t.Fatalf("Unexpected error: %v", toolOut.Error)
+		}
+		result := toolOut.LLMContent
+
+		// Parse the returned XML-ish format
+		resultStr := result[0].Text
+		lines := strings.Split(resultStr, "\n")
+		var pidStr, outFile string
+		for _, line := range lines {
+			if strings.Contains(line, "<pid>") {
+				start := strings.Index(line, "<pid>") + len("<pid>")
+				end := strings.Index(line, "</pid>")
+				if end > start {
+					pidStr = line[start:end]
+				}
+			} else if strings.Contains(line, "<output_file>") {
+				start := strings.Index(line, "<output_file>") + len("<output_file>")
+				end := strings.Index(line, "</output_file>")
+				if end > start {
+					outFile = line[start:end]
+				}
+			}
+		}
+
+		// Wait for the command output to be written to file
+		waitForFile(t, outFile)
+
+		// Check output content
+		outputContent, err := os.ReadFile(outFile)
+		if err != nil {
+			t.Fatalf("Failed to read output file: %v", err)
+		}
+		expectedOutput := "Running in background\n"
+		if string(outputContent) != expectedOutput {
+			t.Errorf("Expected output content %q, got %q", expectedOutput, string(outputContent))
+		}
+
+		// Verify the process is still running by parsing PID
+		if pidStr != "" {
+			// We can't easily test if the process is still running without importing strconv
+			// and the process might have finished by now anyway due to timing
+			t.Log("Process started in background with PID:", pidStr)
+		}
+
+		// Clean up
+		os.Remove(outFile)
+		os.Remove(filepath.Dir(outFile))
+	})
+}
+
+func TestBashTimeout(t *testing.T) {
+	// Test default timeout values
+	t.Run("Default Timeout Values", func(t *testing.T) {
+		// Test foreground default timeout
+		foreground := bashInput{
+			Command:    "echo 'test'",
+			Background: false,
+		}
+		fgTimeout := foreground.timeout(nil)
+		expectedFg := 30 * time.Second
+		if fgTimeout != expectedFg {
+			t.Errorf("Expected foreground default timeout to be %v, got %v", expectedFg, fgTimeout)
+		}
+
+		// Test background default timeout
+		background := bashInput{
+			Command:    "echo 'test'",
+			Background: true,
+		}
+		bgTimeout := background.timeout(nil)
+		expectedBg := 24 * time.Hour
+		if bgTimeout != expectedBg {
+			t.Errorf("Expected background default timeout to be %v, got %v", expectedBg, bgTimeout)
+		}
+
+		// Test slow_ok timeout
+		slowOk := bashInput{
+			Command:    "echo 'test'",
+			Background: false,
+			SlowOK:     true,
+		}
+		slowTimeout := slowOk.timeout(nil)
+		expectedSlow := 15 * time.Minute
+		if slowTimeout != expectedSlow {
+			t.Errorf("Expected slow_ok timeout to be %v, got %v", expectedSlow, slowTimeout)
+		}
+
+		// Test custom timeout config
+		customTimeouts := &Timeouts{
+			Fast:       5 * time.Second,
+			Slow:       2 * time.Minute,
+			Background: 1 * time.Hour,
+		}
+		customFast := bashInput{
+			Command:    "echo 'test'",
+			Background: false,
+		}
+		customTimeout := customFast.timeout(customTimeouts)
+		expectedCustom := 5 * time.Second
+		if customTimeout != expectedCustom {
+			t.Errorf("Expected custom timeout to be %v, got %v", expectedCustom, customTimeout)
+		}
+	})
+}
+
+// waitForFile waits for a file to exist and be non-empty or times out
+func waitForFile(t *testing.T, filepath string) {
+	timeout := time.After(5 * time.Second)
+	tick := time.NewTicker(10 * time.Millisecond)
+	defer tick.Stop()
+
+	for {
+		select {
+		case <-timeout:
+			t.Fatalf("Timed out waiting for file to exist and have contents: %s", filepath)
+			return
+		case <-tick.C:
+			info, err := os.Stat(filepath)
+			if err == nil && info.Size() > 0 {
+				return // File exists and has content
+			}
+		}
+	}
+}
+
+// waitForProcessDeath waits for a process to no longer exist or times out
+func waitForProcessDeath(t *testing.T, pid int) {
+	timeout := time.After(5 * time.Second)
+	tick := time.NewTicker(50 * time.Millisecond)
+	defer tick.Stop()
+
+	for {
+		select {
+		case <-timeout:
+			t.Fatalf("Timed out waiting for process %d to exit", pid)
+			return
+		case <-tick.C:
+			process, _ := os.FindProcess(pid)
+			err := process.Signal(syscall.Signal(0))
+			if err != nil {
+				// Process doesn't exist
+				return
+			}
+		}
+	}
+}

claudetool/bashkit/bashkit.go πŸ”—

@@ -0,0 +1,242 @@
+package bashkit
+
+import (
+	"fmt"
+	"strings"
+	"sync"
+
+	"mvdan.cc/sh/v3/syntax"
+)
+
+var checks = []func(*syntax.CallExpr) error{
+	noBlindGitAdd,
+}
+
+// Process-level checks that track state across calls
+var processAwareChecks = []func(*syntax.CallExpr) error{
+	noSketchWipBranchChangesOnce,
+}
+
+// Track whether sketch-wip branch warning has been shown in this process
+var (
+	sketchWipWarningMu    sync.Mutex
+	sketchWipWarningShown bool
+)
+
+// ResetSketchWipWarning resets the warning state for testing purposes
+func ResetSketchWipWarning() {
+	sketchWipWarningMu.Lock()
+	sketchWipWarningShown = false
+	sketchWipWarningMu.Unlock()
+}
+
+// Check inspects bashScript and returns an error if it ought not be executed.
+// Check DOES NOT PROVIDE SECURITY against malicious actors.
+// It is intended to catch straightforward mistakes in which a model
+// does things despite having been instructed not to do them.
+func Check(bashScript string) error {
+	r := strings.NewReader(bashScript)
+	parser := syntax.NewParser()
+	file, err := parser.Parse(r, "")
+	if err != nil {
+		// Execution will fail, but we'll get a better error message from bash.
+		// Note that if this were security load bearing, this would be a terrible idea:
+		// You could smuggle stuff past Check by exploiting differences in what is considered syntactically valid.
+		// But it is not.
+		return nil
+	}
+
+	syntax.Walk(file, func(node syntax.Node) bool {
+		if err != nil {
+			return false
+		}
+		callExpr, ok := node.(*syntax.CallExpr)
+		if !ok {
+			return true
+		}
+		// Run regular checks
+		for _, check := range checks {
+			err = check(callExpr)
+			if err != nil {
+				return false
+			}
+		}
+		// Run process-aware checks
+		for _, check := range processAwareChecks {
+			err = check(callExpr)
+			if err != nil {
+				return false
+			}
+		}
+		return true
+	})
+
+	return err
+}
+
+// WillRunGitCommit checks if the provided bash script will run 'git commit'.
+// It returns true if any command in the script is a git commit command.
+func WillRunGitCommit(bashScript string) (bool, error) {
+	r := strings.NewReader(bashScript)
+	parser := syntax.NewParser()
+	file, err := parser.Parse(r, "")
+	if err != nil {
+		// Parsing failed, but let's not consider this an error for the same reasons as in Check
+		return false, nil
+	}
+
+	willCommit := false
+
+	syntax.Walk(file, func(node syntax.Node) bool {
+		callExpr, ok := node.(*syntax.CallExpr)
+		if !ok {
+			return true
+		}
+		if isGitCommitCommand(callExpr) {
+			willCommit = true
+			return false
+		}
+		return true
+	})
+
+	return willCommit, nil
+}
+
+// noBlindGitAdd checks for git add commands that blindly add all files.
+// It rejects patterns like 'git add -A', 'git add .', 'git add --all', 'git add *'.
+func noBlindGitAdd(cmd *syntax.CallExpr) error {
+	if hasBlindGitAdd(cmd) {
+		return fmt.Errorf("permission denied: blind git add commands (git add -A, git add ., git add --all, git add *) are not allowed, specify files explicitly")
+	}
+	return nil
+}
+
+func hasBlindGitAdd(cmd *syntax.CallExpr) bool {
+	if len(cmd.Args) < 2 {
+		return false
+	}
+	if cmd.Args[0].Lit() != "git" {
+		return false
+	}
+
+	// Find the 'add' subcommand
+	addIndex := -1
+	for i, arg := range cmd.Args {
+		if arg.Lit() == "add" {
+			addIndex = i
+			break
+		}
+	}
+
+	if addIndex < 0 {
+		return false
+	}
+
+	// Check arguments after 'add' for blind patterns
+	for i := addIndex + 1; i < len(cmd.Args); i++ {
+		arg := cmd.Args[i].Lit()
+		// Check for blind add patterns
+		if arg == "-A" || arg == "--all" || arg == "." || arg == "*" {
+			return true
+		}
+	}
+
+	return false
+}
+
+// isGitCommitCommand checks if a command is 'git commit'.
+func isGitCommitCommand(cmd *syntax.CallExpr) bool {
+	if len(cmd.Args) < 2 {
+		return false
+	}
+
+	// First argument must be 'git'
+	if cmd.Args[0].Lit() != "git" {
+		return false
+	}
+
+	// Look for 'commit' in any position after 'git'
+	for i := 1; i < len(cmd.Args); i++ {
+		if cmd.Args[i].Lit() == "commit" {
+			return true
+		}
+	}
+
+	return false
+}
+
+// noSketchWipBranchChangesOnce checks for git commands that would change the sketch-wip branch.
+// It rejects commands that would rename the sketch-wip branch or switch away from it.
+// This check only shows the warning once per process.
+func noSketchWipBranchChangesOnce(cmd *syntax.CallExpr) error {
+	if hasSketchWipBranchChanges(cmd) {
+		// Check if we've already warned in this process
+		sketchWipWarningMu.Lock()
+		alreadyWarned := sketchWipWarningShown
+		if !alreadyWarned {
+			sketchWipWarningShown = true
+		}
+		sketchWipWarningMu.Unlock()
+
+		if !alreadyWarned {
+			return fmt.Errorf("permission denied: cannot leave 'sketch-wip' branch. This branch is designated for change detection and auto-push; work on other branches may be lost. Warning shown once per session. Repeat command if needed for temporary operations (rebase, bisect, etc.) but return to sketch-wip afterward. Note: users can push to any branch via the Push button in the UI")
+		}
+	}
+	return nil
+}
+
+// hasSketchWipBranchChanges checks if a git command would change the sketch-wip branch.
+func hasSketchWipBranchChanges(cmd *syntax.CallExpr) bool {
+	if len(cmd.Args) < 2 {
+		return false
+	}
+	if cmd.Args[0].Lit() != "git" {
+		return false
+	}
+
+	// Look for subcommands that could change the sketch-wip branch
+	for i := 1; i < len(cmd.Args); i++ {
+		arg := cmd.Args[i].Lit()
+		switch arg {
+		case "branch":
+			// Check for branch rename: git branch -m sketch-wip newname or git branch -M sketch-wip newname
+			if i+2 < len(cmd.Args) {
+				// Look for -m or -M flag
+				for j := i + 1; j < len(cmd.Args)-1; j++ {
+					flag := cmd.Args[j].Lit()
+					if flag == "-m" || flag == "-M" {
+						// Check if sketch-wip is the source branch
+						if cmd.Args[j+1].Lit() == "sketch-wip" {
+							return true
+						}
+					}
+				}
+			}
+		case "checkout":
+			// Check for branch switching: git checkout otherbranch
+			// But allow git checkout files/paths
+			if i+1 < len(cmd.Args) {
+				nextArg := cmd.Args[i+1].Lit()
+				// Skip if it's a flag
+				if !strings.HasPrefix(nextArg, "-") {
+					// This might be a branch checkout - we'll be conservative and warn
+					// unless it looks like a file path
+					if !strings.Contains(nextArg, "/") && !strings.Contains(nextArg, ".") {
+						return true
+					}
+				}
+			}
+		case "switch":
+			// Check for branch switching: git switch otherbranch
+			if i+1 < len(cmd.Args) {
+				nextArg := cmd.Args[i+1].Lit()
+				// Skip if it's a flag
+				if !strings.HasPrefix(nextArg, "-") {
+					return true
+				}
+			}
+		}
+	}
+
+	return false
+}

claudetool/bashkit/bashkit_test.go πŸ”—

@@ -0,0 +1,484 @@
+package bashkit
+
+import (
+	"strings"
+	"testing"
+
+	"mvdan.cc/sh/v3/syntax"
+)
+
+func TestCheck(t *testing.T) {
+	tests := []struct {
+		name     string
+		script   string
+		wantErr  bool
+		errMatch string // string to match in error message, if wantErr is true
+	}{
+		{
+			name:     "valid script",
+			script:   "echo hello world",
+			wantErr:  false,
+			errMatch: "",
+		},
+		{
+			name:     "invalid syntax",
+			script:   "echo 'unterminated string",
+			wantErr:  false, // As per implementation, syntax errors are not flagged
+			errMatch: "",
+		},
+		// Git add validation tests
+		{
+			name:     "git add with -A flag",
+			script:   "git add -A",
+			wantErr:  true,
+			errMatch: "blind git add commands",
+		},
+		{
+			name:     "git add with --all flag",
+			script:   "git add --all",
+			wantErr:  true,
+			errMatch: "blind git add commands",
+		},
+		{
+			name:     "git add with dot",
+			script:   "git add .",
+			wantErr:  true,
+			errMatch: "blind git add commands",
+		},
+		{
+			name:     "git add with asterisk",
+			script:   "git add *",
+			wantErr:  true,
+			errMatch: "blind git add commands",
+		},
+		{
+			name:     "git add with multiple flags including -A",
+			script:   "git add -v -A",
+			wantErr:  true,
+			errMatch: "blind git add commands",
+		},
+		{
+			name:     "git add with specific file",
+			script:   "git add main.go",
+			wantErr:  false,
+			errMatch: "",
+		},
+		{
+			name:     "git add with multiple specific files",
+			script:   "git add main.go utils.go",
+			wantErr:  false,
+			errMatch: "",
+		},
+		{
+			name:     "git add with directory path",
+			script:   "git add src/main.go",
+			wantErr:  false,
+			errMatch: "",
+		},
+		{
+			name:     "git add with git flags before add",
+			script:   "git -C /path/to/repo add -A",
+			wantErr:  true,
+			errMatch: "blind git add commands",
+		},
+		{
+			name:     "git add with valid flags",
+			script:   "git add -v main.go",
+			wantErr:  false,
+			errMatch: "",
+		},
+		{
+			name:     "git command without add",
+			script:   "git status",
+			wantErr:  false,
+			errMatch: "",
+		},
+		{
+			name:     "multiline script with blind git add",
+			script:   "echo 'Adding files' && git add -A && git commit -m 'Update'",
+			wantErr:  true,
+			errMatch: "blind git add commands",
+		},
+		{
+			name:     "git add with pattern that looks like blind but is specific",
+			script:   "git add file.A",
+			wantErr:  false,
+			errMatch: "",
+		},
+		{
+			name:     "commented blind git add",
+			script:   "# git add -A",
+			wantErr:  false,
+			errMatch: "",
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			err := Check(tc.script)
+			if (err != nil) != tc.wantErr {
+				t.Errorf("Check() error = %v, wantErr %v", err, tc.wantErr)
+				return
+			}
+			if tc.wantErr && err != nil && !strings.Contains(err.Error(), tc.errMatch) {
+				t.Errorf("Check() error message = %v, want containing %v", err, tc.errMatch)
+			}
+		})
+	}
+}
+
+func TestWillRunGitCommit(t *testing.T) {
+	tests := []struct {
+		name       string
+		script     string
+		wantCommit bool
+	}{
+		{
+			name:       "simple git commit",
+			script:     "git commit -m 'Add feature'",
+			wantCommit: true,
+		},
+		{
+			name:       "git command without commit",
+			script:     "git status",
+			wantCommit: false,
+		},
+		{
+			name:       "multiline script with git commit",
+			script:     "echo 'Making changes' && git add . && git commit -m 'Update files'",
+			wantCommit: true,
+		},
+		{
+			name:       "multiline script without git commit",
+			script:     "echo 'Checking status' && git status",
+			wantCommit: false,
+		},
+		{
+			name:       "script with commented git commit",
+			script:     "# git commit -m 'This is commented out'",
+			wantCommit: false,
+		},
+		{
+			name:       "git commit with variables",
+			script:     "MSG='Fix bug' && git commit -m 'Using variable'",
+			wantCommit: true,
+		},
+		{
+			name:       "only git command",
+			script:     "git",
+			wantCommit: false,
+		},
+		{
+			name:       "script with invalid syntax",
+			script:     "git commit -m 'unterminated string",
+			wantCommit: false,
+		},
+		{
+			name:       "commit used in different context",
+			script:     "echo 'commit message'",
+			wantCommit: false,
+		},
+		{
+			name:       "git with flags before commit",
+			script:     "git -C /path/to/repo commit -m 'Update'",
+			wantCommit: true,
+		},
+		{
+			name:       "git with multiple flags",
+			script:     "git --git-dir=.git -C repo commit -a -m 'Update'",
+			wantCommit: true,
+		},
+		{
+			name:       "git with env vars",
+			script:     "GIT_AUTHOR_NAME=\"Josh Bleecher Snyder\" GIT_AUTHOR_EMAIL=\"josharian@gmail.com\" git commit -am \"Updated code\"",
+			wantCommit: true,
+		},
+		{
+			name:       "git with redirections",
+			script:     "git commit -m 'Fix issue' > output.log 2>&1",
+			wantCommit: true,
+		},
+		{
+			name:       "git with piped commands",
+			script:     "echo 'Committing' | git commit -F -",
+			wantCommit: true,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			gotCommit, err := WillRunGitCommit(tc.script)
+			if err != nil {
+				t.Errorf("WillRunGitCommit() error = %v", err)
+				return
+			}
+			if gotCommit != tc.wantCommit {
+				t.Errorf("WillRunGitCommit() = %v, want %v", gotCommit, tc.wantCommit)
+			}
+		})
+	}
+}
+
+func TestSketchWipBranchProtection(t *testing.T) {
+	tests := []struct {
+		name        string
+		script      string
+		wantErr     bool
+		errMatch    string
+		resetBefore bool // if true, reset warning state before test
+	}{
+		{
+			name:        "git branch rename sketch-wip",
+			script:      "git branch -m sketch-wip new-branch",
+			wantErr:     true,
+			errMatch:    "cannot leave 'sketch-wip' branch",
+			resetBefore: true,
+		},
+		{
+			name:        "git branch force rename sketch-wip",
+			script:      "git branch -M sketch-wip new-branch",
+			wantErr:     false, // second call should not error (already warned)
+			errMatch:    "",
+			resetBefore: false,
+		},
+		{
+			name:        "git checkout to other branch",
+			script:      "git checkout main",
+			wantErr:     false, // third call should not error (already warned)
+			errMatch:    "",
+			resetBefore: false,
+		},
+		{
+			name:        "git switch to other branch",
+			script:      "git switch main",
+			wantErr:     false, // fourth call should not error (already warned)
+			errMatch:    "",
+			resetBefore: false,
+		},
+		{
+			name:        "git checkout file (should be allowed)",
+			script:      "git checkout -- file.txt",
+			wantErr:     false,
+			errMatch:    "",
+			resetBefore: false,
+		},
+		{
+			name:        "git checkout path (should be allowed)",
+			script:      "git checkout -- src/main.go",
+			wantErr:     false,
+			errMatch:    "",
+			resetBefore: false,
+		},
+		{
+			name:        "git commit (should be allowed)",
+			script:      "git commit -m 'test'",
+			wantErr:     false,
+			errMatch:    "",
+			resetBefore: false,
+		},
+		{
+			name:        "git status (should be allowed)",
+			script:      "git status",
+			wantErr:     false,
+			errMatch:    "",
+			resetBefore: false,
+		},
+		{
+			name:        "git branch rename other branch (should be allowed)",
+			script:      "git branch -m old-branch new-branch",
+			wantErr:     false,
+			errMatch:    "",
+			resetBefore: false,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.resetBefore {
+				ResetSketchWipWarning()
+			}
+			err := Check(tc.script)
+			if (err != nil) != tc.wantErr {
+				t.Errorf("Check() error = %v, wantErr %v", err, tc.wantErr)
+				return
+			}
+			if tc.wantErr && err != nil && !strings.Contains(err.Error(), tc.errMatch) {
+				t.Errorf("Check() error message = %v, want containing %v", err, tc.errMatch)
+			}
+		})
+	}
+}
+
+func TestHasSketchWipBranchChanges(t *testing.T) {
+	tests := []struct {
+		name    string
+		script  string
+		wantHas bool
+	}{
+		{
+			name:    "git branch rename sketch-wip",
+			script:  "git branch -m sketch-wip new-branch",
+			wantHas: true,
+		},
+		{
+			name:    "git branch force rename sketch-wip",
+			script:  "git branch -M sketch-wip new-branch",
+			wantHas: true,
+		},
+		{
+			name:    "git checkout to branch",
+			script:  "git checkout main",
+			wantHas: true,
+		},
+		{
+			name:    "git switch to branch",
+			script:  "git switch main",
+			wantHas: true,
+		},
+		{
+			name:    "git checkout file",
+			script:  "git checkout -- file.txt",
+			wantHas: false,
+		},
+		{
+			name:    "git checkout path",
+			script:  "git checkout src/main.go",
+			wantHas: false,
+		},
+		{
+			name:    "git checkout with .extension",
+			script:  "git checkout file.go",
+			wantHas: false,
+		},
+		{
+			name:    "git status",
+			script:  "git status",
+			wantHas: false,
+		},
+		{
+			name:    "git commit",
+			script:  "git commit -m 'test'",
+			wantHas: false,
+		},
+		{
+			name:    "git branch rename other",
+			script:  "git branch -m old-branch new-branch",
+			wantHas: false,
+		},
+		{
+			name:    "git switch with flag",
+			script:  "git switch -c new-branch",
+			wantHas: false,
+		},
+		{
+			name:    "git checkout with flag",
+			script:  "git checkout -b new-branch",
+			wantHas: false,
+		},
+		{
+			name:    "not a git command",
+			script:  "echo hello",
+			wantHas: false,
+		},
+		{
+			name:    "empty command",
+			script:  "",
+			wantHas: false,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			r := strings.NewReader(tc.script)
+			parser := syntax.NewParser()
+			file, err := parser.Parse(r, "")
+			if err != nil {
+				if tc.wantHas {
+					t.Errorf("Parse error: %v", err)
+				}
+				return
+			}
+
+			found := false
+			syntax.Walk(file, func(node syntax.Node) bool {
+				callExpr, ok := node.(*syntax.CallExpr)
+				if !ok {
+					return true
+				}
+				if hasSketchWipBranchChanges(callExpr) {
+					found = true
+					return false
+				}
+				return true
+			})
+
+			if found != tc.wantHas {
+				t.Errorf("hasSketchWipBranchChanges() = %v, want %v", found, tc.wantHas)
+			}
+		})
+	}
+}
+
+func TestEdgeCases(t *testing.T) {
+	tests := []struct {
+		name        string
+		script      string
+		wantErr     bool
+		resetBefore bool // if true, reset warning state before test
+	}{
+		{
+			name:        "git branch -m with current branch to sketch-wip (should be allowed)",
+			script:      "git branch -m current-branch sketch-wip",
+			wantErr:     false,
+			resetBefore: true,
+		},
+		{
+			name:        "git branch -m sketch-wip with no destination (should be blocked)",
+			script:      "git branch -m sketch-wip",
+			wantErr:     true,
+			resetBefore: true,
+		},
+		{
+			name:        "git branch -M with current branch to sketch-wip (should be allowed)",
+			script:      "git branch -M current-branch sketch-wip",
+			wantErr:     false,
+			resetBefore: true,
+		},
+		{
+			name:        "git checkout with -- flags (should be allowed)",
+			script:      "git checkout -- --weird-filename",
+			wantErr:     false,
+			resetBefore: true,
+		},
+		{
+			name:        "git switch with create flag (should be allowed)",
+			script:      "git switch --create new-branch",
+			wantErr:     false,
+			resetBefore: true,
+		},
+		{
+			name:        "complex git command with sketch-wip rename",
+			script:      "git add . && git commit -m \"test\" && git branch -m sketch-wip production",
+			wantErr:     true,
+			resetBefore: true,
+		},
+		{
+			name:        "git switch with -c short form (should be allowed)",
+			script:      "git switch -c feature-branch",
+			wantErr:     false,
+			resetBefore: true,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.resetBefore {
+				ResetSketchWipWarning()
+			}
+			err := Check(tc.script)
+			if (err != nil) != tc.wantErr {
+				t.Errorf("Check() error = %v, wantErr %v", err, tc.wantErr)
+			}
+		})
+	}
+}

claudetool/bashkit/parsing.go πŸ”—

@@ -0,0 +1,67 @@
+package bashkit
+
+import (
+	"fmt"
+	"strings"
+
+	"mvdan.cc/sh/v3/interp"
+	"mvdan.cc/sh/v3/syntax"
+)
+
+// ExtractCommands parses a bash command and extracts individual command names that are
+// candidates for auto-installation.
+//
+// Returns only simple command names (no paths, no builtins, no variable assignments)
+// that could potentially be missing tools that need installation.
+//
+// Filtering logic:
+// - Excludes commands with paths (./script.sh, /usr/bin/tool, ../build.sh)
+// - Excludes shell builtins (echo, cd, test, [, etc.)
+// - Excludes variable assignments (FOO=bar)
+// - Deduplicates repeated command names
+//
+// Examples:
+//
+//	"ls -la && echo done" β†’ ["ls"] (echo filtered as builtin)
+//	"./deploy.sh && curl api.com" β†’ ["curl"] (./deploy.sh filtered as path)
+//	"yamllint config.yaml" β†’ ["yamllint"] (candidate for installation)
+func ExtractCommands(command string) ([]string, error) {
+	r := strings.NewReader(command)
+	parser := syntax.NewParser()
+	file, err := parser.Parse(r, "")
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse bash command: %w", err)
+	}
+
+	var commands []string
+	seen := make(map[string]bool)
+
+	syntax.Walk(file, func(node syntax.Node) bool {
+		callExpr, ok := node.(*syntax.CallExpr)
+		if !ok || len(callExpr.Args) == 0 {
+			return true
+		}
+		cmdName := callExpr.Args[0].Lit()
+		if cmdName == "" {
+			return true
+		}
+		if strings.Contains(cmdName, "=") {
+			// variable assignment
+			return true
+		}
+		if strings.Contains(cmdName, "/") {
+			// commands with slashes are user-specified executables/scripts
+			return true
+		}
+		if interp.IsBuiltin(cmdName) {
+			return true
+		}
+		if !seen[cmdName] {
+			seen[cmdName] = true
+			commands = append(commands, cmdName)
+		}
+		return true
+	})
+
+	return commands, nil
+}

claudetool/bashkit/parsing_test.go πŸ”—

@@ -0,0 +1,146 @@
+package bashkit
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestExtractCommands(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected []string
+	}{
+		{
+			name:     "simple command",
+			input:    "ls -la",
+			expected: []string{"ls"},
+		},
+		{
+			name:     "command with pipe",
+			input:    "ls -la | grep test",
+			expected: []string{"ls", "grep"},
+		},
+		{
+			name:     "command with logical and (builtin filtered)",
+			input:    "mkdir test && cd test",
+			expected: []string{"mkdir"}, // cd is builtin, filtered out
+		},
+		{
+			name:     "if statement with commands (builtin filtered)",
+			input:    "if [ -f file.txt ]; then cat file.txt; fi",
+			expected: []string{"cat"}, // [ is builtin, filtered out
+		},
+		{
+			name:     "variable assignment with command (builtin filtered)",
+			input:    "FOO=bar echo $FOO",
+			expected: []string{}, // echo is builtin, filtered out
+		},
+		{
+			name:     "script path filtered out (builtin also filtered)",
+			input:    "./script.sh && echo done",
+			expected: []string{}, // echo is builtin, filtered out
+		},
+		{
+			name:     "multiline script (builtin filtered)",
+			input:    "python3 -c 'print(\"hello\")'\necho 'done'",
+			expected: []string{"python3"}, // echo is builtin, filtered out
+		},
+		{
+			name:     "complex command chain (builtin filtered)",
+			input:    "curl -s https://api.github.com | jq '.name' && echo 'done'",
+			expected: []string{"curl", "jq"}, // echo is builtin, filtered out
+		},
+		{
+			name:     "builtins filtered out",
+			input:    "echo 'test' && true && ls",
+			expected: []string{"ls"},
+		},
+		{
+			name:     "empty command",
+			input:    "",
+			expected: []string{},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := ExtractCommands(tt.input)
+			if err != nil {
+				t.Fatalf("ExtractCommands() error = %v", err)
+			}
+			// Handle empty slice comparison
+			if len(result) == 0 && len(tt.expected) == 0 {
+				return // Both are empty, test passes
+			}
+			if !reflect.DeepEqual(result, tt.expected) {
+				t.Errorf("ExtractCommands() = %v, want %v", result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestExtractCommandsErrorHandling(t *testing.T) {
+	// Test with syntactically invalid bash
+	invalidBash := "if [ incomplete"
+	_, err := ExtractCommands(invalidBash)
+	if err == nil {
+		t.Error("ExtractCommands() should return error for invalid bash syntax")
+	}
+}
+
+func TestExtractCommandsPathFiltering(t *testing.T) {
+	// Test that commands with paths are properly filtered out during extraction
+	tests := []struct {
+		name     string
+		input    string
+		expected []string
+	}{
+		{
+			name:     "relative script path filtered (builtin also filtered)",
+			input:    "./my-script.sh && echo 'done'",
+			expected: []string{}, // echo is builtin, filtered out
+		},
+		{
+			name:     "absolute path filtered",
+			input:    "/usr/bin/custom-tool --help",
+			expected: []string{},
+		},
+		{
+			name:     "parent directory script filtered",
+			input:    "../scripts/build.sh",
+			expected: []string{},
+		},
+		{
+			name:     "home directory path filtered",
+			input:    "~/.local/bin/tool",
+			expected: []string{},
+		},
+		{
+			name:     "simple commands without paths included",
+			input:    "curl https://example.com | jq '.name'",
+			expected: []string{"curl", "jq"},
+		},
+		{
+			name:     "mixed paths and simple commands",
+			input:    "./setup.sh && python3 -c 'print(\"hello\")' && /bin/ls",
+			expected: []string{"python3"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := ExtractCommands(tt.input)
+			if err != nil {
+				t.Fatalf("ExtractCommands() error = %v", err)
+			}
+			// Handle empty slice comparison
+			if len(result) == 0 && len(tt.expected) == 0 {
+				return // Both are empty, test passes
+			}
+			if !reflect.DeepEqual(result, tt.expected) {
+				t.Errorf("ExtractCommands() = %v, want %v", result, tt.expected)
+			}
+		})
+	}
+}

claudetool/browse/README.md πŸ”—

@@ -0,0 +1,113 @@
+# Browser Tools for Claude
+
+This package provides a set of tools that allow Claude to control a headless
+Chrome browser from Go. The tools are built using the
+[chromedp](https://github.com/chromedp/chromedp) library.
+
+## Available Tools
+
+1. `browser_navigate` - Navigate to a URL and wait for the page to load
+2. `browser_eval` - Evaluate JavaScript in the browser context
+3. `browser_screenshot` - Take a screenshot of the page or a specific element
+
+## Usage
+
+```go
+// Create a context
+ctx := context.Background()
+
+// Register browser tools and get a cleanup function
+tools, cleanup := browse.RegisterBrowserTools(ctx)
+defer cleanup() // Important: always call cleanup to release browser resources
+
+// Add tools to your agent
+for _, tool := range tools {
+    agent.AddTool(tool)
+}
+```
+
+## Requirements
+
+- Chrome or Chromium must be installed on the system
+- In Docker environments, the multi-stage build automatically provides headless-shell from chromedp/headless-shell
+- For local development, install Chrome/Chromium manually
+- The `chromedp` package handles launching and controlling the browser
+
+## Tool Input/Output
+
+All tools follow a standard JSON input/output format. For example:
+
+**Navigate Tool Input:**
+```json
+{
+  "url": "https://example.com"
+}
+```
+
+**Navigate Tool Output (success):**
+```json
+{
+  "status": "success"
+}
+```
+
+**Tool Output (error):**
+```json
+{
+  "status": "error",
+  "error": "Error message"
+}
+```
+
+## Example Tool Usage
+
+```go
+// Example of using the navigate tool directly
+navTool := tools[0] // Get browser_navigate tool
+input := map[string]string{"url": "https://example.com"}
+inputJSON, _ := json.Marshal(input)
+
+// Call the tool
+result, err := navTool.Run(ctx, json.RawMessage(inputJSON))
+if err != nil {
+    log.Fatalf("Error: %v", err)
+}
+fmt.Println(result)
+```
+
+## Screenshot Storage
+
+The browser screenshot tool has been modified to save screenshots to a temporary directory and identify them by ID, rather than returning base64-encoded data directly. This improves efficiency by:
+
+1. Reducing token usage in LLM responses
+2. Avoiding encoding/decoding overhead
+3. Allowing for larger screenshots without message size limitations
+
+### How It Works
+
+1. When a screenshot is taken, it's saved to `/tmp/shelley-screenshots/` with a unique UUID filename
+2. The tool returns the screenshot ID in its response
+3. The web UI can fetch the screenshot using the `/api/read?path=...` endpoint (with path set to the screenshot file)
+
+### Example Usage
+
+Agent calls the screenshot tool:
+```json
+{
+  "id": "tool_call_123",
+  "name": "browser_screenshot",
+  "params": {}
+}
+```
+
+Tool response:
+```json
+{
+  "id": "tool_call_123",
+  "result": {
+    "id": "550e8400-e29b-41d4-a716-446655440000"
+  }
+}
+```
+
+The screenshot is then accessible at: `/api/read?path=/tmp/shelley-screenshots/550e8400-e29b-41d4-a716-446655440000.png`

claudetool/browse/browse.go πŸ”—

@@ -0,0 +1,722 @@
+// Package browse provides browser automation tools for the agent
+package browse
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"log"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/chromedp/cdproto/runtime"
+	"github.com/chromedp/chromedp"
+	"github.com/google/uuid"
+	"shelley.exe.dev/llm"
+)
+
+// ScreenshotDir is the directory where screenshots are stored
+const ScreenshotDir = "/tmp/shelley-screenshots"
+
+// DefaultIdleTimeout is how long to wait before shutting down an idle browser
+const DefaultIdleTimeout = 30 * time.Minute
+
+// BrowseTools contains all browser tools and manages a shared browser instance
+type BrowseTools struct {
+	ctx              context.Context
+	allocCtx         context.Context
+	allocCancel      context.CancelFunc
+	browserCtx       context.Context
+	browserCtxCancel context.CancelFunc
+	mux              sync.Mutex
+	// Map to track screenshots by ID and their creation time
+	screenshots      map[string]time.Time
+	screenshotsMutex sync.Mutex
+	// Console logs storage
+	consoleLogs      []*runtime.EventConsoleAPICalled
+	consoleLogsMutex sync.Mutex
+	maxConsoleLogs   int
+	// Idle timeout management
+	idleTimeout time.Duration
+	idleTimer   *time.Timer
+}
+
+// NewBrowseTools creates a new set of browser automation tools
+func NewBrowseTools(ctx context.Context) *BrowseTools {
+	return NewBrowseToolsWithIdleTimeout(ctx, DefaultIdleTimeout)
+}
+
+// NewBrowseToolsWithIdleTimeout creates browser tools with a custom idle timeout
+func NewBrowseToolsWithIdleTimeout(ctx context.Context, idleTimeout time.Duration) *BrowseTools {
+	// Ensure the screenshot directory exists
+	if err := os.MkdirAll(ScreenshotDir, 0o755); err != nil {
+		log.Printf("Failed to create screenshot directory: %v", err)
+	}
+
+	return &BrowseTools{
+		ctx:            ctx,
+		screenshots:    make(map[string]time.Time),
+		consoleLogs:    make([]*runtime.EventConsoleAPICalled, 0),
+		maxConsoleLogs: 100,
+		idleTimeout:    idleTimeout,
+	}
+}
+
+// GetBrowserContext returns the browser context, initializing if needed and resetting the idle timer.
+func (b *BrowseTools) GetBrowserContext() (context.Context, error) {
+	b.mux.Lock()
+	defer b.mux.Unlock()
+
+	// If browser exists, reset idle timer and return
+	if b.browserCtx != nil {
+		b.resetIdleTimerLocked()
+		return b.browserCtx, nil
+	}
+
+	// Initialize a new browser
+	opts := chromedp.DefaultExecAllocatorOptions[:]
+	opts = append(opts, chromedp.NoSandbox)
+	opts = append(opts, chromedp.Flag("--disable-dbus", true))
+	opts = append(opts, chromedp.WSURLReadTimeout(60*time.Second))
+
+	allocCtx, allocCancel := chromedp.NewExecAllocator(b.ctx, opts...)
+	browserCtx, browserCancel := chromedp.NewContext(
+		allocCtx,
+		chromedp.WithLogf(log.Printf),
+		chromedp.WithErrorf(log.Printf),
+		chromedp.WithBrowserOption(chromedp.WithDialTimeout(60*time.Second)),
+	)
+
+	// Set up console log listener
+	chromedp.ListenTarget(browserCtx, func(ev any) {
+		if e, ok := ev.(*runtime.EventConsoleAPICalled); ok {
+			b.captureConsoleLog(e)
+		}
+	})
+
+	// Start the browser
+	if err := chromedp.Run(browserCtx); err != nil {
+		allocCancel()
+		return nil, fmt.Errorf("failed to start browser (please apt get chromium or equivalent): %w", err)
+	}
+
+	// Set default viewport size to 1280x720 (16:9 widescreen)
+	if err := chromedp.Run(browserCtx, chromedp.EmulateViewport(1280, 720)); err != nil {
+		browserCancel()
+		allocCancel()
+		return nil, fmt.Errorf("failed to set default viewport: %w", err)
+	}
+
+	b.allocCtx = allocCtx
+	b.allocCancel = allocCancel
+	b.browserCtx = browserCtx
+	b.browserCtxCancel = browserCancel
+
+	b.resetIdleTimerLocked()
+
+	return b.browserCtx, nil
+}
+
+// resetIdleTimerLocked resets or starts the idle timer. Caller must hold b.mux.
+func (b *BrowseTools) resetIdleTimerLocked() {
+	if b.idleTimer != nil {
+		b.idleTimer.Stop()
+	}
+	b.idleTimer = time.AfterFunc(b.idleTimeout, b.idleShutdown)
+}
+
+// idleShutdown is called when the idle timer fires
+func (b *BrowseTools) idleShutdown() {
+	b.mux.Lock()
+	defer b.mux.Unlock()
+
+	if b.browserCtx == nil {
+		return
+	}
+
+	log.Printf("Browser idle for %v, shutting down", b.idleTimeout)
+	b.closeBrowserLocked()
+}
+
+// closeBrowserLocked shuts down the browser. Caller must hold b.mux.
+func (b *BrowseTools) closeBrowserLocked() {
+	if b.idleTimer != nil {
+		b.idleTimer.Stop()
+		b.idleTimer = nil
+	}
+
+	if b.browserCtxCancel != nil {
+		b.browserCtxCancel()
+		b.browserCtxCancel = nil
+	}
+
+	if b.allocCancel != nil {
+		b.allocCancel()
+		b.allocCancel = nil
+	}
+
+	b.browserCtx = nil
+	b.allocCtx = nil
+}
+
+// Close shuts down the browser
+func (b *BrowseTools) Close() {
+	b.mux.Lock()
+	defer b.mux.Unlock()
+	b.closeBrowserLocked()
+}
+
+// NavigateTool definition
+type navigateInput struct {
+	URL     string `json:"url"`
+	Timeout string `json:"timeout,omitempty"`
+}
+
+// isPort80 reports whether urlStr definitely uses port 80.
+func isPort80(urlStr string) bool {
+	parsedURL, err := url.Parse(urlStr)
+	if err != nil {
+		return false
+	}
+	port := parsedURL.Port()
+	return port == "80" || (port == "" && parsedURL.Scheme == "http")
+}
+
+// NewNavigateTool creates a tool for navigating to URLs
+func (b *BrowseTools) NewNavigateTool() *llm.Tool {
+	return &llm.Tool{
+		Name:        "browser_navigate",
+		Description: "Navigate the browser to a specific URL and wait for page to load",
+		InputSchema: json.RawMessage(`{
+			"type": "object",
+			"properties": {
+				"url": {
+					"type": "string",
+					"description": "The URL to navigate to"
+				},
+				"timeout": {
+					"type": "string",
+					"description": "Timeout as a Go duration string (default: 15s)"
+				}
+			},
+			"required": ["url"]
+		}`),
+		Run: b.navigateRun,
+	}
+}
+
+func (b *BrowseTools) navigateRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+	var input navigateInput
+	if err := json.Unmarshal(m, &input); err != nil {
+		return llm.ErrorfToolOut("invalid input: %w", err)
+	}
+
+	if isPort80(input.URL) {
+		return llm.ErrorToolOut(fmt.Errorf("port 80 is not the port you're looking for--port 80 is the main sketch server"))
+	}
+
+	browserCtx, err := b.GetBrowserContext()
+	if err != nil {
+		return llm.ErrorToolOut(err)
+	}
+
+	// Create a timeout context for this operation
+	timeoutCtx, cancel := context.WithTimeout(browserCtx, parseTimeout(input.Timeout))
+	defer cancel()
+
+	err = chromedp.Run(timeoutCtx,
+		chromedp.Navigate(input.URL),
+		chromedp.WaitReady("body"),
+	)
+	if err != nil {
+		return llm.ErrorToolOut(err)
+	}
+
+	return llm.ToolOut{LLMContent: llm.TextContent("done")}
+}
+
+// ResizeTool definition
+type resizeInput struct {
+	Width   int    `json:"width"`
+	Height  int    `json:"height"`
+	Timeout string `json:"timeout,omitempty"`
+}
+
+// NewResizeTool creates a tool for resizing the browser viewport
+func (b *BrowseTools) NewResizeTool() *llm.Tool {
+	return &llm.Tool{
+		Name:        "browser_resize",
+		Description: "Resize the browser viewport to a specific width and height",
+		InputSchema: json.RawMessage(`{
+			"type": "object",
+			"properties": {
+				"width": {
+					"type": "integer",
+					"description": "Viewport width in pixels"
+				},
+				"height": {
+					"type": "integer",
+					"description": "Viewport height in pixels"
+				},
+				"timeout": {
+					"type": "string",
+					"description": "Timeout as a Go duration string (default: 15s)"
+				}
+			},
+			"required": ["width", "height"]
+		}`),
+		Run: b.resizeRun,
+	}
+}
+
+func (b *BrowseTools) resizeRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+	var input resizeInput
+	if err := json.Unmarshal(m, &input); err != nil {
+		return llm.ErrorfToolOut("invalid input: %w", err)
+	}
+
+	if input.Width <= 0 || input.Height <= 0 {
+		return llm.ErrorToolOut(fmt.Errorf("invalid dimensions: width and height must be positive"))
+	}
+
+	browserCtx, err := b.GetBrowserContext()
+	if err != nil {
+		return llm.ErrorToolOut(err)
+	}
+
+	timeoutCtx, cancel := context.WithTimeout(browserCtx, parseTimeout(input.Timeout))
+	defer cancel()
+
+	err = chromedp.Run(timeoutCtx,
+		chromedp.EmulateViewport(int64(input.Width), int64(input.Height)),
+	)
+	if err != nil {
+		return llm.ErrorToolOut(err)
+	}
+
+	return llm.ToolOut{LLMContent: llm.TextContent("done")}
+}
+
+// EvalTool definition
+type evalInput struct {
+	Expression string `json:"expression"`
+	Timeout    string `json:"timeout,omitempty"`
+	Await      *bool  `json:"await,omitempty"`
+}
+
+// NewEvalTool creates a tool for evaluating JavaScript
+func (b *BrowseTools) NewEvalTool() *llm.Tool {
+	return &llm.Tool{
+		Name: "browser_eval",
+		Description: `Evaluate JavaScript in the browser context.
+Your go-to tool for interacting with content: clicking buttons, typing, getting content, scrolling, resizing, waiting for content/selector to be ready, etc.`,
+		InputSchema: json.RawMessage(`{
+			"type": "object",
+			"properties": {
+				"expression": {
+					"type": "string",
+					"description": "JavaScript expression to evaluate"
+				},
+				"timeout": {
+					"type": "string",
+					"description": "Timeout as a Go duration string (default: 15s)"
+				},
+				"await": {
+					"type": "boolean",
+					"description": "If true, wait for promises to resolve and return their resolved value (default: true)"
+				}
+			},
+			"required": ["expression"]
+		}`),
+		Run: b.evalRun,
+	}
+}
+
+func (b *BrowseTools) evalRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+	var input evalInput
+	if err := json.Unmarshal(m, &input); err != nil {
+		return llm.ErrorfToolOut("invalid input: %w", err)
+	}
+
+	browserCtx, err := b.GetBrowserContext()
+	if err != nil {
+		return llm.ErrorToolOut(err)
+	}
+
+	// Create a timeout context for this operation
+	timeoutCtx, cancel := context.WithTimeout(browserCtx, parseTimeout(input.Timeout))
+	defer cancel()
+
+	var result any
+	var evalOps []chromedp.EvaluateOption
+
+	await := true
+	if input.Await != nil {
+		await = *input.Await
+	}
+	if await {
+		evalOps = append(evalOps, func(p *runtime.EvaluateParams) *runtime.EvaluateParams {
+			return p.WithAwaitPromise(true)
+		})
+	}
+
+	evalAction := chromedp.Evaluate(input.Expression, &result, evalOps...)
+
+	err = chromedp.Run(timeoutCtx, evalAction)
+	if err != nil {
+		return llm.ErrorToolOut(err)
+	}
+
+	// Return the result as JSON
+	response, err := json.Marshal(result)
+	if err != nil {
+		return llm.ErrorfToolOut("failed to marshal response: %w", err)
+	}
+
+	return llm.ToolOut{LLMContent: llm.TextContent("<javascript_result>" + string(response) + "</javascript_result>")}
+}
+
+// ScreenshotTool definition
+type screenshotInput struct {
+	Selector string `json:"selector,omitempty"`
+	Timeout  string `json:"timeout,omitempty"`
+}
+
+// NewScreenshotTool creates a tool for taking screenshots
+func (b *BrowseTools) NewScreenshotTool() *llm.Tool {
+	return &llm.Tool{
+		Name:        "browser_take_screenshot",
+		Description: "Take a screenshot of the page or a specific element",
+		InputSchema: json.RawMessage(`{
+			"type": "object",
+			"properties": {
+				"selector": {
+					"type": "string",
+					"description": "CSS selector for the element to screenshot (optional)"
+				},
+				"timeout": {
+					"type": "string",
+					"description": "Timeout as a Go duration string (default: 15s)"
+				}
+			}
+		}`),
+		Run: b.screenshotRun,
+	}
+}
+
+func (b *BrowseTools) screenshotRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+	var input screenshotInput
+	if err := json.Unmarshal(m, &input); err != nil {
+		return llm.ErrorfToolOut("invalid input: %w", err)
+	}
+
+	// Try to get a browser context; if unavailable, return an error
+	browserCtx, err := b.GetBrowserContext()
+	if err != nil {
+		return llm.ErrorToolOut(err)
+	}
+
+	// Create a timeout context for this operation
+	timeoutCtx, cancel := context.WithTimeout(browserCtx, parseTimeout(input.Timeout))
+	defer cancel()
+
+	var buf []byte
+	var actions []chromedp.Action
+
+	if input.Selector != "" {
+		// Take screenshot of specific element
+		actions = append(actions,
+			chromedp.WaitReady(input.Selector),
+			chromedp.Screenshot(input.Selector, &buf, chromedp.NodeVisible),
+		)
+	} else {
+		// Take full page screenshot
+		actions = append(actions, chromedp.CaptureScreenshot(&buf))
+	}
+
+	err = chromedp.Run(timeoutCtx, actions...)
+	if err != nil {
+		return llm.ErrorToolOut(err)
+	}
+
+	// Save the screenshot and get its ID for potential future reference
+	id := b.SaveScreenshot(buf)
+	if id == "" {
+		return llm.ErrorToolOut(fmt.Errorf("failed to save screenshot"))
+	}
+
+	// Get the full path to the screenshot
+	screenshotPath := GetScreenshotPath(id)
+
+	// Encode the image as base64
+	base64Data := base64.StdEncoding.EncodeToString(buf)
+
+	// Prepare display data for the UI
+	display := map[string]any{
+		"type":     "screenshot",
+		"id":       id,
+		"url":      "/api/read?path=" + url.QueryEscape(screenshotPath),
+		"path":     screenshotPath,
+		"selector": input.Selector,
+	}
+
+	// Return the screenshot directly to the LLM and provide display metadata for the UI
+	return llm.ToolOut{LLMContent: []llm.Content{
+		{
+			Type: llm.ContentTypeText,
+			Text: fmt.Sprintf("Screenshot taken (saved as %s)", screenshotPath),
+		},
+		{
+			Type:      llm.ContentTypeText, // Will be mapped to image in content array
+			MediaType: "image/png",
+			Data:      base64Data,
+		},
+	}, Display: display}
+}
+
+// GetTools returns browser tools, optionally filtering out screenshot-related tools
+func (b *BrowseTools) GetTools(includeScreenshotTools bool) []*llm.Tool {
+	tools := []*llm.Tool{
+		b.NewNavigateTool(),
+		b.NewEvalTool(),
+		b.NewResizeTool(),
+		b.NewRecentConsoleLogsTool(),
+		b.NewClearConsoleLogsTool(),
+	}
+
+	// Add screenshot-related tools if supported
+	if includeScreenshotTools {
+		tools = append(tools, b.NewScreenshotTool())
+		tools = append(tools, b.NewReadImageTool())
+	}
+
+	return tools
+}
+
+// SaveScreenshot saves a screenshot to disk and returns its ID
+func (b *BrowseTools) SaveScreenshot(data []byte) string {
+	// Generate a unique ID
+	id := uuid.New().String()
+
+	// Save the file
+	filePath := filepath.Join(ScreenshotDir, id+".png")
+	if err := os.WriteFile(filePath, data, 0o644); err != nil {
+		log.Printf("Failed to save screenshot: %v", err)
+		return ""
+	}
+
+	// Track this screenshot
+	b.screenshotsMutex.Lock()
+	b.screenshots[id] = time.Now()
+	b.screenshotsMutex.Unlock()
+
+	return id
+}
+
+// GetScreenshotPath returns the full path to a screenshot by ID
+func GetScreenshotPath(id string) string {
+	return filepath.Join(ScreenshotDir, id+".png")
+}
+
+// ReadImageTool definition
+type readImageInput struct {
+	Path    string `json:"path"`
+	Timeout string `json:"timeout,omitempty"`
+}
+
+// NewReadImageTool creates a tool for reading images and returning them as base64 encoded data
+func (b *BrowseTools) NewReadImageTool() *llm.Tool {
+	return &llm.Tool{
+		Name:        "read_image",
+		Description: "Read an image file (such as a screenshot) and encode it for sending to the LLM",
+		InputSchema: json.RawMessage(`{
+			"type": "object",
+			"properties": {
+				"path": {
+					"type": "string",
+					"description": "Path to the image file to read"
+				},
+				"timeout": {
+					"type": "string",
+					"description": "Timeout as a Go duration string (default: 15s)"
+				}
+			},
+			"required": ["path"]
+		}`),
+		Run: b.readImageRun,
+	}
+}
+
+func (b *BrowseTools) readImageRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+	var input readImageInput
+	if err := json.Unmarshal(m, &input); err != nil {
+		return llm.ErrorfToolOut("invalid input: %w", err)
+	}
+
+	// Check if the path exists
+	if _, err := os.Stat(input.Path); os.IsNotExist(err) {
+		return llm.ErrorfToolOut("image file not found: %s", input.Path)
+	}
+
+	// Read the file
+	imageData, err := os.ReadFile(input.Path)
+	if err != nil {
+		return llm.ErrorfToolOut("failed to read image file: %w", err)
+	}
+
+	// Detect the image type
+	imageType := http.DetectContentType(imageData)
+	if !strings.HasPrefix(imageType, "image/") {
+		return llm.ErrorfToolOut("file is not an image: %s", imageType)
+	}
+
+	// Encode the image as base64
+	base64Data := base64.StdEncoding.EncodeToString(imageData)
+
+	// Create a Content object that includes both text and the image
+	return llm.ToolOut{LLMContent: []llm.Content{
+		{
+			Type: llm.ContentTypeText,
+			Text: fmt.Sprintf("Image from %s (type: %s)", input.Path, imageType),
+		},
+		{
+			Type:      llm.ContentTypeText, // Will be mapped to image in content array
+			MediaType: imageType,
+			Data:      base64Data,
+		},
+	}}
+}
+
+// parseTimeout parses a timeout string and returns a time.Duration
+// It returns a default of 5 seconds if the timeout is empty or invalid
+func parseTimeout(timeout string) time.Duration {
+	dur, err := time.ParseDuration(timeout)
+	if err != nil {
+		return 15 * time.Second
+	}
+	return dur
+}
+
+// captureConsoleLog captures a console log event and stores it
+func (b *BrowseTools) captureConsoleLog(e *runtime.EventConsoleAPICalled) {
+	// Add to logs with mutex protection
+	b.consoleLogsMutex.Lock()
+	defer b.consoleLogsMutex.Unlock()
+
+	// Add the log and maintain max size
+	b.consoleLogs = append(b.consoleLogs, e)
+	if len(b.consoleLogs) > b.maxConsoleLogs {
+		b.consoleLogs = b.consoleLogs[len(b.consoleLogs)-b.maxConsoleLogs:]
+	}
+}
+
+// RecentConsoleLogsTool definition
+type recentConsoleLogsInput struct {
+	Limit int `json:"limit,omitempty"`
+}
+
+// NewRecentConsoleLogsTool creates a tool for retrieving recent console logs
+func (b *BrowseTools) NewRecentConsoleLogsTool() *llm.Tool {
+	return &llm.Tool{
+		Name:        "browser_recent_console_logs",
+		Description: "Get recent browser console logs",
+		InputSchema: json.RawMessage(`{
+			"type": "object",
+			"properties": {
+				"limit": {
+					"type": "integer",
+					"description": "Maximum number of log entries to return (default: 100)"
+				}
+			}
+		}`),
+		Run: b.recentConsoleLogsRun,
+	}
+}
+
+func (b *BrowseTools) recentConsoleLogsRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+	var input recentConsoleLogsInput
+	if err := json.Unmarshal(m, &input); err != nil {
+		return llm.ErrorfToolOut("invalid input: %w", err)
+	}
+
+	// Ensure browser is initialized
+	_, err := b.GetBrowserContext()
+	if err != nil {
+		return llm.ErrorToolOut(err)
+	}
+
+	// Apply limit (default to 100 if not specified)
+	limit := 100
+	if input.Limit > 0 {
+		limit = input.Limit
+	}
+
+	// Get console logs with mutex protection
+	b.consoleLogsMutex.Lock()
+	logs := make([]*runtime.EventConsoleAPICalled, 0, len(b.consoleLogs))
+	start := 0
+	if len(b.consoleLogs) > limit {
+		start = len(b.consoleLogs) - limit
+	}
+	logs = append(logs, b.consoleLogs[start:]...)
+	b.consoleLogsMutex.Unlock()
+
+	// Format the logs as JSON
+	logData, err := json.MarshalIndent(logs, "", "  ")
+	if err != nil {
+		return llm.ErrorfToolOut("failed to serialize logs: %w", err)
+	}
+
+	// Format the logs
+	var sb strings.Builder
+	sb.WriteString(fmt.Sprintf("Retrieved %d console log entries:\n\n", len(logs)))
+
+	if len(logs) == 0 {
+		sb.WriteString("No console logs captured.")
+	} else {
+		// Add the JSON data for full details
+		sb.WriteString(string(logData))
+	}
+
+	return llm.ToolOut{LLMContent: llm.TextContent(sb.String())}
+}
+
+// ClearConsoleLogsTool definition
+type clearConsoleLogsInput struct{}
+
+// NewClearConsoleLogsTool creates a tool for clearing console logs
+func (b *BrowseTools) NewClearConsoleLogsTool() *llm.Tool {
+	return &llm.Tool{
+		Name:        "browser_clear_console_logs",
+		Description: "Clear all captured browser console logs",
+		InputSchema: llm.EmptySchema(),
+		Run:         b.clearConsoleLogsRun,
+	}
+}
+
+func (b *BrowseTools) clearConsoleLogsRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+	var input clearConsoleLogsInput
+	if err := json.Unmarshal(m, &input); err != nil {
+		return llm.ErrorfToolOut("invalid input: %w", err)
+	}
+
+	// Ensure browser is initialized
+	_, err := b.GetBrowserContext()
+	if err != nil {
+		return llm.ErrorToolOut(err)
+	}
+
+	// Clear console logs with mutex protection
+	b.consoleLogsMutex.Lock()
+	logCount := len(b.consoleLogs)
+	b.consoleLogs = make([]*runtime.EventConsoleAPICalled, 0)
+	b.consoleLogsMutex.Unlock()
+
+	return llm.ToolOut{LLMContent: llm.TextContent(fmt.Sprintf("Cleared %d console log entries.", logCount))}
+}

claudetool/browse/browse_test.go πŸ”—

@@ -0,0 +1,408 @@
+package browse
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"slices"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/chromedp/chromedp"
+	"shelley.exe.dev/llm"
+)
+
+func TestToolCreation(t *testing.T) {
+	// Create browser tools instance
+	tools := NewBrowseTools(context.Background())
+	t.Cleanup(func() {
+		tools.Close()
+	})
+
+	// Test each tool has correct name and description
+	toolTests := []struct {
+		tool          *llm.Tool
+		expectedName  string
+		shortDesc     string
+		requiredProps []string
+	}{
+		{tools.NewNavigateTool(), "browser_navigate", "Navigate", []string{"url"}},
+		{tools.NewEvalTool(), "browser_eval", "Evaluate", []string{"expression"}},
+		{tools.NewResizeTool(), "browser_resize", "Resize", []string{"width", "height"}},
+		{tools.NewScreenshotTool(), "browser_take_screenshot", "Take", nil},
+	}
+
+	for _, tt := range toolTests {
+		t.Run(tt.expectedName, func(t *testing.T) {
+			if tt.tool.Name != tt.expectedName {
+				t.Errorf("expected name %q, got %q", tt.expectedName, tt.tool.Name)
+			}
+
+			if !strings.Contains(tt.tool.Description, tt.shortDesc) {
+				t.Errorf("description %q should contain %q", tt.tool.Description, tt.shortDesc)
+			}
+
+			// Verify schema has required properties
+			if len(tt.requiredProps) > 0 {
+				var schema struct {
+					Required []string `json:"required"`
+				}
+				if err := json.Unmarshal(tt.tool.InputSchema, &schema); err != nil {
+					t.Fatalf("failed to unmarshal schema: %v", err)
+				}
+
+				for _, prop := range tt.requiredProps {
+					if !slices.Contains(schema.Required, prop) {
+						t.Errorf("property %q should be required", prop)
+					}
+				}
+			}
+		})
+	}
+}
+
+func TestGetTools(t *testing.T) {
+	// Create browser tools instance
+	tools := NewBrowseTools(context.Background())
+	t.Cleanup(func() {
+		tools.Close()
+	})
+
+	// Test with screenshot tools included
+	t.Run("with screenshots", func(t *testing.T) {
+		toolsWithScreenshots := tools.GetTools(true)
+		if len(toolsWithScreenshots) != 7 {
+			t.Errorf("expected 7 tools with screenshots, got %d", len(toolsWithScreenshots))
+		}
+
+		// Check tool naming convention
+		for _, tool := range toolsWithScreenshots {
+			// Most tools have browser_ prefix, except for read_image
+			if tool.Name != "read_image" && !strings.HasPrefix(tool.Name, "browser_") {
+				t.Errorf("tool name %q does not have prefix 'browser_'", tool.Name)
+			}
+		}
+	})
+
+	// Test without screenshot tools
+	t.Run("without screenshots", func(t *testing.T) {
+		noScreenshotTools := tools.GetTools(false)
+		if len(noScreenshotTools) != 5 {
+			t.Errorf("expected 5 tools without screenshots, got %d", len(noScreenshotTools))
+		}
+	})
+}
+
+// TestBrowserInitialization verifies that the browser can start correctly
+func TestBrowserInitialization(t *testing.T) {
+	// Skip long tests in short mode
+	if testing.Short() {
+		t.Skip("skipping browser initialization test in short mode")
+	}
+
+	// Create browser tools instance
+	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+	defer cancel()
+
+	tools := NewBrowseTools(ctx)
+	t.Cleanup(func() {
+		tools.Close()
+	})
+
+	// Get browser context (this initializes the browser)
+	browserCtx, err := tools.GetBrowserContext()
+	if err != nil {
+		if strings.Contains(err.Error(), "failed to start browser") {
+			t.Skip("Browser automation not available in this environment")
+		}
+		t.Fatalf("Failed to get browser context: %v", err)
+	}
+
+	// Try to navigate to a simple page
+	var title string
+	err = chromedp.Run(browserCtx,
+		chromedp.Navigate("about:blank"),
+		chromedp.Title(&title),
+	)
+	if err != nil {
+		t.Fatalf("Failed to navigate to about:blank: %v", err)
+	}
+
+	t.Logf("Successfully navigated to about:blank, title: %q", title)
+}
+
+// TestNavigateTool verifies that the navigate tool works correctly
+func TestNavigateTool(t *testing.T) {
+	// Skip long tests in short mode
+	if testing.Short() {
+		t.Skip("skipping navigate tool test in short mode")
+	}
+
+	// Create browser tools instance
+	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+	defer cancel()
+
+	tools := NewBrowseTools(ctx)
+	t.Cleanup(func() {
+		tools.Close()
+	})
+
+	// Get the navigate tool
+	navTool := tools.NewNavigateTool()
+
+	// Create input for the navigate tool
+	input := map[string]string{"url": "https://example.com"}
+	inputJSON, _ := json.Marshal(input)
+
+	// Call the tool
+	toolOut := navTool.Run(ctx, json.RawMessage(inputJSON))
+	if toolOut.Error != nil {
+		t.Fatalf("Error running navigate tool: %v", toolOut.Error)
+	}
+	result := toolOut.LLMContent
+
+	// Verify the response is successful
+	resultText := result[0].Text
+	if !strings.Contains(resultText, "done") {
+		// If browser automation is not available, skip the test
+		if strings.Contains(resultText, "browser automation not available") {
+			t.Skip("Browser automation not available in this environment")
+		} else {
+			t.Fatalf("Expected done in result text, got: %s", resultText)
+		}
+	}
+
+	// Try to get the page title to verify the navigation worked
+	browserCtx, err := tools.GetBrowserContext()
+	if err != nil {
+		// If browser automation is not available, skip the test
+		if strings.Contains(err.Error(), "browser automation not available") {
+			t.Skip("Browser automation not available in this environment")
+		} else {
+			t.Fatalf("Failed to get browser context: %v", err)
+		}
+	}
+
+	var title string
+	err = chromedp.Run(browserCtx, chromedp.Title(&title))
+	if err != nil {
+		t.Fatalf("Failed to get page title: %v", err)
+	}
+
+	t.Logf("Successfully navigated to example.com, title: %q", title)
+	if title != "Example Domain" {
+		t.Errorf("Expected title 'Example Domain', got '%s'", title)
+	}
+}
+
+// TestScreenshotTool tests that the screenshot tool properly saves files
+func TestScreenshotTool(t *testing.T) {
+	// Create browser tools instance
+	ctx := context.Background()
+	tools := NewBrowseTools(ctx)
+	t.Cleanup(func() {
+		tools.Close()
+	})
+
+	// Test SaveScreenshot function directly
+	testData := []byte("test image data")
+	id := tools.SaveScreenshot(testData)
+	if id == "" {
+		t.Fatal("SaveScreenshot returned empty ID")
+	}
+
+	// Get the file path and check if the file exists
+	filePath := GetScreenshotPath(id)
+	_, err := os.Stat(filePath)
+	if err != nil {
+		t.Fatalf("Failed to find screenshot file: %v", err)
+	}
+
+	// Read the file contents
+	contents, err := os.ReadFile(filePath)
+	if err != nil {
+		t.Fatalf("Failed to read screenshot file: %v", err)
+	}
+
+	// Check the file contents
+	if string(contents) != string(testData) {
+		t.Errorf("File contents don't match: expected %q, got %q", string(testData), string(contents))
+	}
+
+	// Clean up the test file
+	os.Remove(filePath)
+}
+
+func TestReadImageTool(t *testing.T) {
+	// Create a test BrowseTools instance
+	ctx := context.Background()
+	browseTools := NewBrowseTools(ctx)
+	t.Cleanup(func() {
+		browseTools.Close()
+	})
+
+	// Create a test image
+	testDir := t.TempDir()
+	testImagePath := filepath.Join(testDir, "test_image.png")
+
+	// Create a small 1x1 black PNG image
+	smallPng := []byte{
+		0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
+		0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
+		0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0x60, 0x00, 0x00, 0x00,
+		0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
+		0x42, 0x60, 0x82,
+	}
+
+	// Write the test image
+	err := os.WriteFile(testImagePath, smallPng, 0o644)
+	if err != nil {
+		t.Fatalf("Failed to create test image: %v", err)
+	}
+
+	// Create the tool
+	readImageTool := browseTools.NewReadImageTool()
+
+	// Prepare input
+	input := fmt.Sprintf(`{"path": "%s"}`, testImagePath)
+
+	// Run the tool
+	toolOut := readImageTool.Run(ctx, json.RawMessage(input))
+	if toolOut.Error != nil {
+		t.Fatalf("Read image tool failed: %v", toolOut.Error)
+	}
+	result := toolOut.LLMContent
+
+	// In the updated code, result is already a []llm.Content
+	contents := result
+
+	// Check that we got at least two content objects
+	if len(contents) < 2 {
+		t.Fatalf("Expected at least 2 content objects, got %d", len(contents))
+	}
+
+	// Check that the second content has image data
+	if contents[1].MediaType == "" {
+		t.Errorf("Expected MediaType in second content")
+	}
+
+	if contents[1].Data == "" {
+		t.Errorf("Expected Data in second content")
+	}
+}
+
+// TestDefaultViewportSize verifies that the browser starts with the correct default viewport size
+func TestDefaultViewportSize(t *testing.T) {
+	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+	defer cancel()
+
+	// Skip if CI or headless testing environment
+	if os.Getenv("CI") != "" || os.Getenv("HEADLESS_TEST") != "" {
+		t.Skip("Skipping browser test in CI/headless environment")
+	}
+
+	tools := NewBrowseTools(ctx)
+	t.Cleanup(func() {
+		tools.Close()
+	})
+
+	// Navigate to a simple page to ensure the browser is ready
+	navInput := json.RawMessage(`{"url": "about:blank"}`)
+	toolOut := tools.NewNavigateTool().Run(ctx, navInput)
+	if toolOut.Error != nil {
+		if strings.Contains(toolOut.Error.Error(), "browser automation not available") {
+			t.Skip("Browser automation not available in this environment")
+		}
+		t.Fatalf("Navigation error: %v", toolOut.Error)
+	}
+	content := toolOut.LLMContent
+	if !strings.Contains(content[0].Text, "done") {
+		t.Fatalf("Expected done in navigation response, got: %s", content[0].Text)
+	}
+
+	// Check default viewport dimensions via JavaScript
+	evalInput := json.RawMessage(`{"expression": "({width: window.innerWidth, height: window.innerHeight})"}`)
+	toolOut = tools.NewEvalTool().Run(ctx, evalInput)
+	if toolOut.Error != nil {
+		t.Fatalf("Evaluation error: %v", toolOut.Error)
+	}
+	content = toolOut.LLMContent
+
+	// Parse the result to verify dimensions
+	var response struct {
+		Width  float64 `json:"width"`
+		Height float64 `json:"height"`
+	}
+
+	text := content[0].Text
+	text = strings.TrimPrefix(text, "<javascript_result>")
+	text = strings.TrimSuffix(text, "</javascript_result>")
+
+	if err := json.Unmarshal([]byte(text), &response); err != nil {
+		t.Fatalf("Failed to parse evaluation response (%q => %q): %v", content[0].Text, text, err)
+	}
+
+	// Verify the default viewport size is 1280x720
+	expectedWidth := 1280.0
+	expectedHeight := 720.0
+
+	if response.Width != expectedWidth {
+		t.Errorf("Expected default width %v, got %v", expectedWidth, response.Width)
+	}
+	if response.Height != expectedHeight {
+		t.Errorf("Expected default height %v, got %v", expectedHeight, response.Height)
+	}
+}
+
+// TestBrowserIdleShutdownAndRestart verifies the browser shuts down after idle and can restart
+func TestBrowserIdleShutdownAndRestart(t *testing.T) {
+	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+	defer cancel()
+
+	// Use a short idle timeout for testing
+	idleTimeout := 100 * time.Millisecond
+	tools := NewBrowseToolsWithIdleTimeout(ctx, idleTimeout)
+	t.Cleanup(func() {
+		tools.Close()
+	})
+
+	// First use - should start the browser
+	browserCtx1, err := tools.GetBrowserContext()
+	if err != nil {
+		if strings.Contains(err.Error(), "failed to start browser") {
+			t.Skip("Browser automation not available in this environment")
+		}
+		t.Fatalf("Failed to get browser context: %v", err)
+	}
+	if browserCtx1 == nil {
+		t.Fatal("Expected non-nil browser context")
+	}
+
+	// Wait for idle timeout to fire
+	time.Sleep(idleTimeout + 50*time.Millisecond)
+
+	// Second use - should start a new browser (old one was killed)
+	browserCtx2, err := tools.GetBrowserContext()
+	if err != nil {
+		t.Fatalf("Failed to get browser context after idle: %v", err)
+	}
+	if browserCtx2 == nil {
+		t.Fatal("Expected non-nil browser context after restart")
+	}
+
+	// The contexts should be different (new browser instance)
+	if browserCtx1 == browserCtx2 {
+		t.Error("Expected different browser context after idle shutdown")
+	}
+
+	// Verify the new browser actually works
+	navTool := tools.NewNavigateTool()
+	input := json.RawMessage(`{"url": "about:blank"}`)
+	toolOut := navTool.Run(ctx, input)
+	if toolOut.Error != nil {
+		t.Fatalf("Navigate failed after restart: %v", toolOut.Error)
+	}
+}

claudetool/browse/register.go πŸ”—

@@ -0,0 +1,21 @@
+package browse
+
+import (
+	"context"
+
+	"shelley.exe.dev/llm"
+)
+
+// RegisterBrowserTools returns all browser tools ready to be added to an agent.
+// It also returns a cleanup function that should be called when done to properly close the browser.
+// The browser will be initialized lazily when a browser tool is first used.
+func RegisterBrowserTools(ctx context.Context, supportsScreenshots bool) ([]*llm.Tool, func()) {
+	browserTools := NewBrowseTools(ctx)
+
+	return browserTools.GetTools(supportsScreenshots), func() {
+		browserTools.Close()
+	}
+}
+
+// Tool is an alias for llm.Tool to make the documentation clearer
+type Tool = llm.Tool

claudetool/changedir.go πŸ”—

@@ -0,0 +1,102 @@
+package claudetool
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"shelley.exe.dev/llm"
+)
+
+// ChangeDirTool changes the working directory for bash commands.
+type ChangeDirTool struct {
+	// WorkingDir is the shared mutable working directory.
+	WorkingDir *MutableWorkingDir
+	// OnChange is called after the working directory changes successfully.
+	// This can be used to persist the change to a database.
+	OnChange func(newDir string)
+}
+
+const (
+	changeDirName        = "change_dir"
+	changeDirDescription = `Change the working directory for subsequent bash commands.
+
+This affects the working directory used by the bash tool. The directory must exist.
+Relative paths are resolved against the current working directory.
+
+Use this to navigate the filesystem persistently across bash commands,
+rather than using 'cd' within each bash command (which doesn't persist).
+`
+	changeDirInputSchema = `{
+  "type": "object",
+  "required": ["path"],
+  "properties": {
+    "path": {
+      "type": "string",
+      "description": "The directory path to change to (absolute or relative)"
+    }
+  }
+}`
+)
+
+type changeDirInput struct {
+	Path string `json:"path"`
+}
+
+// Tool returns an llm.Tool for changing directories.
+func (c *ChangeDirTool) Tool() *llm.Tool {
+	return &llm.Tool{
+		Name:        changeDirName,
+		Description: changeDirDescription,
+		InputSchema: llm.MustSchema(changeDirInputSchema),
+		Run:         c.Run,
+	}
+}
+
+// Run executes the change_dir tool.
+func (c *ChangeDirTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
+	var req changeDirInput
+	if err := json.Unmarshal(m, &req); err != nil {
+		return llm.ErrorfToolOut("failed to parse change_dir input: %w", err)
+	}
+
+	if req.Path == "" {
+		return llm.ErrorfToolOut("path is required")
+	}
+
+	// Get current working directory
+	currentWD := c.WorkingDir.Get()
+
+	// Resolve the path
+	targetPath := req.Path
+	if !filepath.IsAbs(targetPath) {
+		targetPath = filepath.Join(currentWD, targetPath)
+	}
+	targetPath = filepath.Clean(targetPath)
+
+	// Validate the directory exists
+	info, err := os.Stat(targetPath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return llm.ErrorfToolOut("directory does not exist: %s", targetPath)
+		}
+		return llm.ErrorfToolOut("failed to stat path: %w", err)
+	}
+	if !info.IsDir() {
+		return llm.ErrorfToolOut("path is not a directory: %s", targetPath)
+	}
+
+	// Update the working directory
+	c.WorkingDir.Set(targetPath)
+
+	// Notify callback if set
+	if c.OnChange != nil {
+		c.OnChange(targetPath)
+	}
+
+	return llm.ToolOut{
+		LLMContent: llm.TextContent(fmt.Sprintf("Changed working directory to: %s", targetPath)),
+	}
+}

claudetool/changedir_test.go πŸ”—

@@ -0,0 +1,215 @@
+package claudetool
+
+import (
+	"context"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestChangeDirTool(t *testing.T) {
+	// Create a temp directory structure
+	tmpDir := t.TempDir()
+	subDir := filepath.Join(tmpDir, "subdir")
+	if err := os.Mkdir(subDir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+
+	wd := NewMutableWorkingDir(tmpDir)
+	tool := &ChangeDirTool{WorkingDir: wd}
+
+	t.Run("change to absolute path", func(t *testing.T) {
+		// Reset
+		wd.Set(tmpDir)
+
+		input, _ := json.Marshal(changeDirInput{Path: subDir})
+		result := tool.Run(context.Background(), input)
+
+		if result.Error != nil {
+			t.Fatalf("unexpected error: %v", result.Error)
+		}
+
+		if wd.Get() != subDir {
+			t.Errorf("expected working dir %q, got %q", subDir, wd.Get())
+		}
+	})
+
+	t.Run("change to relative path", func(t *testing.T) {
+		// Reset
+		wd.Set(tmpDir)
+
+		input, _ := json.Marshal(changeDirInput{Path: "subdir"})
+		result := tool.Run(context.Background(), input)
+
+		if result.Error != nil {
+			t.Fatalf("unexpected error: %v", result.Error)
+		}
+
+		if wd.Get() != subDir {
+			t.Errorf("expected working dir %q, got %q", subDir, wd.Get())
+		}
+	})
+
+	t.Run("change to parent directory", func(t *testing.T) {
+		wd.Set(subDir)
+
+		input, _ := json.Marshal(changeDirInput{Path: ".."})
+		result := tool.Run(context.Background(), input)
+
+		if result.Error != nil {
+			t.Fatalf("unexpected error: %v", result.Error)
+		}
+
+		if wd.Get() != tmpDir {
+			t.Errorf("expected working dir %q, got %q", tmpDir, wd.Get())
+		}
+	})
+
+	t.Run("error on non-existent path", func(t *testing.T) {
+		wd.Set(tmpDir)
+
+		input, _ := json.Marshal(changeDirInput{Path: "/nonexistent/path"})
+		result := tool.Run(context.Background(), input)
+
+		if result.Error == nil {
+			t.Fatal("expected error for non-existent path")
+		}
+	})
+
+	t.Run("error on file path", func(t *testing.T) {
+		// Create a file
+		filePath := filepath.Join(tmpDir, "file.txt")
+		if err := os.WriteFile(filePath, []byte("test"), 0o644); err != nil {
+			t.Fatal(err)
+		}
+
+		wd.Set(tmpDir)
+
+		input, _ := json.Marshal(changeDirInput{Path: filePath})
+		result := tool.Run(context.Background(), input)
+
+		if result.Error == nil {
+			t.Fatal("expected error for file path")
+		}
+	})
+
+	t.Run("OnChange callback is called", func(t *testing.T) {
+		wd.Set(tmpDir)
+
+		var callbackDir string
+		toolWithCallback := &ChangeDirTool{
+			WorkingDir: wd,
+			OnChange: func(newDir string) {
+				callbackDir = newDir
+			},
+		}
+
+		input, _ := json.Marshal(changeDirInput{Path: subDir})
+		result := toolWithCallback.Run(context.Background(), input)
+
+		if result.Error != nil {
+			t.Fatalf("unexpected error: %v", result.Error)
+		}
+
+		if callbackDir != subDir {
+			t.Errorf("expected callback dir %q, got %q", subDir, callbackDir)
+		}
+	})
+}
+
+func TestChangeDirWithBash(t *testing.T) {
+	// Create a temp directory structure
+	tmpDir := t.TempDir()
+	subDir := filepath.Join(tmpDir, "subdir")
+	if err := os.Mkdir(subDir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+
+	// Create a file in subdir
+	testFile := filepath.Join(subDir, "test.txt")
+	if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	wd := NewMutableWorkingDir(tmpDir)
+	changeDirTool := &ChangeDirTool{WorkingDir: wd}
+	bashTool := &BashTool{WorkingDir: wd}
+
+	ctx := context.Background()
+
+	// Run pwd to verify starting directory
+	input, _ := json.Marshal(bashInput{Command: "pwd"})
+	result := bashTool.Run(ctx, input)
+	if result.Error != nil {
+		t.Fatalf("bash pwd failed: %v", result.Error)
+	}
+
+	// Change directory
+	cdInput, _ := json.Marshal(changeDirInput{Path: subDir})
+	result = changeDirTool.Run(ctx, cdInput)
+	if result.Error != nil {
+		t.Fatalf("change_dir failed: %v", result.Error)
+	}
+
+	// Run ls - should now see test.txt
+	result = bashTool.Run(ctx, json.RawMessage(`{"command": "ls"}`))
+	if result.Error != nil {
+		t.Fatalf("bash ls failed: %v", result.Error)
+	}
+
+	// Verify we can see test.txt (indicating we're in subdir)
+	if len(result.LLMContent) == 0 {
+		t.Fatal("expected output from ls")
+	}
+	output := result.LLMContent[0].Text
+	if output == "" {
+		t.Fatal("expected non-empty output from ls")
+	}
+	// The output should contain "test.txt"
+	if !contains(output, "test.txt") {
+		t.Errorf("expected ls output to contain 'test.txt', got: %q", output)
+	}
+}
+
+func contains(s, substr string) bool {
+	for i := 0; i <= len(s)-len(substr); i++ {
+		if s[i:i+len(substr)] == substr {
+			return true
+		}
+	}
+	return false
+}
+
+func TestBashToolMissingWorkingDir(t *testing.T) {
+	// Create a temp directory, then remove it
+	tmpDir := t.TempDir()
+	subDir := filepath.Join(tmpDir, "subdir")
+	if err := os.Mkdir(subDir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+
+	wd := NewMutableWorkingDir(subDir)
+	bashTool := &BashTool{WorkingDir: wd}
+
+	// Remove the directory
+	if err := os.RemoveAll(subDir); err != nil {
+		t.Fatal(err)
+	}
+
+	// Try to run a command - should get a clear error
+	input, _ := json.Marshal(bashInput{Command: "ls"})
+	result := bashTool.Run(context.Background(), input)
+
+	if result.Error == nil {
+		t.Fatal("expected error when working directory doesn't exist")
+	}
+
+	errStr := result.Error.Error()
+	if !contains(errStr, "working directory does not exist") {
+		t.Errorf("expected error about missing working directory, got: %s", errStr)
+	}
+	if !contains(errStr, "change_dir") {
+		t.Errorf("expected error to mention change_dir tool, got: %s", errStr)
+	}
+}

claudetool/editbuf/LICENSE πŸ”—

@@ -0,0 +1,27 @@
+Copyright (c) 2009 The Go Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

claudetool/editbuf/editbuf.go πŸ”—

@@ -0,0 +1,92 @@
+// Modified from rsc.io/edit
+
+// Copyright 2017 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package edit implements buffered position-based editing of byte slices.
+package editbuf
+
+import (
+	"fmt"
+	"sort"
+)
+
+// A Buffer is a queue of edits to apply to a given byte slice.
+type Buffer struct {
+	old []byte
+	q   edits
+}
+
+// An edit records a single text modification: change the bytes in [start,end) to new.
+type edit struct {
+	start int
+	end   int
+	new   string
+}
+
+// An edits is a list of edits that is sortable by start offset, breaking ties by end offset.
+type edits []edit
+
+func (x edits) Len() int      { return len(x) }
+func (x edits) Swap(i, j int) { x[i], x[j] = x[j], x[i] }
+func (x edits) Less(i, j int) bool {
+	if x[i].start != x[j].start {
+		return x[i].start < x[j].start
+	}
+	return x[i].end < x[j].end
+}
+
+// NewBuffer returns a new buffer to accumulate changes to an initial data slice.
+// The returned buffer maintains a reference to the data, so the caller must ensure
+// the data is not modified until after the Buffer is done being used.
+func NewBuffer(old []byte) *Buffer {
+	return &Buffer{old: old}
+}
+
+// Insert inserts the new string at old[pos:pos].
+func (b *Buffer) Insert(pos int, new string) {
+	if pos < 0 || pos > len(b.old) {
+		panic("invalid edit position")
+	}
+	b.q = append(b.q, edit{pos, pos, new})
+}
+
+// Delete deletes the text old[start:end].
+func (b *Buffer) Delete(start, end int) {
+	if end < start || start < 0 || end > len(b.old) {
+		panic("invalid edit position")
+	}
+	b.q = append(b.q, edit{start, end, ""})
+}
+
+// Replace replaces old[start:end] with new.
+func (b *Buffer) Replace(start, end int, new string) {
+	if end < start || start < 0 || end > len(b.old) {
+		panic("invalid edit position")
+	}
+	b.q = append(b.q, edit{start, end, new})
+}
+
+// Bytes returns a new byte slice containing the original data
+// with the queued edits applied.
+func (b *Buffer) Bytes() ([]byte, error) {
+	// Sort edits by starting position and then by ending position.
+	// Breaking ties by ending position allows insertions at point x
+	// to be applied before a replacement of the text at [x, y).
+	sort.Stable(b.q)
+
+	var new []byte
+	offset := 0
+	for i, e := range b.q {
+		if e.start < offset {
+			e0 := b.q[i-1]
+			return nil, fmt.Errorf("overlapping edits: [%d,%d)->%q, [%d,%d)->%q", e0.start, e0.end, e0.new, e.start, e.end, e.new)
+		}
+		new = append(new, b.old[offset:e.start]...)
+		offset = e.end
+		new = append(new, e.new...)
+	}
+	new = append(new, b.old[offset:]...)
+	return new, nil
+}

claudetool/keyword.go πŸ”—

@@ -0,0 +1,236 @@
+package claudetool
+
+import (
+	"context"
+	_ "embed"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"os/exec"
+	"strings"
+
+	"shelley.exe.dev/llm"
+)
+
+// LLMServiceProvider defines the interface for getting LLM services
+type LLMServiceProvider interface {
+	GetService(modelID string) (llm.Service, error)
+	GetAvailableModels() []string
+}
+
+// KeywordTool provides keyword search functionality
+type KeywordTool struct {
+	llmProvider LLMServiceProvider
+	workingDir  *MutableWorkingDir
+}
+
+// NewKeywordTool creates a new keyword tool with the given LLM provider
+func NewKeywordTool(provider LLMServiceProvider) *KeywordTool {
+	return &KeywordTool{llmProvider: provider}
+}
+
+// NewKeywordToolWithWorkingDir creates a new keyword tool with the given LLM provider and shared working directory
+func NewKeywordToolWithWorkingDir(provider LLMServiceProvider, wd *MutableWorkingDir) *KeywordTool {
+	return &KeywordTool{llmProvider: provider, workingDir: wd}
+}
+
+// Tool returns the LLM tool definition
+func (k *KeywordTool) Tool() *llm.Tool {
+	return &llm.Tool{
+		Name:        keywordName,
+		Description: keywordDescription,
+		InputSchema: llm.MustSchema(keywordInputSchema),
+		Run:         k.keywordRun,
+	}
+}
+
+const (
+	keywordName        = "keyword_search"
+	keywordDescription = `
+keyword_search locates files with a search-and-filter approach.
+Use when navigating unfamiliar codebases with only conceptual understanding or vague user questions.
+
+Effective use:
+- Provide a detailed query for accurate relevance ranking
+- Prefer MANY SPECIFIC terms over FEW GENERAL ones (high precision beats high recall)
+- Order search terms by importance (most important first)
+- Supports regex search terms for flexible matching
+
+IMPORTANT: Do NOT use this tool if you have precise information like log lines, error messages, stack traces, filenames, or symbols. Use direct approaches (rg, cat, etc.) instead.
+`
+
+	// If you modify this, update the termui template for prettier rendering.
+	keywordInputSchema = `
+{
+  "type": "object",
+  "required": [
+    "query",
+    "search_terms"
+  ],
+  "properties": {
+    "query": {
+      "type": "string",
+      "description": "A detailed statement of what you're trying to find or learn."
+    },
+    "search_terms": {
+      "type": "array",
+      "items": {
+        "type": "string"
+      },
+      "description": "List of search terms in descending order of importance."
+    }
+  }
+}
+`
+)
+
+type keywordInput struct {
+	Query       string   `json:"query"`
+	SearchTerms []string `json:"search_terms"`
+}
+
+//go:embed keyword_system_prompt.txt
+var keywordSystemPrompt string
+
+// FindRepoRoot attempts to find the git repository root from the current directory
+func FindRepoRoot(wd string) (string, error) {
+	cmd := exec.Command("git", "rev-parse", "--show-toplevel")
+	cmd.Dir = wd
+	out, err := cmd.Output()
+	// todo: cwd here and throughout
+	if err != nil {
+		return "", fmt.Errorf("failed to find git repository root: %w", err)
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
+// keywordRun is the main implementation using the LLM provider
+func (k *KeywordTool) keywordRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+	var input keywordInput
+	if err := json.Unmarshal(m, &input); err != nil {
+		return llm.ErrorToolOut(err)
+	}
+	wd := k.workingDir.Get()
+	root, err := FindRepoRoot(wd)
+	if err == nil {
+		wd = root
+	}
+	slog.InfoContext(ctx, "keyword search input", "query", input.Query, "keywords", input.SearchTerms, "wd", wd)
+
+	// first remove stopwords
+	var keep []string
+	for _, term := range input.SearchTerms {
+		out, err := ripgrep(ctx, wd, []string{term})
+		if err != nil {
+			return llm.ErrorToolOut(err)
+		}
+		if len(out) > 64*1024 {
+			slog.InfoContext(ctx, "keyword search result too large", "term", term, "bytes", len(out))
+			continue
+		}
+		keep = append(keep, term)
+	}
+
+	if len(keep) == 0 {
+		return llm.ToolOut{LLMContent: llm.TextContent("each of those search terms yielded too many results")}
+	}
+
+	// peel off keywords until we get a result that fits in the query window
+	var out string
+	for {
+		var err error
+		out, err = ripgrep(ctx, wd, keep)
+		if err != nil {
+			return llm.ErrorToolOut(err)
+		}
+		if len(out) < 128*1024 {
+			break
+		}
+		keep = keep[:len(keep)-1]
+	}
+
+	// Select the best available LLM service
+	llmService, err := k.selectBestLLM(k.llmProvider)
+	if err != nil {
+		return llm.ErrorfToolOut("failed to get LLM service: %w", err)
+	}
+
+	// Create the filtering request
+	system := []llm.SystemContent{
+		{Type: "text", Text: strings.TrimSpace(keywordSystemPrompt)},
+	}
+
+	initialMessage := llm.Message{
+		Role: llm.MessageRoleUser,
+		Content: []llm.Content{
+			llm.StringContent("<pwd>\n" + wd + "\n</pwd>"),
+			llm.StringContent("<ripgrep_results>\n" + out + "\n</ripgrep_results>"),
+			llm.StringContent("<query>\n" + input.Query + "\n</query>"),
+		},
+	}
+
+	req := &llm.Request{
+		Messages: []llm.Message{initialMessage},
+		System:   system,
+	}
+
+	resp, err := llmService.Do(ctx, req)
+	if err != nil {
+		return llm.ErrorfToolOut("failed to send relevance filtering message: %w", err)
+	}
+	if len(resp.Content) != 1 {
+		return llm.ErrorfToolOut("unexpected number of messages (%d) in relevance filtering response: %v", len(resp.Content), resp.Content)
+	}
+
+	filtered := resp.Content[0].Text
+
+	slog.InfoContext(ctx, "keyword search results processed",
+		"bytes", len(out),
+		"lines", strings.Count(out, "\n"),
+		"files", strings.Count(out, "\n\n"),
+		"query", input.Query,
+		"filtered", filtered,
+	)
+
+	return llm.ToolOut{LLMContent: llm.TextContent(resp.Content[0].Text)}
+}
+
+func ripgrep(ctx context.Context, wd string, terms []string) (string, error) {
+	args := []string{"-C", "10", "-i", "--line-number", "--with-filename"}
+	for _, term := range terms {
+		args = append(args, "-e", term)
+	}
+	cmd := exec.CommandContext(ctx, "rg", args...)
+	cmd.Dir = wd
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		// ripgrep returns exit code 1 when no matches are found, which is not an error for us
+		if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
+			return "no matches found", nil
+		}
+		return "", fmt.Errorf("search failed: %v\n%s", err, out)
+	}
+	outStr := string(out)
+	return outStr, nil
+}
+
+// selectBestLLM selects the best available LLM service for keyword search
+func (k *KeywordTool) selectBestLLM(provider LLMServiceProvider) (llm.Service, error) {
+	// Preferred models in order of preference for keyword search (fast, cheap models preferred)
+	preferredModels := []string{"qwen3-coder-fireworks", "gpt-5-thinking-mini", "gpt5-mini", "claude-sonnet-4.5", "predictable"}
+
+	for _, model := range preferredModels {
+		svc, err := provider.GetService(model)
+		if err == nil {
+			return svc, nil
+		}
+	}
+
+	// If no preferred model is available, try any available model
+	available := provider.GetAvailableModels()
+	if len(available) > 0 {
+		return provider.GetService(available[0])
+	}
+
+	return nil, fmt.Errorf("no LLM services available")
+}

claudetool/keyword_system_prompt.txt πŸ”—

@@ -0,0 +1,28 @@
+You are a code search relevance evaluator. Your task is to analyze ripgrep results and determine which files are most relevant to the user's query.
+
+INPUT FORMAT:
+- You will receive ripgrep output containing file matches for keywords with 10 lines of context
+- At the end will be "QUERY: <original search query>"
+
+ANALYSIS INSTRUCTIONS:
+1. Examine each file match and its surrounding context
+2. Evaluate relevance to the query based on:
+   - Direct relevance to concepts in the query
+   - Implementation of functionality described in the query
+   - Evidence of patterns or systems related to the query
+3. Exercise strict judgment - only return files that are genuinely relevant
+
+OUTPUT FORMAT:
+Respond with a plain text list of the most relevant files in decreasing order of relevance:
+
+/path/to/most/relevant/file: Concise relevance explanation
+/path/to/second/file: Concise relevance explanation
+...
+
+IMPORTANT:
+- Only include files with meaningful relevance to the query
+- Keep it short, don't blather
+- Do NOT list all files that had keyword matches
+- Focus on quality over quantity
+- If no files are truly relevant, return "No relevant files found"
+- Use absolute file paths

claudetool/onstart/analyze.go πŸ”—

@@ -0,0 +1,228 @@
+// Package onstart provides codebase analysis used to inform the initial system prompt.
+package onstart
+
+import (
+	"bufio"
+	"bytes"
+	"cmp"
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"slices"
+	"strings"
+
+	"golang.org/x/sync/errgroup"
+)
+
+// Codebase contains metadata about the codebase.
+type Codebase struct {
+	// ExtensionCounts tracks the number of files with each extension
+	ExtensionCounts map[string]int
+	// Total number of files analyzed
+	TotalFiles int
+	// BuildFiles contains paths to build and configuration files
+	BuildFiles []string
+	// DocumentationFiles contains paths to documentation files
+	DocumentationFiles []string
+	// GuidanceFiles contains paths to files that provide context and guidance to LLMs
+	GuidanceFiles []string
+	// InjectFiles contains paths to critical guidance files (like DEAR_LLM.md, claude.md, and cursorrules)
+	// that need to be injected into the system prompt for highest visibility
+	InjectFiles []string
+	// InjectFileContents maps paths to file contents for critical inject files
+	// to avoid requiring an extra file read during template rendering
+	InjectFileContents map[string]string
+}
+
+// AnalyzeCodebase walks the codebase and analyzes the paths it finds.
+func AnalyzeCodebase(ctx context.Context, repoPath string) (*Codebase, error) {
+	// TODO: do a filesystem walk instead?
+	// There's a balance: git ls-files skips node_modules etc,
+	// but some guidance files might be locally .gitignored.
+	cmd := exec.Command("git", "ls-files", "-z")
+	cmd.Dir = repoPath
+
+	r, w := io.Pipe() // stream and scan rather than buffer
+	cmd.Stdout = w
+
+	err := cmd.Start()
+	if err != nil {
+		return nil, err
+	}
+
+	extCounts := make(map[string]int)
+	var buildFiles []string
+	var documentationFiles []string
+	var guidanceFiles []string
+	var injectFiles []string
+	injectFileContents := make(map[string]string)
+	var totalFiles int
+
+	eg, _ := errgroup.WithContext(ctx)
+
+	eg.Go(func() error {
+		defer r.Close()
+
+		scanner := bufio.NewScanner(r)
+		scanner.Split(scanZero)
+		for scanner.Scan() {
+			file := scanner.Text()
+			file = strings.TrimSpace(file)
+			if file == "" {
+				continue
+			}
+			totalFiles++
+			ext := strings.ToLower(filepath.Ext(file))
+			ext = cmp.Or(ext, "<no-extension>")
+			extCounts[ext]++
+
+			fileCategory := categorizeFile(file)
+			// fmt.Println(file, "->", fileCategory)
+			switch fileCategory {
+			case "build":
+				buildFiles = append(buildFiles, file)
+			case "documentation":
+				documentationFiles = append(documentationFiles, file)
+			case "guidance":
+				guidanceFiles = append(guidanceFiles, file)
+			case "inject":
+				injectFiles = append(injectFiles, file)
+			}
+		}
+		return scanner.Err()
+	})
+
+	// Wait for the command to complete
+	eg.Go(func() error {
+		err := cmd.Wait()
+		if err != nil {
+			w.CloseWithError(err)
+		} else {
+			w.Close()
+		}
+		return err
+	})
+
+	if err := eg.Wait(); err != nil {
+		return nil, err
+	}
+
+	// Read content of inject files
+	for _, filePath := range injectFiles {
+		absPath := filepath.Join(repoPath, filePath)
+		content, err := os.ReadFile(absPath)
+		if err != nil {
+			fmt.Printf("Warning: Failed to read inject file %s: %v\n", filePath, err)
+			continue
+		}
+		injectFileContents[filePath] = string(content)
+	}
+
+	return &Codebase{
+		ExtensionCounts:    extCounts,
+		TotalFiles:         totalFiles,
+		BuildFiles:         buildFiles,
+		DocumentationFiles: documentationFiles,
+		GuidanceFiles:      guidanceFiles,
+		InjectFiles:        injectFiles,
+		InjectFileContents: injectFileContents,
+	}, nil
+}
+
+// categorizeFile categorizes a file into one of four categories: build, documentation, guidance, or inject.
+// Returns an empty string if the file doesn't belong to any of these categories.
+// categorizeFile categorizes a file into one of four categories: build, documentation, guidance, or inject.
+// Returns an empty string if the file doesn't belong to any of these categories.
+// The path parameter is relative to the repository root as returned by git ls-files.
+func categorizeFile(path string) string {
+	filename := filepath.Base(path)
+	lowerPath := strings.ToLower(path)
+	lowerFilename := strings.ToLower(filename)
+
+	// InjectFiles - critical guidance files that should be injected into the system prompt
+	// These are repository root files only - files directly in the repo root, not in subdirectories
+	// Since git ls-files returns paths relative to repo root, we just need to check for absence of path separators
+	isRepoRootFile := !strings.Contains(path, "/")
+	if isRepoRootFile {
+		if (strings.HasPrefix(lowerFilename, "claude.") && strings.HasSuffix(lowerFilename, ".md")) ||
+			strings.HasPrefix(lowerFilename, "dear_llm") ||
+			(strings.HasPrefix(lowerFilename, "agents.") && strings.HasSuffix(lowerFilename, ".md")) ||
+			strings.Contains(lowerFilename, "cursorrules") {
+			return "inject"
+		}
+	}
+
+	// GitHub Copilot: https://code.visualstudio.com/docs/copilot/copilot-customization
+	if path == ".github/copilot-instructions.md" {
+		return "inject"
+	}
+
+	// BuildFiles - build and configuration files
+	if strings.HasPrefix(lowerFilename, "makefile") ||
+		strings.HasSuffix(lowerPath, ".vscode/tasks.json") {
+		return "build"
+	}
+
+	// DocumentationFiles - general documentation files
+	if strings.HasPrefix(lowerFilename, "readme") ||
+		strings.HasPrefix(lowerFilename, "contributing") {
+		return "documentation"
+	}
+
+	// GuidanceFiles - other files that provide guidance but aren't critical enough to inject
+	// Non-root directory claude.md files, and other guidance files
+	if (strings.HasPrefix(lowerFilename, "claude.") && strings.HasSuffix(lowerFilename, ".md")) ||
+		(strings.HasPrefix(lowerFilename, "agent.") && strings.HasSuffix(lowerFilename, ".md")) {
+		return "guidance"
+	}
+
+	return ""
+}
+
+// TopExtensions returns the top 5 most common file extensions in the codebase
+func (c *Codebase) TopExtensions() []string {
+	type extCount struct {
+		ext   string
+		count int
+	}
+	pairs := make([]extCount, 0, len(c.ExtensionCounts))
+	for ext, count := range c.ExtensionCounts {
+		pairs = append(pairs, extCount{ext, count})
+	}
+
+	// Sort by count (descending), then by extension (ascending)
+	slices.SortFunc(pairs, func(a, b extCount) int {
+		return cmp.Or(
+			-cmp.Compare(a.count, b.count),
+			cmp.Compare(a.ext, b.ext),
+		)
+	})
+
+	const nTop = 5
+	count := min(nTop, len(pairs))
+	result := make([]string, count)
+	for i := range count {
+		result[i] = fmt.Sprintf("%v: %v (%0.0f%%)", pairs[i].ext, pairs[i].count, 100*float64(pairs[i].count)/float64(c.TotalFiles))
+	}
+
+	return result
+}
+
+func scanZero(data []byte, atEOF bool) (advance int, token []byte, err error) {
+	if atEOF && len(data) == 0 {
+		return 0, nil, nil
+	}
+	if i := bytes.IndexByte(data, 0); i >= 0 {
+		// We have a full NUL line.
+		return i + 1, data[0:i], nil
+	}
+	// If we're at EOF, we have a final, non-terminated line. Return it.
+	if atEOF {
+		return len(data), data, nil
+	}
+	// Request more data.
+	return 0, nil, nil
+}

claudetool/onstart/analyze_test.go πŸ”—

@@ -0,0 +1,238 @@
+package onstart
+
+import (
+	"context"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"slices"
+	"testing"
+)
+
+func TestAnalyzeCodebase(t *testing.T) {
+	t.Run("Basic Analysis", func(t *testing.T) {
+		// Test basic functionality with regular ASCII filenames
+		codebase, err := AnalyzeCodebase(context.Background(), ".")
+		if err != nil {
+			t.Fatalf("AnalyzeCodebase failed: %v", err)
+		}
+
+		if codebase == nil {
+			t.Fatal("Expected non-nil codebase")
+		}
+
+		if codebase.TotalFiles == 0 {
+			t.Error("Expected some files to be analyzed")
+		}
+
+		if len(codebase.ExtensionCounts) == 0 {
+			t.Error("Expected extension counts to be populated")
+		}
+	})
+
+	t.Run("Non-ASCII Filenames", func(t *testing.T) {
+		// Create a temporary directory with unicode filenames for testing
+		tempDir := t.TempDir()
+
+		// Initialize git repository
+		cmd := exec.Command("git", "init")
+		cmd.Dir = tempDir
+		if err := cmd.Run(); err != nil {
+			t.Fatalf("Failed to init git repo: %v", err)
+		}
+
+		cmd = exec.Command("git", "config", "user.name", "Test User")
+		cmd.Dir = tempDir
+		if err := cmd.Run(); err != nil {
+			t.Fatalf("Failed to set git user.name: %v", err)
+		}
+
+		cmd = exec.Command("git", "config", "user.email", "test@example.com")
+		cmd.Dir = tempDir
+		if err := cmd.Run(); err != nil {
+			t.Fatalf("Failed to set git user.email: %v", err)
+		}
+
+		// Configure git to handle unicode filenames properly
+		cmd = exec.Command("git", "config", "core.quotepath", "false")
+		cmd.Dir = tempDir
+		if err := cmd.Run(); err != nil {
+			t.Fatalf("Failed to set git core.quotepath: %v", err)
+		}
+
+		cmd = exec.Command("git", "config", "core.precomposeunicode", "true")
+		cmd.Dir = tempDir
+		if err := cmd.Run(); err != nil {
+			t.Fatalf("Failed to set git core.precomposeunicode: %v", err)
+		}
+
+		// Create test files with unicode characters dynamically
+		testFiles := map[string]string{
+			"ζ΅‹θ―•ζ–‡δ»Ά.go":           "// Package test with Chinese characters in filename\npackage test\n\nfunc TestFunction() {\n\t// This is a test file\n}",
+			"cafΓ©.js":           "// JavaScript file with French characters\nconsole.log('Hello from cafΓ©!');",
+			"русский.py":        "# Python file with Russian characters\nprint('ΠŸΡ€ΠΈΠ²Π΅Ρ‚ ΠΌΠΈΡ€!')",
+			"πŸš€rocket.md":        "# README with Emoji\n\nThis file has an emoji in the filename.",
+			"readme-espaΓ±ol.md": "# Spanish README\n\nEste es un archivo de documentaciΓ³n.",
+			"Übung.html":        "<!DOCTYPE html>\n<html><head><title>German Exercise</title></head><body><h1>Übung</h1></body></html>",
+			"Makefile-ζ—₯本θͺž":      "# Japanese Makefile\nall:\n\techo 'Japanese makefile'",
+		}
+
+		// Create subdirectory
+		subdir := filepath.Join(tempDir, "subdir")
+		err := os.MkdirAll(subdir, 0o755)
+		if err != nil {
+			t.Fatalf("Failed to create subdir: %v", err)
+		}
+
+		// Add file in subdirectory
+		testFiles["subdir/claude.ν•œκ΅­μ–΄.md"] = "# Korean Claude file\n\nThis is a guidance file with Korean characters."
+
+		// Write all test files
+		for filename, content := range testFiles {
+			fullPath := filepath.Join(tempDir, filename)
+			dir := filepath.Dir(fullPath)
+			if dir != tempDir {
+				err := os.MkdirAll(dir, 0o755)
+				if err != nil {
+					t.Fatalf("Failed to create directory %s: %v", dir, err)
+				}
+			}
+			err := os.WriteFile(fullPath, []byte(content), 0o644)
+			if err != nil {
+				t.Fatalf("Failed to write file %s: %v", filename, err)
+			}
+		}
+
+		// Add all files to git at once
+		cmd = exec.Command("git", "add", ".")
+		cmd.Dir = tempDir
+		if err := cmd.Run(); err != nil {
+			t.Fatalf("Failed to add files to git: %v", err)
+		}
+
+		// Test with non-ASCII characters in filenames
+		codebase, err := AnalyzeCodebase(context.Background(), tempDir)
+		if err != nil {
+			t.Fatalf("AnalyzeCodebase failed with non-ASCII filenames: %v", err)
+		}
+
+		if codebase == nil {
+			t.Fatal("Expected non-nil codebase")
+		}
+
+		// We expect 8 files in our temp directory
+		expectedFiles := 8
+		if codebase.TotalFiles != expectedFiles {
+			t.Errorf("Expected %d files, got %d", expectedFiles, codebase.TotalFiles)
+		}
+
+		// Verify extension counts include our non-ASCII files
+		expectedExtensions := map[string]int{
+			".go":            1, // ζ΅‹θ―•ζ–‡δ»Ά.go
+			".js":            1, // cafΓ©.js
+			".py":            1, // русский.py
+			".md":            3, // πŸš€rocket.md, readme-espaΓ±ol.md, claude.ν•œκ΅­μ–΄.md
+			".html":          1, // Übung.html
+			"<no-extension>": 1, // Makefile-ζ—₯本θͺž
+		}
+
+		for ext, expectedCount := range expectedExtensions {
+			actualCount, exists := codebase.ExtensionCounts[ext]
+			if !exists {
+				t.Errorf("Expected extension %s to be found", ext)
+				continue
+			}
+			if actualCount != expectedCount {
+				t.Errorf("Expected %d files with extension %s, got %d", expectedCount, ext, actualCount)
+			}
+		}
+
+		// Verify file categorization works with non-ASCII filenames
+		// Check build files
+		if !slices.Contains(codebase.BuildFiles, "Makefile-ζ—₯本θͺž") {
+			t.Error("Expected Makefile-ζ—₯本θͺž to be categorized as a build file")
+		}
+
+		// Check documentation files
+		if !slices.Contains(codebase.DocumentationFiles, "readme-espaΓ±ol.md") {
+			t.Error("Expected readme-espaΓ±ol.md to be categorized as a documentation file")
+		}
+
+		// Check guidance files
+		if !slices.Contains(codebase.GuidanceFiles, "subdir/claude.ν•œκ΅­μ–΄.md") {
+			t.Error("Expected subdir/claude.ν•œκ΅­μ–΄.md to be categorized as a guidance file")
+		}
+	})
+}
+
+func TestCategorizeFile(t *testing.T) {
+	t.Run("Non-ASCII Filenames", func(t *testing.T) {
+		tests := []struct {
+			name     string
+			path     string
+			expected string
+		}{
+			{"Chinese Go file", "ζ΅‹θ―•ζ–‡δ»Ά.go", ""},
+			{"French JS file", "cafΓ©.js", ""},
+			{"Russian Python file", "русский.py", ""},
+			{"Emoji markdown file", "πŸš€rocket.md", ""},
+			{"German HTML file", "Übung.html", ""},
+			{"Japanese Makefile", "Makefile-ζ—₯本θͺž", "build"},
+			{"Spanish README", "readme-espaΓ±ol.md", "documentation"},
+			{"Korean Claude file", "subdir/claude.ν•œκ΅­μ–΄.md", "guidance"},
+			// Test edge cases with Unicode normalization and combining characters
+			{"Mixed Unicode file", "testδΈ­ζ–‡πŸš€.txt", ""},
+			{"Combining characters", "filé̂.go", ""}, // file with combining acute and circumflex accents
+			{"Right-to-left script", "Ω…Ψ±Ψ­Ψ¨Ψ§.py", ""},  // Arabic "hello"
+		}
+
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				result := categorizeFile(tt.path)
+				if result != tt.expected {
+					t.Errorf("categorizeFile(%q) = %q, want %q", tt.path, result, tt.expected)
+				}
+			})
+		}
+	})
+}
+
+func TestTopExtensions(t *testing.T) {
+	t.Run("With Non-ASCII Files", func(t *testing.T) {
+		// Create a test codebase with known extension counts
+		codebase := &Codebase{
+			ExtensionCounts: map[string]int{
+				".md":   5, // Most common
+				".go":   3,
+				".js":   2,
+				".py":   1,
+				".html": 1, // Least common
+			},
+			TotalFiles: 12,
+		}
+
+		topExt := codebase.TopExtensions()
+		if len(topExt) != 5 {
+			t.Errorf("Expected 5 top extensions, got %d", len(topExt))
+		}
+
+		// Check that extensions are sorted by count (descending)
+		expected := []string{
+			".md: 5 (42%)",
+			".go: 3 (25%)",
+			".js: 2 (17%)",
+			".html: 1 (8%)",
+			".py: 1 (8%)",
+		}
+
+		for i, expectedExt := range expected {
+			if i >= len(topExt) {
+				t.Errorf("Missing expected extension at index %d: %s", i, expectedExt)
+				continue
+			}
+			if topExt[i] != expectedExt {
+				t.Errorf("Expected extension %q at index %d, got %q", expectedExt, i, topExt[i])
+			}
+		}
+	})
+}

claudetool/patch.go πŸ”—

@@ -0,0 +1,626 @@
+package claudetool
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"go/parser"
+	"go/token"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/pkg/diff"
+	"shelley.exe.dev/llm"
+	"sketch.dev/claudetool/editbuf"
+	"sketch.dev/claudetool/patchkit"
+)
+
+// PatchCallback defines the signature for patch tool callbacks.
+// It runs after the patch tool has executed.
+// It receives the patch input and the tool output,
+// and returns a new, possibly altered tool output.
+type PatchCallback func(input PatchInput, output llm.ToolOut) llm.ToolOut
+
+// PatchTool specifies an llm.Tool for patching files.
+// PatchTools are not concurrency-safe.
+type PatchTool struct {
+	Callback PatchCallback // may be nil
+	// WorkingDir is the shared mutable working directory.
+	WorkingDir *MutableWorkingDir
+	// Simplified indicates whether to use the simplified input schema.
+	// Helpful for weaker models.
+	Simplified bool
+	// ClipboardEnabled controls whether clipboard functionality is enabled.
+	// Ignored if Simplified is true.
+	// NB: The actual implementation of the patch tool is unchanged,
+	// this flag merely extends the description and input schema to include the clipboard operations.
+	ClipboardEnabled bool
+	// clipboards stores clipboard name -> text
+	clipboards map[string]string
+}
+
+// getWorkingDir returns the current working directory.
+func (p *PatchTool) getWorkingDir() string {
+	return p.WorkingDir.Get()
+}
+
+// Tool returns an llm.Tool based on p.
+func (p *PatchTool) Tool() *llm.Tool {
+	description := PatchBaseDescription + PatchUsageNotes
+	schema := PatchStandardInputSchema
+	switch {
+	case p.Simplified:
+		schema = PatchStandardSimplifiedSchema
+	case p.ClipboardEnabled:
+		description = PatchBaseDescription + PatchClipboardDescription + PatchUsageNotes
+		schema = PatchClipboardInputSchema
+	}
+	return &llm.Tool{
+		Name:        PatchName,
+		Description: strings.TrimSpace(description),
+		InputSchema: llm.MustSchema(schema),
+		Run:         p.Run,
+	}
+}
+
+const (
+	PatchName            = "patch"
+	PatchBaseDescription = `
+File modification tool for precise text edits.
+
+Operations:
+- replace: Substitute unique text with new content
+- append_eof: Append new text at the end of the file
+- prepend_bof: Insert new text at the beginning of the file
+- overwrite: Replace the entire file with new content (automatically creates the file)
+`
+
+	PatchClipboardDescription = `
+Clipboard:
+- toClipboard: Store oldText to a named clipboard before the operation
+- fromClipboard: Use clipboard content as newText (ignores provided newText)
+- Clipboards persist across patch calls
+- Always use clipboards when moving/copying code (within or across files), even when the moved/copied code will also have edits.
+  This prevents transcription errors and distinguishes intentional changes from unintentional changes.
+
+Indentation adjustment:
+- reindent applies to whatever text is being inserted
+- First strips the specified prefix from each line, then adds the new prefix
+- Useful when moving code from one indentation to another
+
+Recipes:
+- cut: replace with empty newText and toClipboard
+- copy: replace with toClipboard and fromClipboard using the same clipboard name
+- paste: replace with fromClipboard
+- in-place indentation change: same as copy, but add indentation adjustment
+`
+
+	PatchUsageNotes = `
+Usage notes:
+- All inputs are interpreted literally (no automatic newline or whitespace handling)
+- For replace operations, oldText must appear EXACTLY ONCE in the file
+`
+
+	// If you modify this, update the termui template for prettier rendering.
+	PatchStandardInputSchema = `
+{
+  "type": "object",
+  "required": ["path", "patches"],
+  "properties": {
+    "path": {
+      "type": "string",
+      "description": "Path to the file to patch"
+    },
+    "patches": {
+      "type": "array",
+      "description": "List of patch requests to apply",
+      "items": {
+        "type": "object",
+        "required": ["operation", "newText"],
+        "properties": {
+          "operation": {
+            "type": "string",
+            "enum": ["replace", "append_eof", "prepend_bof", "overwrite"],
+            "description": "Type of operation to perform"
+          },
+          "oldText": {
+            "type": "string",
+            "description": "Text to locate for the operation (must be unique in file, required for replace)"
+          },
+          "newText": {
+            "type": "string",
+            "description": "The new text to use (empty for deletions)"
+          }
+        }
+      }
+    }
+  }
+}
+`
+
+	PatchStandardSimplifiedSchema = `{
+  "type": "object",
+  "required": ["path", "patch"],
+  "properties": {
+    "path": {
+      "type": "string",
+      "description": "Path to the file to patch"
+    },
+    "patch": {
+      "type": "object",
+      "required": ["operation", "newText"],
+      "properties": {
+        "operation": {
+          "type": "string",
+          "enum": ["replace", "append_eof", "prepend_bof", "overwrite"],
+          "description": "Type of operation to perform"
+        },
+        "oldText": {
+          "type": "string",
+          "description": "Text to locate for the operation (must be unique in file, required for replace)"
+        },
+        "newText": {
+          "type": "string",
+          "description": "The new text to use (empty for deletions)"
+        }
+      }
+    }
+  }
+}`
+
+	PatchClipboardInputSchema = `
+{
+  "type": "object",
+  "required": ["path", "patches"],
+  "properties": {
+    "path": {
+      "type": "string",
+      "description": "Path to the file to patch"
+    },
+    "patches": {
+      "type": "array",
+      "description": "List of patch requests to apply",
+      "items": {
+        "type": "object",
+        "required": ["operation"],
+        "properties": {
+          "operation": {
+            "type": "string",
+            "enum": ["replace", "append_eof", "prepend_bof", "overwrite"],
+            "description": "Type of operation to perform"
+          },
+          "oldText": {
+            "type": "string",
+            "description": "Text to locate (must be unique in file, required for replace)"
+          },
+          "newText": {
+            "type": "string",
+            "description": "The new text to use (empty for deletions, leave empty if fromClipboard is set)"
+          },
+          "toClipboard": {
+            "type": "string",
+            "description": "Save oldText to this named clipboard before the operation"
+          },
+          "fromClipboard": {
+            "type": "string",
+            "description": "Use content from this clipboard as newText (overrides newText field)"
+          },
+          "reindent": {
+            "type": "object",
+            "description": "Modify indentation of the inserted text (newText or fromClipboard) before insertion",
+            "properties": {
+              "strip": {
+                "type": "string",
+                "description": "Remove this prefix from each non-empty line before insertion"
+              },
+              "add": {
+                "type": "string",
+                "description": "Add this prefix to each non-empty line after stripping"
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
+`
+)
+
+// TODO: maybe rename PatchRequest to PatchOperation or PatchSpec or PatchPart or just Patch?
+
+// PatchInput represents the input structure for patch operations.
+type PatchInput struct {
+	Path    string         `json:"path"`
+	Patches []PatchRequest `json:"patches"`
+}
+
+// PatchInputOne is a simplified version of PatchInput for single patch operations.
+type PatchInputOne struct {
+	Path    string        `json:"path"`
+	Patches *PatchRequest `json:"patches"`
+}
+
+// PatchInputOneSingular is PatchInputOne with a better name for the singular case.
+type PatchInputOneSingular struct {
+	Path  string        `json:"path"`
+	Patch *PatchRequest `json:"patch"`
+}
+
+type PatchInputOneString struct {
+	Path    string `json:"path"`
+	Patches string `json:"patches"` // contains Patches as a JSON string 🀦
+}
+
+// PatchRequest represents a single patch operation.
+type PatchRequest struct {
+	Operation     string    `json:"operation"`
+	OldText       string    `json:"oldText,omitempty"`
+	NewText       string    `json:"newText,omitempty"`
+	ToClipboard   string    `json:"toClipboard,omitempty"`
+	FromClipboard string    `json:"fromClipboard,omitempty"`
+	Reindent      *Reindent `json:"reindent,omitempty"`
+}
+
+// Reindent represents indentation adjustment configuration.
+type Reindent struct {
+	// TODO: it might be nice to make this more flexible,
+	// so it can e.g. strip all whitespace,
+	// or strip the prefix only on lines where it is present,
+	// or strip based on a regex.
+	Strip string `json:"strip,omitempty"`
+	Add   string `json:"add,omitempty"`
+}
+
+// Run implements the patch tool logic.
+func (p *PatchTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
+	if p.clipboards == nil {
+		p.clipboards = make(map[string]string)
+	}
+	input, err := p.patchParse(m)
+	var output llm.ToolOut
+	if err != nil {
+		output = llm.ErrorToolOut(err)
+	} else {
+		output = p.patchRun(ctx, &input)
+	}
+	if p.Callback != nil {
+		return p.Callback(input, output)
+	}
+	return output
+}
+
+// patchParse parses the input message into a PatchInput structure.
+// It accepts a few different formats, because empirically,
+// LLMs sometimes generate slightly different JSON structures,
+// and we may as well accept such near misses.
+func (p *PatchTool) patchParse(m json.RawMessage) (PatchInput, error) {
+	var input PatchInput
+	originalErr := json.Unmarshal(m, &input)
+	if originalErr == nil && len(input.Patches) > 0 {
+		return input, nil
+	}
+	var inputOne PatchInputOne
+	if err := json.Unmarshal(m, &inputOne); err == nil && inputOne.Patches != nil {
+		return PatchInput{Path: inputOne.Path, Patches: []PatchRequest{*inputOne.Patches}}, nil
+	} else if originalErr == nil {
+		originalErr = err
+	}
+	var inputOneSingular PatchInputOneSingular
+	if err := json.Unmarshal(m, &inputOneSingular); err == nil && inputOneSingular.Patch != nil {
+		return PatchInput{Path: inputOneSingular.Path, Patches: []PatchRequest{*inputOneSingular.Patch}}, nil
+	} else if originalErr == nil {
+		originalErr = err
+	}
+	var inputOneString PatchInputOneString
+	if err := json.Unmarshal(m, &inputOneString); err == nil {
+		var onePatch PatchRequest
+		if err := json.Unmarshal([]byte(inputOneString.Patches), &onePatch); err == nil && onePatch.Operation != "" {
+			return PatchInput{Path: inputOneString.Path, Patches: []PatchRequest{onePatch}}, nil
+		} else if originalErr == nil {
+			originalErr = err
+		}
+		var patches []PatchRequest
+		if err := json.Unmarshal([]byte(inputOneString.Patches), &patches); err == nil {
+			return PatchInput{Path: inputOneString.Path, Patches: patches}, nil
+		} else if originalErr == nil {
+			originalErr = err
+		}
+	}
+	return PatchInput{}, fmt.Errorf("failed to unmarshal patch input: %w\nJSON: %s", originalErr, string(m))
+}
+
+// patchRun implements the guts of the patch tool.
+// It populates input from m.
+func (p *PatchTool) patchRun(ctx context.Context, input *PatchInput) llm.ToolOut {
+	path := input.Path
+	if !filepath.IsAbs(input.Path) {
+		// Use shared WorkingDir if available, then context, then Pwd fallback
+		pwd := p.getWorkingDir()
+		path = filepath.Join(pwd, input.Path)
+	}
+	input.Path = path
+	if len(input.Patches) == 0 {
+		return llm.ErrorToolOut(fmt.Errorf("no patches provided"))
+	}
+	// TODO: check whether the file is autogenerated, and if so, require a "force" flag to modify it.
+
+	orig, err := os.ReadFile(input.Path)
+	// If the file doesn't exist, we can still apply patches
+	// that don't require finding existing text.
+	switch {
+	case errors.Is(err, os.ErrNotExist):
+		for _, patch := range input.Patches {
+			switch patch.Operation {
+			case "prepend_bof", "append_eof", "overwrite":
+			default:
+				return llm.ErrorfToolOut("file %q does not exist", input.Path)
+			}
+		}
+	case err != nil:
+		return llm.ErrorfToolOut("failed to read file %q: %w", input.Path, err)
+	}
+
+	likelyGoFile := strings.HasSuffix(input.Path, ".go")
+
+	autogenerated := likelyGoFile && IsAutogeneratedGoFile(orig)
+
+	origStr := string(orig)
+	// Process the patches "simultaneously", minimizing them along the way.
+	// Claude generates patches that interact with each other.
+	buf := editbuf.NewBuffer(orig)
+
+	// TODO: is it better to apply the patches that apply cleanly and report on the failures?
+	// or instead have it be all-or-nothing?
+	// For now, it is all-or-nothing.
+	// TODO: when the model gets into a "cannot apply patch" cycle of doom, how do we get it unstuck?
+	// Also: how do we detect that it's in a cycle?
+	var patchErr error
+
+	var clipboardsModified []string
+	updateToClipboard := func(patch PatchRequest, spec *patchkit.Spec) {
+		if patch.ToClipboard == "" {
+			return
+		}
+		// Update clipboard with the actual matched text
+		matchedOldText := origStr[spec.Off : spec.Off+spec.Len]
+		p.clipboards[patch.ToClipboard] = matchedOldText
+		clipboardsModified = append(clipboardsModified, fmt.Sprintf(`<clipboard_modified name="%s"><message>clipboard contents altered in order to match uniquely</message><new_contents>%q</new_contents></clipboard_modified>`, patch.ToClipboard, matchedOldText))
+	}
+
+	for i, patch := range input.Patches {
+		// Process toClipboard first, so that copy works
+		if patch.ToClipboard != "" {
+			if patch.Operation != "replace" {
+				return llm.ErrorfToolOut("toClipboard (%s): can only be used with replace operation", patch.ToClipboard)
+			}
+			if patch.OldText == "" {
+				return llm.ErrorfToolOut("toClipboard (%s): oldText cannot be empty when using toClipboard", patch.ToClipboard)
+			}
+			p.clipboards[patch.ToClipboard] = patch.OldText
+		}
+
+		// Handle fromClipboard
+		newText := patch.NewText
+		if patch.FromClipboard != "" {
+			clipboardText, ok := p.clipboards[patch.FromClipboard]
+			if !ok {
+				return llm.ErrorfToolOut("fromClipboard (%s): no clipboard with that name", patch.FromClipboard)
+			}
+			newText = clipboardText
+		}
+
+		// Apply indentation adjustment if specified
+		if patch.Reindent != nil {
+			reindentedText, err := reindent(newText, patch.Reindent)
+			if err != nil {
+				return llm.ErrorfToolOut("reindent(%q -> %q): %w", patch.Reindent.Strip, patch.Reindent.Add, err)
+			}
+			newText = reindentedText
+		}
+
+		switch patch.Operation {
+		case "prepend_bof":
+			buf.Insert(0, newText)
+		case "append_eof":
+			buf.Insert(len(orig), newText)
+		case "overwrite":
+			buf.Replace(0, len(orig), newText)
+		case "replace":
+			if patch.OldText == "" {
+				return llm.ErrorfToolOut("patch %d: oldText cannot be empty for %s operation", i, patch.Operation)
+			}
+
+			// Attempt to apply the patch.
+			spec, count := patchkit.Unique(origStr, patch.OldText, newText)
+			switch count {
+			case 0:
+				// no matches, maybe recoverable, continued below
+			case 1:
+				// exact match, apply
+				slog.DebugContext(ctx, "patch_applied", "method", "unique")
+				spec.ApplyToEditBuf(buf)
+				continue
+			case 2:
+				// multiple matches
+				patchErr = errors.Join(patchErr, fmt.Errorf("old text not unique:\n%s", patch.OldText))
+				continue
+			default:
+				slog.ErrorContext(ctx, "unique returned unexpected count", "count", count)
+				patchErr = errors.Join(patchErr, fmt.Errorf("internal error"))
+				continue
+			}
+
+			// The following recovery mechanisms are heuristic.
+			// They aren't perfect, but they appear safe,
+			// and the cases they cover appear with some regularity.
+
+			// Try adjusting the whitespace prefix.
+			spec, ok := patchkit.UniqueDedent(origStr, patch.OldText, newText)
+			if ok {
+				slog.DebugContext(ctx, "patch_applied", "method", "unique_dedent")
+				spec.ApplyToEditBuf(buf)
+				updateToClipboard(patch, spec)
+				continue
+			}
+
+			// Try ignoring leading/trailing whitespace in a semantically safe way.
+			spec, ok = patchkit.UniqueInValidGo(origStr, patch.OldText, newText)
+			if ok {
+				slog.DebugContext(ctx, "patch_applied", "method", "unique_in_valid_go")
+				spec.ApplyToEditBuf(buf)
+				updateToClipboard(patch, spec)
+				continue
+			}
+
+			// Try ignoring semantically insignificant whitespace.
+			spec, ok = patchkit.UniqueGoTokens(origStr, patch.OldText, newText)
+			if ok {
+				slog.DebugContext(ctx, "patch_applied", "method", "unique_go_tokens")
+				spec.ApplyToEditBuf(buf)
+				updateToClipboard(patch, spec)
+				continue
+			}
+
+			// Try trimming the first line of the patch, if we can do so safely.
+			spec, ok = patchkit.UniqueTrim(origStr, patch.OldText, newText)
+			if ok {
+				slog.DebugContext(ctx, "patch_applied", "method", "unique_trim")
+				spec.ApplyToEditBuf(buf)
+				// Do NOT call updateToClipboard here,
+				// because the trimmed text may vary significantly from the original text.
+				continue
+			}
+
+			// No dice.
+			patchErr = errors.Join(patchErr, fmt.Errorf("old text not found:\n%s", patch.OldText))
+			continue
+		default:
+			return llm.ErrorfToolOut("unrecognized operation %q", patch.Operation)
+		}
+	}
+
+	if patchErr != nil {
+		errorMsg := patchErr.Error()
+		for _, msg := range clipboardsModified {
+			errorMsg += "\n" + msg
+		}
+		return llm.ErrorToolOut(fmt.Errorf("%s", errorMsg))
+	}
+
+	patched, err := buf.Bytes()
+	if err != nil {
+		return llm.ErrorToolOut(err)
+	}
+	if err := os.MkdirAll(filepath.Dir(input.Path), 0o700); err != nil {
+		return llm.ErrorfToolOut("failed to create directory %q: %w", filepath.Dir(input.Path), err)
+	}
+	if err := os.WriteFile(input.Path, patched, 0o600); err != nil {
+		return llm.ErrorfToolOut("failed to write patched contents to file %q: %w", input.Path, err)
+	}
+
+	response := new(strings.Builder)
+	fmt.Fprintf(response, "<patches_applied>all</patches_applied>\n")
+	for _, msg := range clipboardsModified {
+		fmt.Fprintln(response, msg)
+	}
+
+	if autogenerated {
+		fmt.Fprintf(response, "<warning>%q appears to be autogenerated. Patches were applied anyway.</warning>\n", input.Path)
+	}
+
+	diff := generateUnifiedDiff(input.Path, string(orig), string(patched))
+
+	// TODO: maybe report the patch result to the model, i.e. some/all of the new code after the patches and formatting.
+	return llm.ToolOut{
+		LLMContent: llm.TextContent(response.String()),
+		Display:    diff,
+	}
+}
+
+// IsAutogeneratedGoFile reports whether a Go file has markers indicating it was autogenerated.
+func IsAutogeneratedGoFile(buf []byte) bool {
+	for _, sig := range autogeneratedSignals {
+		if bytes.Contains(buf, []byte(sig)) {
+			return true
+		}
+	}
+
+	// https://pkg.go.dev/cmd/go#hdr-Generate_Go_files_by_processing_source
+	// "This line must appear before the first non-comment, non-blank text in the file."
+	// Approximate that by looking for it at the top of the file, before the last of the imports.
+	// (Sometimes people put it after the package declaration, because of course they do.)
+	// At least in the imports region we know it's not part of their actual code;
+	// we don't want to ignore the generator (which also includes these strings!),
+	// just the generated code.
+	fset := token.NewFileSet()
+	f, err := parser.ParseFile(fset, "x.go", buf, parser.ImportsOnly|parser.ParseComments)
+	if err == nil {
+		for _, cg := range f.Comments {
+			t := strings.ToLower(cg.Text())
+			for _, sig := range autogeneratedHeaderSignals {
+				if strings.Contains(t, sig) {
+					return true
+				}
+			}
+		}
+	}
+
+	return false
+}
+
+// autogeneratedSignals are signals that a file is autogenerated, when present anywhere in the file.
+var autogeneratedSignals = [][]byte{
+	[]byte("\nfunc bindataRead("), // pre-embed bindata packed file
+}
+
+// autogeneratedHeaderSignals are signals that a file is autogenerated, when present at the top of the file.
+var autogeneratedHeaderSignals = []string{
+	// canonical would be `(?m)^// Code generated .* DO NOT EDIT\.$`
+	// but people screw it up, a lot, so be more lenient
+	strings.ToLower("generate"),
+	strings.ToLower("DO NOT EDIT"),
+	strings.ToLower("export by"),
+}
+
+func generateUnifiedDiff(filePath, original, patched string) string {
+	buf := new(strings.Builder)
+	err := diff.Text(filePath, filePath, original, patched, buf)
+	if err != nil {
+		return fmt.Sprintf("(diff generation failed: %v)\n", err)
+	}
+	return buf.String()
+}
+
+// reindent applies indentation adjustments to text.
+func reindent(text string, adj *Reindent) (string, error) {
+	if adj == nil {
+		return text, nil
+	}
+
+	lines := strings.Split(text, "\n")
+
+	for i, line := range lines {
+		if line == "" {
+			continue
+		}
+		var ok bool
+		lines[i], ok = strings.CutPrefix(line, adj.Strip)
+		if !ok {
+			return "", fmt.Errorf("strip precondition failed: line %q does not start with %q", line, adj.Strip)
+		}
+	}
+
+	for i, line := range lines {
+		if line == "" {
+			continue
+		}
+		lines[i] = adj.Add + line
+	}
+
+	return strings.Join(lines, "\n"), nil
+}

claudetool/patch_test.go πŸ”—

@@ -0,0 +1,625 @@
+package claudetool
+
+import (
+	"context"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"shelley.exe.dev/llm"
+)
+
+func TestPatchTool_BasicOperations(t *testing.T) {
+	tempDir := t.TempDir()
+	patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+	ctx := context.Background()
+
+	// Test overwrite operation (creates new file)
+	testFile := filepath.Join(tempDir, "test.txt")
+	input := PatchInput{
+		Path: testFile,
+		Patches: []PatchRequest{{
+			Operation: "overwrite",
+			NewText:   "Hello World\n",
+		}},
+	}
+
+	msg, _ := json.Marshal(input)
+	result := patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("overwrite failed: %v", result.Error)
+	}
+
+	content, err := os.ReadFile(testFile)
+	if err != nil {
+		t.Fatalf("failed to read file: %v", err)
+	}
+	if string(content) != "Hello World\n" {
+		t.Errorf("expected 'Hello World\\n', got %q", string(content))
+	}
+
+	// Test replace operation
+	input.Patches = []PatchRequest{{
+		Operation: "replace",
+		OldText:   "World",
+		NewText:   "Patch",
+	}}
+
+	msg, _ = json.Marshal(input)
+	result = patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("replace failed: %v", result.Error)
+	}
+
+	content, _ = os.ReadFile(testFile)
+	if string(content) != "Hello Patch\n" {
+		t.Errorf("expected 'Hello Patch\\n', got %q", string(content))
+	}
+
+	// Test append_eof operation
+	input.Patches = []PatchRequest{{
+		Operation: "append_eof",
+		NewText:   "Appended line\n",
+	}}
+
+	msg, _ = json.Marshal(input)
+	result = patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("append_eof failed: %v", result.Error)
+	}
+
+	content, _ = os.ReadFile(testFile)
+	expected := "Hello Patch\nAppended line\n"
+	if string(content) != expected {
+		t.Errorf("expected %q, got %q", expected, string(content))
+	}
+
+	// Test prepend_bof operation
+	input.Patches = []PatchRequest{{
+		Operation: "prepend_bof",
+		NewText:   "Prepended line\n",
+	}}
+
+	msg, _ = json.Marshal(input)
+	result = patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("prepend_bof failed: %v", result.Error)
+	}
+
+	content, _ = os.ReadFile(testFile)
+	expected = "Prepended line\nHello Patch\nAppended line\n"
+	if string(content) != expected {
+		t.Errorf("expected %q, got %q", expected, string(content))
+	}
+}
+
+func TestPatchTool_ClipboardOperations(t *testing.T) {
+	tempDir := t.TempDir()
+	patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+	ctx := context.Background()
+
+	testFile := filepath.Join(tempDir, "clipboard.txt")
+
+	// Create initial content
+	input := PatchInput{
+		Path: testFile,
+		Patches: []PatchRequest{{
+			Operation: "overwrite",
+			NewText:   "function original() {\n    return 'original';\n}\n",
+		}},
+	}
+
+	msg, _ := json.Marshal(input)
+	result := patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("initial overwrite failed: %v", result.Error)
+	}
+
+	// Test toClipboard operation
+	input.Patches = []PatchRequest{{
+		Operation:   "replace",
+		OldText:     "function original() {\n    return 'original';\n}",
+		NewText:     "function renamed() {\n    return 'renamed';\n}",
+		ToClipboard: "saved_func",
+	}}
+
+	msg, _ = json.Marshal(input)
+	result = patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("toClipboard failed: %v", result.Error)
+	}
+
+	// Test fromClipboard operation
+	input.Patches = []PatchRequest{{
+		Operation:     "append_eof",
+		FromClipboard: "saved_func",
+	}}
+
+	msg, _ = json.Marshal(input)
+	result = patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("fromClipboard failed: %v", result.Error)
+	}
+
+	content, _ := os.ReadFile(testFile)
+	if !strings.Contains(string(content), "function original()") {
+		t.Error("clipboard content not restored properly")
+	}
+}
+
+func TestPatchTool_IndentationAdjustment(t *testing.T) {
+	tempDir := t.TempDir()
+	patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+	ctx := context.Background()
+
+	testFile := filepath.Join(tempDir, "indent.go")
+
+	// Create file with tab indentation
+	input := PatchInput{
+		Path: testFile,
+		Patches: []PatchRequest{{
+			Operation: "overwrite",
+			NewText:   "package main\n\nfunc main() {\n\tif true {\n\t\t// placeholder\n\t}\n}\n",
+		}},
+	}
+
+	msg, _ := json.Marshal(input)
+	result := patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("initial setup failed: %v", result.Error)
+	}
+
+	// Test indentation adjustment: convert spaces to tabs
+	input.Patches = []PatchRequest{{
+		Operation: "replace",
+		OldText:   "// placeholder",
+		NewText:   "    fmt.Println(\"hello\")\n    fmt.Println(\"world\")",
+		Reindent: &Reindent{
+			Strip: "    ",
+			Add:   "\t\t",
+		},
+	}}
+
+	msg, _ = json.Marshal(input)
+	result = patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("indentation adjustment failed: %v", result.Error)
+	}
+
+	content, _ := os.ReadFile(testFile)
+	expected := "\t\tfmt.Println(\"hello\")\n\t\tfmt.Println(\"world\")"
+	if !strings.Contains(string(content), expected) {
+		t.Errorf("indentation not adjusted correctly, got:\n%s", string(content))
+	}
+}
+
+func TestPatchTool_FuzzyMatching(t *testing.T) {
+	tempDir := t.TempDir()
+	patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+	ctx := context.Background()
+
+	testFile := filepath.Join(tempDir, "fuzzy.go")
+
+	// Create Go file with specific indentation
+	input := PatchInput{
+		Path: testFile,
+		Patches: []PatchRequest{{
+			Operation: "overwrite",
+			NewText:   "package main\n\nfunc test() {\n\tif condition {\n\t\tfmt.Println(\"hello\")\n\t\tfmt.Println(\"world\")\n\t}\n}\n",
+		}},
+	}
+
+	msg, _ := json.Marshal(input)
+	result := patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("initial setup failed: %v", result.Error)
+	}
+
+	// Test fuzzy matching with different whitespace
+	input.Patches = []PatchRequest{{
+		Operation: "replace",
+		OldText:   "if condition {\n        fmt.Println(\"hello\")\n        fmt.Println(\"world\")\n    }", // spaces instead of tabs
+		NewText:   "if condition {\n\t\tfmt.Println(\"modified\")\n\t}",
+	}}
+
+	msg, _ = json.Marshal(input)
+	result = patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("fuzzy matching failed: %v", result.Error)
+	}
+
+	content, _ := os.ReadFile(testFile)
+	if !strings.Contains(string(content), "modified") {
+		t.Error("fuzzy matching did not work")
+	}
+}
+
+func TestPatchTool_ErrorCases(t *testing.T) {
+	tempDir := t.TempDir()
+	patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+	ctx := context.Background()
+
+	testFile := filepath.Join(tempDir, "error.txt")
+
+	// Test replace operation on non-existent file
+	input := PatchInput{
+		Path: testFile,
+		Patches: []PatchRequest{{
+			Operation: "replace",
+			OldText:   "something",
+			NewText:   "else",
+		}},
+	}
+
+	msg, _ := json.Marshal(input)
+	result := patch.Run(ctx, msg)
+	if result.Error == nil {
+		t.Error("expected error for replace on non-existent file")
+	}
+
+	// Create file with duplicate text
+	input.Patches = []PatchRequest{{
+		Operation: "overwrite",
+		NewText:   "duplicate\nduplicate\n",
+	}}
+
+	msg, _ = json.Marshal(input)
+	result = patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("failed to create test file: %v", result.Error)
+	}
+
+	// Test non-unique text
+	input.Patches = []PatchRequest{{
+		Operation: "replace",
+		OldText:   "duplicate",
+		NewText:   "unique",
+	}}
+
+	msg, _ = json.Marshal(input)
+	result = patch.Run(ctx, msg)
+	if result.Error == nil || !strings.Contains(result.Error.Error(), "not unique") {
+		t.Error("expected non-unique error")
+	}
+
+	// Test missing text
+	input.Patches = []PatchRequest{{
+		Operation: "replace",
+		OldText:   "nonexistent",
+		NewText:   "something",
+	}}
+
+	msg, _ = json.Marshal(input)
+	result = patch.Run(ctx, msg)
+	if result.Error == nil || !strings.Contains(result.Error.Error(), "not found") {
+		t.Error("expected not found error")
+	}
+
+	// Test invalid clipboard reference
+	input.Patches = []PatchRequest{{
+		Operation:     "append_eof",
+		FromClipboard: "nonexistent",
+	}}
+
+	msg, _ = json.Marshal(input)
+	result = patch.Run(ctx, msg)
+	if result.Error == nil || !strings.Contains(result.Error.Error(), "clipboard") {
+		t.Error("expected clipboard error")
+	}
+}
+
+func TestPatchTool_FlexibleInputParsing(t *testing.T) {
+	tempDir := t.TempDir()
+	patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+	ctx := context.Background()
+
+	testFile := filepath.Join(tempDir, "flexible.txt")
+
+	// Test single patch format (PatchInputOne)
+	inputOne := PatchInputOne{
+		Path: testFile,
+		Patches: &PatchRequest{
+			Operation: "overwrite",
+			NewText:   "Single patch format\n",
+		},
+	}
+
+	msg, _ := json.Marshal(inputOne)
+	result := patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("single patch format failed: %v", result.Error)
+	}
+
+	content, _ := os.ReadFile(testFile)
+	if string(content) != "Single patch format\n" {
+		t.Error("single patch format did not work")
+	}
+
+	// Test string patch format (PatchInputOneString)
+	patchStr := `{"operation": "replace", "oldText": "Single", "newText": "Modified"}`
+	inputStr := PatchInputOneString{
+		Path:    testFile,
+		Patches: patchStr,
+	}
+
+	msg, _ = json.Marshal(inputStr)
+	result = patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("string patch format failed: %v", result.Error)
+	}
+
+	content, _ = os.ReadFile(testFile)
+	if !strings.Contains(string(content), "Modified") {
+		t.Error("string patch format did not work")
+	}
+}
+
+func TestPatchTool_AutogeneratedDetection(t *testing.T) {
+	tempDir := t.TempDir()
+	patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+	ctx := context.Background()
+
+	testFile := filepath.Join(tempDir, "generated.go")
+
+	// Create autogenerated file
+	input := PatchInput{
+		Path: testFile,
+		Patches: []PatchRequest{{
+			Operation: "overwrite",
+			NewText:   "// Code generated by tool. DO NOT EDIT.\npackage main\n\nfunc generated() {}\n",
+		}},
+	}
+
+	msg, _ := json.Marshal(input)
+	result := patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("failed to create generated file: %v", result.Error)
+	}
+
+	// Test patching autogenerated file (should warn but work)
+	input.Patches = []PatchRequest{{
+		Operation: "replace",
+		OldText:   "func generated() {}",
+		NewText:   "func modified() {}",
+	}}
+
+	msg, _ = json.Marshal(input)
+	result = patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("patching generated file failed: %v", result.Error)
+	}
+
+	if len(result.LLMContent) == 0 || !strings.Contains(result.LLMContent[0].Text, "autogenerated") {
+		t.Error("expected autogenerated warning")
+	}
+}
+
+func TestPatchTool_MultiplePatches(t *testing.T) {
+	tempDir := t.TempDir()
+	patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+	ctx := context.Background()
+
+	testFile := filepath.Join(tempDir, "multi.go")
+	var msg []byte
+	var result llm.ToolOut
+
+	// Apply multiple patches - first create file, then modify
+	input := PatchInput{
+		Path: testFile,
+		Patches: []PatchRequest{{
+			Operation: "overwrite",
+			NewText:   "package main\n\nfunc first() {\n\tprintln(\"first\")\n}\n\nfunc second() {\n\tprintln(\"second\")\n}\n",
+		}},
+	}
+
+	msg, _ = json.Marshal(input)
+	result = patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("failed to create initial file: %v", result.Error)
+	}
+
+	// Now apply multiple patches in one call
+	input.Patches = []PatchRequest{
+		{
+			Operation: "replace",
+			OldText:   "println(\"first\")",
+			NewText:   "println(\"ONE\")",
+		},
+		{
+			Operation: "replace",
+			OldText:   "println(\"second\")",
+			NewText:   "println(\"TWO\")",
+		},
+		{
+			Operation: "append_eof",
+			NewText:   "\n// Multiple patches applied\n",
+		},
+	}
+
+	msg, _ = json.Marshal(input)
+	result = patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("multiple patches failed: %v", result.Error)
+	}
+
+	content, _ := os.ReadFile(testFile)
+	contentStr := string(content)
+	if !strings.Contains(contentStr, "ONE") || !strings.Contains(contentStr, "TWO") {
+		t.Error("multiple patches not applied correctly")
+	}
+	if !strings.Contains(contentStr, "Multiple patches applied") {
+		t.Error("append_eof in multiple patches not applied")
+	}
+}
+
+func TestPatchTool_CopyRecipe(t *testing.T) {
+	tempDir := t.TempDir()
+	patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+	ctx := context.Background()
+
+	testFile := filepath.Join(tempDir, "copy.txt")
+
+	// Create initial content
+	input := PatchInput{
+		Path: testFile,
+		Patches: []PatchRequest{{
+			Operation: "overwrite",
+			NewText:   "original text",
+		}},
+	}
+
+	msg, _ := json.Marshal(input)
+	result := patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("failed to create file: %v", result.Error)
+	}
+
+	// Test copy recipe (toClipboard + fromClipboard with same name)
+	input.Patches = []PatchRequest{{
+		Operation:     "replace",
+		OldText:       "original text",
+		NewText:       "replaced text",
+		ToClipboard:   "copy_test",
+		FromClipboard: "copy_test",
+	}}
+
+	msg, _ = json.Marshal(input)
+	result = patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("copy recipe failed: %v", result.Error)
+	}
+
+	content, _ := os.ReadFile(testFile)
+	// The copy recipe should preserve the original text
+	if string(content) != "original text" {
+		t.Errorf("copy recipe failed, expected 'original text', got %q", string(content))
+	}
+}
+
+func TestPatchTool_RelativePaths(t *testing.T) {
+	tempDir := t.TempDir()
+	patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+	ctx := context.Background()
+
+	// Test relative path resolution
+	input := PatchInput{
+		Path: "relative.txt", // relative path
+		Patches: []PatchRequest{{
+			Operation: "overwrite",
+			NewText:   "relative path test\n",
+		}},
+	}
+
+	msg, _ := json.Marshal(input)
+	result := patch.Run(ctx, msg)
+	if result.Error != nil {
+		t.Fatalf("relative path failed: %v", result.Error)
+	}
+
+	// Check file was created in correct location
+	expectedPath := filepath.Join(tempDir, "relative.txt")
+	content, err := os.ReadFile(expectedPath)
+	if err != nil {
+		t.Fatalf("file not created at expected path: %v", err)
+	}
+	if string(content) != "relative path test\n" {
+		t.Error("relative path file content incorrect")
+	}
+}
+
+// Benchmark basic patch operations
+func BenchmarkPatchTool_BasicOperations(b *testing.B) {
+	tempDir := b.TempDir()
+	patch := &PatchTool{WorkingDir: NewMutableWorkingDir(tempDir)}
+	ctx := context.Background()
+
+	testFile := filepath.Join(tempDir, "bench.go")
+	initialContent := "package main\n\nfunc test() {\n\tfor i := 0; i < 100; i++ {\n\t\tfmt.Println(i)\n\t}\n}\n"
+
+	// Setup
+	input := PatchInput{
+		Path: testFile,
+		Patches: []PatchRequest{{
+			Operation: "overwrite",
+			NewText:   initialContent,
+		}},
+	}
+	msg, _ := json.Marshal(input)
+	patch.Run(ctx, msg)
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		// Benchmark replace operation
+		input.Patches = []PatchRequest{{
+			Operation: "replace",
+			OldText:   "fmt.Println(i)",
+			NewText:   "fmt.Printf(\"%d\\n\", i)",
+		}}
+
+		msg, _ := json.Marshal(input)
+		result := patch.Run(ctx, msg)
+		if result.Error != nil {
+			b.Fatalf("benchmark failed: %v", result.Error)
+		}
+
+		// Reset for next iteration
+		input.Patches = []PatchRequest{{
+			Operation: "replace",
+			OldText:   "fmt.Printf(\"%d\\n\", i)",
+			NewText:   "fmt.Println(i)",
+		}}
+		msg, _ = json.Marshal(input)
+		patch.Run(ctx, msg)
+	}
+}
+
+func TestPatchTool_CallbackFunction(t *testing.T) {
+	tempDir := t.TempDir()
+	callbackCalled := false
+	var capturedInput PatchInput
+	var capturedOutput llm.ToolOut
+
+	patch := &PatchTool{
+		WorkingDir: NewMutableWorkingDir(tempDir),
+		Callback: func(input PatchInput, output llm.ToolOut) llm.ToolOut {
+			callbackCalled = true
+			capturedInput = input
+			capturedOutput = output
+			// Modify the output
+			output.LLMContent = llm.TextContent("Modified by callback")
+			return output
+		},
+	}
+
+	ctx := context.Background()
+	testFile := filepath.Join(tempDir, "callback.txt")
+
+	input := PatchInput{
+		Path: testFile,
+		Patches: []PatchRequest{{
+			Operation: "overwrite",
+			NewText:   "callback test",
+		}},
+	}
+
+	msg, _ := json.Marshal(input)
+	result := patch.Run(ctx, msg)
+
+	if !callbackCalled {
+		t.Error("callback was not called")
+	}
+
+	if capturedInput.Path != testFile {
+		t.Error("callback did not receive correct input")
+	}
+
+	if len(result.LLMContent) == 0 || result.LLMContent[0].Text != "Modified by callback" {
+		t.Error("callback did not modify output correctly")
+	}
+
+	if capturedOutput.Error != nil {
+		t.Errorf("callback received error: %v", capturedOutput.Error)
+	}
+}

claudetool/patchkit/patchkit.go πŸ”—

@@ -0,0 +1,415 @@
+package patchkit
+
+import (
+	"fmt"
+	"go/scanner"
+	"go/token"
+	"slices"
+	"strings"
+	"unicode"
+
+	"sketch.dev/claudetool/editbuf"
+)
+
+// A Spec specifies a single patch.
+type Spec struct {
+	Off int    // Byte offset to apply the replacement
+	Len int    // Length of the replacement
+	Src string // Original string (for debugging)
+	Old string // Search string
+	New string // Replacement string
+}
+
+// Unique generates a patch spec to apply op, given a unique occurrence of needle in haystack and replacement text replace.
+// It reports the number of matches found for needle in haystack: 0, 1, or 2 (for any value > 1).
+func Unique(haystack, needle, replace string) (*Spec, int) {
+	prefix, rest, ok := strings.Cut(haystack, needle)
+	if !ok {
+		return nil, 0
+	}
+	if strings.Contains(rest, needle) {
+		return nil, 2
+	}
+	s := &Spec{
+		Off: len(prefix),
+		Len: len(needle),
+		Src: haystack,
+		Old: needle,
+		New: replace,
+	}
+	return s, 1
+}
+
+// minimize reduces the size of the patch by removing any shared prefix and suffix.
+func (s *Spec) minimize() {
+	pre := commonPrefixLen(s.Old, s.New)
+	s.Off += pre
+	s.Len -= pre
+	s.Old = s.Old[pre:]
+	s.New = s.New[pre:]
+	suf := commonSuffixLen(s.Old, s.New)
+	s.Len -= suf
+	s.Old = s.Old[:len(s.Old)-suf]
+	s.New = s.New[:len(s.New)-suf]
+}
+
+// ApplyToEditBuf applies the patch to the given edit buffer.
+func (s *Spec) ApplyToEditBuf(buf *editbuf.Buffer) {
+	s.minimize()
+	buf.Replace(s.Off, s.Off+s.Len, s.New)
+}
+
+// UniqueDedent is Unique, but with flexibility around consistent whitespace prefix changes.
+// Unlike Unique, which returns a count of matches,
+// UniqueDedent returns a boolean indicating whether a unique match was found.
+// It is for LLMs that have a hard time reliably reproducing uniform whitespace prefixes.
+// For example, they may generate 8 spaces instead of 6 for all relevant lines.
+// UniqueDedent adjusts the needle's whitespace prefix to match the haystack's
+// and then replaces the unique instance of needle in haystack with replacement.
+func UniqueDedent(haystack, needle, replace string) (*Spec, bool) {
+	// TODO: this all definitely admits of some optimization
+	haystackLines := slices.Collect(strings.Lines(haystack))
+	needleLines := slices.Collect(strings.Lines(needle))
+	match := uniqueTrimmedLineMatch(haystackLines, needleLines)
+	if match == -1 {
+		return nil, false
+	}
+	// We now systematically adjust needle's whitespace prefix to match haystack.
+	// The first line gets special treatment, because its leading whitespace is irrelevant,
+	// and models often skip past it (or part of it).
+	if len(needleLines) == 0 {
+		return nil, false
+	}
+	// First line: cut leading whitespace and make corresponding fixes to replacement.
+	// The leading whitespace will come out in the wash in Unique.
+	// We need to make corresponding fixes to the replacement.
+	nl0 := needleLines[0]
+	noWS := strings.TrimLeftFunc(nl0, unicode.IsSpace)
+	ws0, _ := strings.CutSuffix(nl0, noWS) // can't fail
+	rest, ok := strings.CutPrefix(replace, ws0)
+	if ok {
+		// Adjust needle and replacement in tandem.
+		nl0 = noWS
+		replace = rest
+	}
+	// Calculate common whitespace prefixes for the rest.
+	haystackPrefix := commonWhitespacePrefix(haystackLines[match : match+len(needleLines)])
+	needlePrefix := commonWhitespacePrefix(needleLines[1:])
+	nbuf := new(strings.Builder)
+	for i, line := range needleLines {
+		if i == 0 {
+			nbuf.WriteString(nl0)
+			continue
+		}
+		// Allow empty (newline-only) lines not to be prefixed.
+		if strings.TrimRight(line, "\n\r") == "" {
+			nbuf.WriteString(line)
+			continue
+		}
+		// Swap in haystackPrefix for needlePrefix.
+		nbuf.WriteString(haystackPrefix)
+		nbuf.WriteString(line[len(needlePrefix):])
+	}
+	// Do a replacement with our new-and-improved needle.
+	needle = nbuf.String()
+	spec, count := Unique(haystack, needle, replace)
+	if count != 1 {
+		return nil, false
+	}
+	return spec, true
+}
+
+type tok struct {
+	pos token.Position
+	tok token.Token
+	lit string
+}
+
+func (t tok) String() string {
+	if t.lit == "" {
+		return fmt.Sprintf("%s", t.tok)
+	}
+	return fmt.Sprintf("%s(%q)", t.tok, t.lit)
+}
+
+func tokenize(code string) ([]tok, bool) {
+	var s scanner.Scanner
+	fset := token.NewFileSet()
+	file := fset.AddFile("", fset.Base(), len(code))
+	s.Init(file, []byte(code), nil, scanner.ScanComments)
+	var tokens []tok
+	for {
+		pos, t, lit := s.Scan()
+		if s.ErrorCount > 0 {
+			return nil, false // invalid Go code (or not Go code at all)
+		}
+		if t == token.EOF {
+			return tokens, true
+		}
+		tokens = append(tokens, tok{pos: fset.PositionFor(pos, false), tok: t, lit: lit})
+	}
+}
+
+func tokensEqual(a, b []tok) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for i := range a {
+		at, bt := a[i], b[i]
+		// positions are expected to differ
+		if at.tok != bt.tok || at.lit != bt.lit {
+			return false
+		}
+	}
+	return true
+}
+
+func tokensUniqueMatch(haystack, needle []tok) int {
+	// TODO: optimize
+	match := -1
+	for i := range haystack {
+		rest := haystack[i:]
+		if len(rest) < len(needle) {
+			break
+		}
+		rest = rest[:len(needle)]
+		if !tokensEqual(rest, needle) {
+			continue
+		}
+		if match != -1 {
+			return -1 // multiple matches
+		}
+		match = i
+	}
+	return match
+}
+
+// UniqueGoTokens is Unique, but with flexibility around all insignificant whitespace.
+// Like UniqueDedent, it returns a boolean indicating whether a unique match was found.
+// It is safe (enough) because it ensures that the needle alterations occurs only in places
+// where whitespace is not semantically significant.
+// In practice, this appears safe.
+func UniqueGoTokens(haystack, needle, replace string) (*Spec, bool) {
+	nt, ok := tokenize(needle)
+	if !ok {
+		return nil, false
+	}
+	ht, ok := tokenize(haystack)
+	if !ok {
+		return nil, false
+	}
+	match := tokensUniqueMatch(ht, nt)
+	if match == -1 {
+		return nil, false
+	}
+	matchEnd := match + len(nt) - 1
+	start := ht[match].pos.Offset
+	needle = haystack[start:]
+	if matchEnd+1 < len(ht) {
+		// todo: handle match at very end of file
+		end := ht[matchEnd+1].pos.Offset
+		needle = needle[:end-start]
+	}
+	// OK, declare this very fuzzy match to be our new needle.
+	spec, count := Unique(haystack, needle, replace)
+	if count != 1 {
+		return nil, false
+	}
+	return spec, true
+}
+
+// UniqueInValidGo is Unique, but with flexibility around all leading and trailing whitespace.
+// Like UniqueDedent, it returns a boolean indicating whether a unique match was found.
+// It is safe (enough) because it ensures that the needle alterations occurs only in places
+// where whitespace is not semantically significant.
+// In practice, this appears safe.
+func UniqueInValidGo(haystack, needle, replace string) (*Spec, bool) {
+	haystackLines := slices.Collect(strings.Lines(haystack))
+	needleLines := slices.Collect(strings.Lines(needle))
+	matchStart := uniqueTrimmedLineMatch(haystackLines, needleLines)
+	if matchStart == -1 {
+		return nil, false
+	}
+	needle, replace = improveNeedle(haystack, needle, replace, matchStart)
+	matchEnd := matchStart + strings.Count(needle, "\n")
+	// Ensure that none of the lines that we fuzzy-matched involve a multiline comment or string literal.
+	var s scanner.Scanner
+	fset := token.NewFileSet()
+	file := fset.AddFile("", fset.Base(), len(haystack))
+	s.Init(file, []byte(haystack), nil, scanner.ScanComments)
+	for {
+		pos, tok, lit := s.Scan()
+		if s.ErrorCount > 0 {
+			return nil, false // invalid Go code (or not Go code at all)
+		}
+		if tok == token.EOF {
+			break
+		}
+		if tok == token.SEMICOLON || !strings.Contains(lit, "\n") {
+			continue
+		}
+		// In a token that spans multiple lines,
+		// so not perfectly matching whitespace might be unsafe.
+		p := fset.Position(pos)
+		tokenStart := p.Line - 1 // 1-based to 0-based
+		tokenEnd := tokenStart + strings.Count(lit, "\n")
+		// Check whether [matchStart, matchEnd] overlaps [tokenStart, tokenEnd]
+		// TODO: think more about edge conditions here. Any off-by-one errors?
+		// For example, leading whitespace and trailing whitespace
+		// on this token's lines are not semantically significant.
+		if tokenStart <= matchEnd && matchStart <= tokenEnd {
+			// if tokenStart <= matchStart && tokenEnd <= tokenEnd {}
+			return nil, false // this token overlaps the range we're replacing, not safe
+		}
+	}
+
+	// TODO: restore this sanity check? it's mildly expensive and i've never seen it fail.
+	// replaced := strings.Join(haystackLines[:matchStart], "") + replacement + strings.Join(haystackLines[matchEnd:], "")
+	// _, err := format.Source([]byte(replaced))
+	// if err != nil {
+	//     return nil, false
+	// }
+
+	// OK, declare this very fuzzy match to be our new needle.
+	needle = strings.Join(haystackLines[matchStart:matchEnd], "")
+	spec, count := Unique(haystack, needle, replace)
+	if count != 1 {
+		return nil, false
+	}
+	return spec, true
+}
+
+// UniqueTrim is Unique, but with flexibility to shrink old/replace in tandem.
+func UniqueTrim(haystack, needle, replace string) (*Spec, bool) {
+	// LLMs appear to particularly struggle with the first line of a patch.
+	// If that first line is replicated in replace,
+	// and removing it yields a unique match,
+	// we can remove that line entirely from both.
+	n0, nRest, nOK := strings.Cut(needle, "\n")
+	r0, rRest, rOK := strings.Cut(replace, "\n")
+	if !nOK || !rOK || n0 != r0 {
+		return nil, false
+	}
+	spec, count := Unique(haystack, nRest, rRest)
+	if count != 1 {
+		return nil, false
+	}
+	return spec, true
+}
+
+// uniqueTrimmedLineMatch returns the index of the first line in haystack that matches needle,
+// when ignoring leading and trailing whitespace.
+// uniqueTrimmedLineMatch returns -1 if there is no unique match.
+func uniqueTrimmedLineMatch(haystackLines, needleLines []string) int {
+	// TODO: optimize
+	trimmedHaystackLines := trimSpaceAll(haystackLines)
+	trimmedNeedleLines := trimSpaceAll(needleLines)
+	match := -1
+	for i := range trimmedHaystackLines {
+		rest := trimmedHaystackLines[i:]
+		if len(rest) < len(trimmedNeedleLines) {
+			break
+		}
+		rest = rest[:len(trimmedNeedleLines)]
+		if !slices.Equal(rest, trimmedNeedleLines) {
+			continue
+		}
+		if match != -1 {
+			return -1 // multiple matches
+		}
+		match = i
+	}
+	return match
+}
+
+func trimSpaceAll(x []string) []string {
+	trimmed := make([]string, len(x))
+	for i, s := range x {
+		trimmed[i] = strings.TrimSpace(s)
+	}
+	return trimmed
+}
+
+// improveNeedle adjusts both needle and replacement in tandem to better match haystack.
+// Note that we adjust search and replace together.
+func improveNeedle(haystack, needle, replacement string, matchLine int) (string, string) {
+	// TODO: we make new slices too much
+	needleLines := slices.Collect(strings.Lines(needle))
+	if len(needleLines) == 0 {
+		return needle, replacement
+	}
+	haystackLines := slices.Collect(strings.Lines(haystack))
+	if matchLine+len(needleLines) > len(haystackLines) {
+		// should be impossible, but just in case
+		return needle, replacement
+	}
+	// Add trailing last-line newline if needed to better match haystack.
+	if !strings.HasSuffix(needle, "\n") && strings.HasSuffix(haystackLines[matchLine+len(needleLines)-1], "\n") {
+		needle += "\n"
+		replacement += "\n"
+	}
+	// Add leading first-line prefix if needed to better match haystack.
+	rest, ok := strings.CutSuffix(haystackLines[matchLine], needleLines[0])
+	if ok {
+		needle = rest + needle
+		replacement = rest + replacement
+	}
+	return needle, replacement
+}
+
+func isNonSpace(r rune) bool {
+	return !unicode.IsSpace(r)
+}
+
+func whitespacePrefix(s string) string {
+	firstNonSpace := strings.IndexFunc(s, isNonSpace)
+	return s[:max(0, firstNonSpace)] // map -1 for "not found" onto 0
+}
+
+// commonWhitespacePrefix returns the longest common whitespace prefix of the elements of x, somewhat flexibly.
+func commonWhitespacePrefix(x []string) string {
+	var pre string
+	for i, s := range x {
+		if i == 0 {
+			pre = s
+			continue
+		}
+		// ignore line endings for the moment
+		// (this is just for prefixes)
+		s = strings.TrimRight(s, "\n\r")
+		if s == "" {
+			continue
+		}
+		n := commonPrefixLen(pre, whitespacePrefix(s))
+		if n == 0 {
+			return ""
+		}
+		pre = pre[:n]
+	}
+	pre = strings.TrimRightFunc(pre, isNonSpace)
+	return pre
+}
+
+// commonPrefixLen returns the length of the common prefix of two strings.
+// TODO: optimize, see e.g. https://go-review.googlesource.com/c/go/+/408116
+func commonPrefixLen(a, b string) int {
+	shortest := min(len(a), len(b))
+	for i := range shortest {
+		if a[i] != b[i] {
+			return i
+		}
+	}
+	return shortest
+}
+
+// commonSuffixLen returns the length of the common suffix of two strings.
+// TODO: optimize
+func commonSuffixLen(a, b string) int {
+	shortest := min(len(a), len(b))
+	for i := 0; i < shortest; i++ {
+		if a[len(a)-i-1] != b[len(b)-i-1] {
+			return i
+		}
+	}
+	return shortest
+}

claudetool/patchkit/patchkit_test.go πŸ”—

@@ -0,0 +1,572 @@
+package patchkit
+
+import (
+	"strings"
+	"testing"
+
+	"sketch.dev/claudetool/editbuf"
+)
+
+func TestUnique(t *testing.T) {
+	tests := []struct {
+		name      string
+		haystack  string
+		needle    string
+		replace   string
+		wantCount int
+		wantOff   int
+		wantLen   int
+	}{
+		{
+			name:      "single_match",
+			haystack:  "hello world hello",
+			needle:    "world",
+			replace:   "universe",
+			wantCount: 1,
+			wantOff:   6,
+			wantLen:   5,
+		},
+		{
+			name:      "no_match",
+			haystack:  "hello world",
+			needle:    "missing",
+			replace:   "found",
+			wantCount: 0,
+		},
+		{
+			name:      "multiple_matches",
+			haystack:  "hello hello hello",
+			needle:    "hello",
+			replace:   "hi",
+			wantCount: 2,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			spec, count := Unique(tt.haystack, tt.needle, tt.replace)
+			if count != tt.wantCount {
+				t.Errorf("Unique() count = %v, want %v", count, tt.wantCount)
+			}
+			if count == 1 {
+				if spec.Off != tt.wantOff {
+					t.Errorf("Unique() offset = %v, want %v", spec.Off, tt.wantOff)
+				}
+				if spec.Len != tt.wantLen {
+					t.Errorf("Unique() length = %v, want %v", spec.Len, tt.wantLen)
+				}
+				if spec.Old != tt.needle {
+					t.Errorf("Unique() old = %q, want %q", spec.Old, tt.needle)
+				}
+				if spec.New != tt.replace {
+					t.Errorf("Unique() new = %q, want %q", spec.New, tt.replace)
+				}
+			}
+		})
+	}
+}
+
+func TestSpec_ApplyToEditBuf(t *testing.T) {
+	haystack := "hello world hello"
+	spec, count := Unique(haystack, "world", "universe")
+	if count != 1 {
+		t.Fatalf("expected unique match, got count %d", count)
+	}
+
+	buf := editbuf.NewBuffer([]byte(haystack))
+	spec.ApplyToEditBuf(buf)
+
+	result, err := buf.Bytes()
+	if err != nil {
+		t.Fatalf("failed to get buffer bytes: %v", err)
+	}
+
+	expected := "hello universe hello"
+	if string(result) != expected {
+		t.Errorf("ApplyToEditBuf() = %q, want %q", string(result), expected)
+	}
+}
+
+func TestUniqueDedent(t *testing.T) {
+	tests := []struct {
+		name     string
+		haystack string
+		needle   string
+		replace  string
+		wantOK   bool
+	}{
+		{
+			name:     "simple_case_that_should_work",
+			haystack: "hello\nworld",
+			needle:   "hello\nworld",
+			replace:  "hi\nthere",
+			wantOK:   true,
+		},
+		{
+			name:     "no_match",
+			haystack: "func test() {\n\treturn 1\n}",
+			needle:   "func missing() {\n\treturn 2\n}",
+			replace:  "func found() {\n\treturn 3\n}",
+			wantOK:   false,
+		},
+		{
+			name:     "multiple_matches",
+			haystack: "hello\nhello\n",
+			needle:   "hello",
+			replace:  "hi",
+			wantOK:   false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			spec, ok := UniqueDedent(tt.haystack, tt.needle, tt.replace)
+			if ok != tt.wantOK {
+				t.Errorf("UniqueDedent() ok = %v, want %v", ok, tt.wantOK)
+				return
+			}
+			if ok {
+				// Test that it can be applied
+				buf := editbuf.NewBuffer([]byte(tt.haystack))
+				spec.ApplyToEditBuf(buf)
+				result, err := buf.Bytes()
+				if err != nil {
+					t.Errorf("failed to apply spec: %v", err)
+				}
+				// Just check that it changed something
+				if string(result) == tt.haystack {
+					t.Error("UniqueDedent produced no change")
+				}
+			}
+		})
+	}
+}
+
+func TestUniqueGoTokens(t *testing.T) {
+	tests := []struct {
+		name     string
+		haystack string
+		needle   string
+		replace  string
+		wantOK   bool
+	}{
+		{
+			name:     "basic_tokenization_works",
+			haystack: "a+b",
+			needle:   "a+b",
+			replace:  "a*b",
+			wantOK:   true,
+		},
+		{
+			name:     "invalid_go_code",
+			haystack: "not go code @#$",
+			needle:   "@#$",
+			replace:  "valid",
+			wantOK:   false,
+		},
+		{
+			name:     "needle_not_valid_go",
+			haystack: "func test() { return 1 }",
+			needle:   "invalid @#$",
+			replace:  "valid",
+			wantOK:   false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			spec, ok := UniqueGoTokens(tt.haystack, tt.needle, tt.replace)
+			if ok != tt.wantOK {
+				t.Errorf("UniqueGoTokens() ok = %v, want %v", ok, tt.wantOK)
+				return
+			}
+			if ok {
+				// Test that it can be applied
+				buf := editbuf.NewBuffer([]byte(tt.haystack))
+				spec.ApplyToEditBuf(buf)
+				result, err := buf.Bytes()
+				if err != nil {
+					t.Errorf("failed to apply spec: %v", err)
+				}
+				// Check that replacement occurred
+				if !strings.Contains(string(result), tt.replace) {
+					t.Errorf("replacement not found in result: %q", string(result))
+				}
+			}
+		})
+	}
+}
+
+func TestUniqueInValidGo(t *testing.T) {
+	tests := []struct {
+		name     string
+		haystack string
+		needle   string
+		replace  string
+		wantOK   bool
+	}{
+		{
+			name: "leading_trailing_whitespace_difference",
+			haystack: `package main
+
+func test() {
+	if condition {
+		fmt.Println("hello")
+	}
+}`,
+			needle: `if condition {
+        fmt.Println("hello")
+    }`,
+			replace: `if condition {
+		fmt.Println("modified")
+	}`,
+			wantOK: true,
+		},
+		{
+			name:     "invalid_go_haystack",
+			haystack: "not go code",
+			needle:   "not",
+			replace:  "valid",
+			wantOK:   false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			spec, ok := UniqueInValidGo(tt.haystack, tt.needle, tt.replace)
+			if ok != tt.wantOK {
+				t.Errorf("UniqueInValidGo() ok = %v, want %v", ok, tt.wantOK)
+				return
+			}
+			if ok {
+				// Test that it can be applied
+				buf := editbuf.NewBuffer([]byte(tt.haystack))
+				spec.ApplyToEditBuf(buf)
+				result, err := buf.Bytes()
+				if err != nil {
+					t.Errorf("failed to apply spec: %v", err)
+				}
+				// Check that replacement occurred
+				if !strings.Contains(string(result), "modified") {
+					t.Errorf("expected replacement not found in result: %q", string(result))
+				}
+			}
+		})
+	}
+}
+
+func TestUniqueTrim(t *testing.T) {
+	tests := []struct {
+		name     string
+		haystack string
+		needle   string
+		replace  string
+		wantOK   bool
+	}{
+		{
+			name:     "trim_first_line",
+			haystack: "line1\nline2\nline3",
+			needle:   "line1\nline2",
+			replace:  "line1\nmodified",
+			wantOK:   true,
+		},
+		{
+			name:     "different_first_lines",
+			haystack: "line1\nline2\nline3",
+			needle:   "different\nline2",
+			replace:  "different\nmodified",
+			wantOK:   true, // Update: seems UniqueTrim is more flexible than expected
+		},
+		{
+			name:     "no_newlines",
+			haystack: "single line",
+			needle:   "single",
+			replace:  "modified",
+			wantOK:   false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			spec, ok := UniqueTrim(tt.haystack, tt.needle, tt.replace)
+			if ok != tt.wantOK {
+				t.Errorf("UniqueTrim() ok = %v, want %v", ok, tt.wantOK)
+				return
+			}
+			if ok {
+				// Test that it can be applied
+				buf := editbuf.NewBuffer([]byte(tt.haystack))
+				spec.ApplyToEditBuf(buf)
+				result, err := buf.Bytes()
+				if err != nil {
+					t.Errorf("failed to apply spec: %v", err)
+				}
+				// Check that something changed
+				if string(result) == tt.haystack {
+					t.Error("UniqueTrim produced no change")
+				}
+			}
+		})
+	}
+}
+
+func TestCommonPrefixLen(t *testing.T) {
+	tests := []struct {
+		a, b string
+		want int
+	}{
+		{"hello", "help", 3},
+		{"abc", "xyz", 0},
+		{"same", "same", 4},
+		{"", "anything", 0},
+		{"a", "", 0},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.a+"_"+tt.b, func(t *testing.T) {
+			got := commonPrefixLen(tt.a, tt.b)
+			if got != tt.want {
+				t.Errorf("commonPrefixLen(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestCommonSuffixLen(t *testing.T) {
+	tests := []struct {
+		a, b string
+		want int
+	}{
+		{"hello", "jello", 4},
+		{"abc", "xyz", 0},
+		{"same", "same", 4},
+		{"", "anything", 0},
+		{"a", "", 0},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.a+"_"+tt.b, func(t *testing.T) {
+			got := commonSuffixLen(tt.a, tt.b)
+			if got != tt.want {
+				t.Errorf("commonSuffixLen(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestSpec_minimize(t *testing.T) {
+	tests := []struct {
+		name     string
+		old, new string
+		wantOff  int
+		wantLen  int
+		wantOld  string
+		wantNew  string
+	}{
+		{
+			name:    "common_prefix_suffix",
+			old:     "prefixMIDDLEsuffix",
+			new:     "prefixCHANGEDsuffix",
+			wantOff: 6,
+			wantLen: 6,
+			wantOld: "MIDDLE",
+			wantNew: "CHANGED",
+		},
+		{
+			name:    "no_common_parts",
+			old:     "abc",
+			new:     "xyz",
+			wantOff: 0,
+			wantLen: 3,
+			wantOld: "abc",
+			wantNew: "xyz",
+		},
+		{
+			name:    "identical_strings",
+			old:     "same",
+			new:     "same",
+			wantOff: 4,
+			wantLen: 0,
+			wantOld: "",
+			wantNew: "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			spec := &Spec{
+				Off: 0,
+				Len: len(tt.old),
+				Old: tt.old,
+				New: tt.new,
+			}
+			spec.minimize()
+
+			if spec.Off != tt.wantOff {
+				t.Errorf("minimize() Off = %v, want %v", spec.Off, tt.wantOff)
+			}
+			if spec.Len != tt.wantLen {
+				t.Errorf("minimize() Len = %v, want %v", spec.Len, tt.wantLen)
+			}
+			if spec.Old != tt.wantOld {
+				t.Errorf("minimize() Old = %q, want %q", spec.Old, tt.wantOld)
+			}
+			if spec.New != tt.wantNew {
+				t.Errorf("minimize() New = %q, want %q", spec.New, tt.wantNew)
+			}
+		})
+	}
+}
+
+func TestWhitespacePrefix(t *testing.T) {
+	tests := []struct {
+		input string
+		want  string
+	}{
+		{"  hello", "  "},
+		{"\t\tworld", "\t\t"},
+		{"no_prefix", ""},
+		{"   \n", ""}, // whitespacePrefix stops at first non-space
+		{"", ""},
+		{"   ", ""}, // whitespace-only string treated as having no prefix
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			got := whitespacePrefix(tt.input)
+			if got != tt.want {
+				t.Errorf("whitespacePrefix(%q) = %q, want %q", tt.input, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestCommonWhitespacePrefix(t *testing.T) {
+	tests := []struct {
+		name  string
+		lines []string
+		want  string
+	}{
+		{
+			name:  "common_spaces",
+			lines: []string{"  hello", "  world", "  test"},
+			want:  "  ",
+		},
+		{
+			name:  "mixed_indentation",
+			lines: []string{"\t\thello", "\tworld"},
+			want:  "\t",
+		},
+		{
+			name:  "no_common_prefix",
+			lines: []string{"hello", "  world"},
+			want:  "",
+		},
+		{
+			name:  "empty_lines_ignored",
+			lines: []string{"  hello", "", "  world"},
+			want:  "  ",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := commonWhitespacePrefix(tt.lines)
+			if got != tt.want {
+				t.Errorf("commonWhitespacePrefix() = %q, want %q", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestTokenize(t *testing.T) {
+	tests := []struct {
+		name     string
+		code     string
+		wantOK   bool
+		expected []string // token representations for verification
+	}{
+		{
+			name:     "simple_go_code",
+			code:     "func main() { fmt.Println(\"hello\") }",
+			wantOK:   true,
+			expected: []string{"func(\"func\")", "IDENT(\"main\")", "(", ")", "{", "IDENT(\"fmt\")", ".", "IDENT(\"Println\")", "(", "STRING(\"\\\"hello\\\"\")", ")", "}", ";(\"\\n\")"},
+		},
+		{
+			name:   "invalid_code",
+			code:   "@#$%invalid",
+			wantOK: false,
+		},
+		{
+			name:     "empty_code",
+			code:     "",
+			wantOK:   true,
+			expected: []string{},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			tokens, ok := tokenize(tt.code)
+			if ok != tt.wantOK {
+				t.Errorf("tokenize() ok = %v, want %v", ok, tt.wantOK)
+				return
+			}
+			if ok && len(tt.expected) > 0 {
+				if len(tokens) != len(tt.expected) {
+					t.Errorf("tokenize() produced %d tokens, want %d", len(tokens), len(tt.expected))
+					return
+				}
+				for i, expected := range tt.expected {
+					if tokens[i].String() != expected {
+						t.Errorf("token[%d] = %s, want %s", i, tokens[i].String(), expected)
+					}
+				}
+			}
+		})
+	}
+}
+
+// Benchmark the core Unique function
+func BenchmarkUnique(b *testing.B) {
+	haystack := strings.Repeat("hello world ", 1000) + "TARGET" + strings.Repeat(" goodbye world", 1000)
+	needle := "TARGET"
+	replace := "REPLACEMENT"
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		_, count := Unique(haystack, needle, replace)
+		if count != 1 {
+			b.Fatalf("expected unique match, got %d", count)
+		}
+	}
+}
+
+// Benchmark fuzzy matching functions
+func BenchmarkUniqueDedent(b *testing.B) {
+	haystack := "hello\nworld"
+	needle := "hello\nworld"
+	replace := "hi\nthere"
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		_, ok := UniqueDedent(haystack, needle, replace)
+		if !ok {
+			b.Fatal("expected successful match")
+		}
+	}
+}
+
+func BenchmarkUniqueGoTokens(b *testing.B) {
+	haystack := "a+b"
+	needle := "a+b"
+	replace := "a*b"
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		_, ok := UniqueGoTokens(haystack, needle, replace)
+		if !ok {
+			b.Fatal("expected successful match")
+		}
+	}
+}

claudetool/shared.go πŸ”—

@@ -0,0 +1,38 @@
+// Package claudetool provides tools for Claude AI models.
+//
+// When adding, removing, or modifying tools in this package,
+// remember to update the tool display template in termui/termui.go
+// to ensure proper tool output formatting.
+package claudetool
+
+import (
+	"context"
+)
+
+type workingDirCtxKeyType string
+
+const workingDirCtxKey workingDirCtxKeyType = "workingDir"
+
+func WithWorkingDir(ctx context.Context, wd string) context.Context {
+	return context.WithValue(ctx, workingDirCtxKey, wd)
+}
+
+func WorkingDir(ctx context.Context) string {
+	// If cmd.Dir is empty, it uses the current working directory,
+	// so we can use that as a fallback.
+	wd, _ := ctx.Value(workingDirCtxKey).(string)
+	return wd
+}
+
+type sessionIDCtxKeyType string
+
+const sessionIDCtxKey sessionIDCtxKeyType = "sessionID"
+
+func WithSessionID(ctx context.Context, sessionID string) context.Context {
+	return context.WithValue(ctx, sessionIDCtxKey, sessionID)
+}
+
+func SessionID(ctx context.Context) string {
+	sessionID, _ := ctx.Value(sessionIDCtxKey).(string)
+	return sessionID
+}

claudetool/think.go πŸ”—

@@ -0,0 +1,39 @@
+package claudetool
+
+import (
+	"context"
+	"encoding/json"
+
+	"shelley.exe.dev/llm"
+)
+
+// The Think tool provides space to think.
+var Think = &llm.Tool{
+	Name:        thinkName,
+	Description: thinkDescription,
+	InputSchema: llm.MustSchema(thinkInputSchema),
+	Run:         thinkRun,
+}
+
+const (
+	thinkName        = "think"
+	thinkDescription = `Think out loud, take notes, form plans. Has no external effects.`
+
+	// If you modify this, update the termui template for prettier rendering.
+	thinkInputSchema = `
+{
+  "type": "object",
+  "required": ["thoughts"],
+  "properties": {
+    "thoughts": {
+      "type": "string",
+      "description": "The thoughts, notes, or plans to record"
+    }
+  }
+}
+`
+)
+
+func thinkRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+	return llm.ToolOut{LLMContent: llm.TextContent("recorded")}
+}

claudetool/toolset.go πŸ”—

@@ -0,0 +1,137 @@
+package claudetool
+
+import (
+	"context"
+	"strings"
+	"sync"
+
+	"shelley.exe.dev/claudetool/browse"
+	"shelley.exe.dev/llm"
+)
+
+// WorkingDir is a thread-safe mutable working directory.
+type MutableWorkingDir struct {
+	mu  sync.RWMutex
+	dir string
+}
+
+// NewMutableWorkingDir creates a new MutableWorkingDir with the given initial directory.
+func NewMutableWorkingDir(dir string) *MutableWorkingDir {
+	return &MutableWorkingDir{dir: dir}
+}
+
+// Get returns the current working directory.
+func (w *MutableWorkingDir) Get() string {
+	w.mu.RLock()
+	defer w.mu.RUnlock()
+	return w.dir
+}
+
+// Set updates the working directory.
+func (w *MutableWorkingDir) Set(dir string) {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+	w.dir = dir
+}
+
+// ToolSetConfig contains configuration for creating a ToolSet.
+type ToolSetConfig struct {
+	// WorkingDir is the initial working directory for tools.
+	WorkingDir string
+	// LLMProvider provides access to LLM services for tool validation.
+	LLMProvider LLMServiceProvider
+	// EnableJITInstall enables just-in-time tool installation.
+	EnableJITInstall bool
+	// EnableBrowser enables browser tools.
+	EnableBrowser bool
+	// ModelID is the model being used for this conversation.
+	// Used to determine tool configuration (e.g., simplified patch schema for weaker models).
+	ModelID string
+	// OnWorkingDirChange is called when the working directory changes.
+	// This can be used to persist the change to a database.
+	OnWorkingDirChange func(newDir string)
+}
+
+// ToolSet holds a set of tools for a single conversation.
+// Each conversation should have its own ToolSet.
+type ToolSet struct {
+	tools   []*llm.Tool
+	cleanup func()
+	wd      *MutableWorkingDir
+}
+
+// Tools returns the tools in this set.
+func (ts *ToolSet) Tools() []*llm.Tool {
+	return ts.tools
+}
+
+// Cleanup releases resources held by the tools (e.g., browser).
+func (ts *ToolSet) Cleanup() {
+	if ts.cleanup != nil {
+		ts.cleanup()
+	}
+}
+
+// WorkingDir returns the shared working directory.
+func (ts *ToolSet) WorkingDir() *MutableWorkingDir {
+	return ts.wd
+}
+
+// NewToolSet creates a new set of tools for a conversation.
+// isStrongModel returns true for models that can handle complex tool schemas.
+func isStrongModel(modelID string) bool {
+	lower := strings.ToLower(modelID)
+	return strings.Contains(lower, "sonnet") || strings.Contains(lower, "opus")
+}
+
+func NewToolSet(ctx context.Context, cfg ToolSetConfig) *ToolSet {
+	workingDir := cfg.WorkingDir
+	if workingDir == "" {
+		workingDir = "/"
+	}
+	wd := NewMutableWorkingDir(workingDir)
+
+	bashTool := &BashTool{
+		WorkingDir:       wd,
+		LLMProvider:      cfg.LLMProvider,
+		EnableJITInstall: cfg.EnableJITInstall,
+	}
+
+	// Use simplified patch schema for weaker models, full schema for sonnet/opus
+	simplified := !isStrongModel(cfg.ModelID)
+	patchTool := &PatchTool{
+		Simplified:       simplified,
+		WorkingDir:       wd,
+		ClipboardEnabled: true,
+	}
+
+	keywordTool := NewKeywordToolWithWorkingDir(cfg.LLMProvider, wd)
+
+	changeDirTool := &ChangeDirTool{
+		WorkingDir: wd,
+		OnChange:   cfg.OnWorkingDirChange,
+	}
+
+	tools := []*llm.Tool{
+		Think,
+		bashTool.Tool(),
+		patchTool.Tool(),
+		keywordTool.Tool(),
+		changeDirTool.Tool(),
+	}
+
+	var cleanup func()
+	if cfg.EnableBrowser {
+		browserTools, browserCleanup := browse.RegisterBrowserTools(ctx, true)
+		if len(browserTools) > 0 {
+			tools = append(tools, browserTools...)
+		}
+		cleanup = browserCleanup
+	}
+
+	return &ToolSet{
+		tools:   tools,
+		cleanup: cleanup,
+		wd:      wd,
+	}
+}

cmd/go2ts.go πŸ”—

@@ -0,0 +1,93 @@
+// A command line tool for generating typescript type declarations from go
+// struct types.
+//
+// Example:
+//
+//	go run ./cmd/go2ts -o ui/src/generated-types.ts
+package main
+
+import (
+	"flag"
+	"fmt"
+	"os"
+	"time"
+
+	"go.skia.org/infra/go/go2ts"
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/db/generated"
+	"shelley.exe.dev/llm"
+)
+
+func main() {
+	outputPath := flag.String("o", "", "Path to the output TypeScript file.")
+	flag.Parse()
+
+	if *outputPath == "" {
+		fmt.Println("Usage: go run ./cmd/go2ts -o <output-file>")
+		os.Exit(1)
+	}
+
+	generator := TS()
+
+	w, err := os.Create(*outputPath)
+	if err != nil {
+		fmt.Printf("error: %v\n", err)
+		os.Exit(1)
+	}
+	defer w.Close()
+
+	fmt.Fprintf(w, "// Auto-generated by shelley.exe.dev/cmd/go2ts.go\n")
+	fmt.Fprintf(w, "// Do not edit manually - regenerate with: go run ./cmd/go2ts -o ui/src/generated-types.ts\n\n")
+	generator.Render(w)
+}
+
+// TS returns a Go2TS generator for go types we want to use in TypeScript.
+func TS() *go2ts.Go2TS {
+	generator := go2ts.New()
+
+	// Database message types enum
+	generator.AddMultipleUnion(
+		[]db.MessageType{
+			db.MessageTypeUser,
+			db.MessageTypeAgent,
+			db.MessageTypeTool,
+			db.MessageTypeError,
+			db.MessageTypeSystem,
+		},
+	)
+
+	// Database struct types
+	generator.AddMultiple(
+		generated.Conversation{},
+		llm.Usage{},
+	)
+
+	generator.AddMultiple(
+		apiMessageForTS{},
+		streamResponseForTS{},
+	)
+
+	// Generate clean nominal types
+	generator.GenerateNominalTypes = true
+
+	return generator
+}
+
+type apiMessageForTS struct {
+	MessageID      string    `json:"message_id"`
+	ConversationID string    `json:"conversation_id"`
+	SequenceID     int64     `json:"sequence_id"`
+	Type           string    `json:"type"`
+	LlmData        *string   `json:"llm_data,omitempty"`
+	UserData       *string   `json:"user_data,omitempty"`
+	UsageData      *string   `json:"usage_data,omitempty"`
+	CreatedAt      time.Time `json:"created_at"`
+	DisplayData    *string   `json:"display_data,omitempty"`
+	EndOfTurn      *bool     `json:"end_of_turn,omitempty"`
+}
+
+type streamResponseForTS struct {
+	Messages     []apiMessageForTS      `json:"messages"`
+	Conversation generated.Conversation `json:"conversation"`
+	AgentWorking bool                   `json:"agent_working"`
+}

cmd/shelley/main.go πŸ”—

@@ -0,0 +1,376 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"log/slog"
+	"net"
+	"os"
+	"strconv"
+	"strings"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/models"
+	"shelley.exe.dev/server"
+	"shelley.exe.dev/templates"
+	"shelley.exe.dev/version"
+)
+
+type GlobalConfig struct {
+	DBPath          string
+	Debug           bool
+	Model           string
+	PredictableOnly bool
+	ConfigPath      string
+	TerminalURL     string
+	DefaultModel    string
+}
+
+func main() {
+	// Define global flags
+	var global GlobalConfig
+	defaultModelID := models.Default().ID
+	flag.StringVar(&global.DBPath, "db", "shelley.db", "Path to SQLite database file")
+	flag.BoolVar(&global.Debug, "debug", false, "Enable debug logging")
+	flag.StringVar(&global.Model, "model", defaultModelID, "LLM model to use (use 'predictable' for testing)")
+	flag.BoolVar(&global.PredictableOnly, "predictable-only", false, "Use only the predictable service, ignoring all other models")
+	flag.StringVar(&global.ConfigPath, "config", "", "Path to shelley.json configuration file (optional)")
+	flag.StringVar(&global.DefaultModel, "default-model", defaultModelID, "Default model for web UI")
+
+	// Custom usage function
+	flag.Usage = func() {
+		fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [global-flags] <command> [command-flags]\n\n", os.Args[0])
+		fmt.Fprintf(flag.CommandLine.Output(), "Global flags:\n")
+		flag.PrintDefaults()
+		fmt.Fprintf(flag.CommandLine.Output(), "\nCommands:\n")
+		fmt.Fprintf(flag.CommandLine.Output(), "  serve [flags]                 Start the web server\n")
+		fmt.Fprintf(flag.CommandLine.Output(), "  unpack-template <name> <dir>  Unpack a project template to a directory\n")
+		fmt.Fprintf(flag.CommandLine.Output(), "  version                       Print version information as JSON\n")
+		fmt.Fprintf(flag.CommandLine.Output(), "\nUse '%s <command> -h' for command-specific help\n", os.Args[0])
+	}
+
+	// Parse all flags first
+	flag.Parse()
+	args := flag.Args()
+
+	// Apply seccomp filter early, before spawning any child processes.
+	// This prevents child processes from killing shelley.
+	// Turns out this doesn't work, because it blocks sudo, which we want to work.
+	// if err := seccomp.BlockKillSelf(); err != nil {
+	// 	slog.Info("seccomp filter not installed", "error", err)
+	// }
+
+	if len(args) == 0 {
+		flag.Usage()
+		os.Exit(1)
+	}
+
+	command := args[0]
+	switch command {
+	case "serve":
+		runServe(global, args[1:])
+	case "unpack-template":
+		runUnpackTemplate(args[1:])
+	case "version":
+		runVersion()
+	default:
+		fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
+		flag.Usage()
+		os.Exit(1)
+	}
+}
+
+func runServe(global GlobalConfig, args []string) {
+	fs := flag.NewFlagSet("serve", flag.ExitOnError)
+	port := fs.String("port", "9000", "Port to listen on")
+	systemdActivation := fs.Bool("systemd-activation", false, "Use systemd socket activation (listen on fd from systemd)")
+	requireHeader := fs.String("require-header", "", "Require this header on all API requests (e.g., X-Exedev-Userid)")
+	fs.Parse(args)
+
+	logger := setupLogging(global.Debug)
+
+	database := setupDatabase(global.DBPath, logger)
+	defer database.Close()
+
+	// Set the database path for system prompt generation
+	server.DBPath = global.DBPath
+
+	// Build LLM configuration
+	llmConfig := buildLLMConfig(logger, global.ConfigPath, global.TerminalURL, global.DefaultModel)
+
+	// Create request history for debugging
+	llmHistory := models.NewLLMRequestHistory(10)
+
+	// Initialize LLM service manager
+	llmManager := server.NewLLMServiceManager(llmConfig, llmHistory)
+
+	// Log available models
+	availableModels := llmManager.GetAvailableModels()
+	logger.Info("Available models", "models", strings.Join(availableModels, ", "))
+
+	toolSetConfig := setupToolSetConfig(llmManager)
+
+	// Create server
+	svr := server.NewServer(database, llmManager, toolSetConfig, logger, global.PredictableOnly, llmConfig.TerminalURL, llmConfig.DefaultModel, *requireHeader, llmConfig.Links)
+
+	var err error
+	if *systemdActivation {
+		listener, listenerErr := systemdListener()
+		if listenerErr != nil {
+			logger.Error("Failed to get systemd listener", "error", listenerErr)
+			os.Exit(1)
+		}
+		logger.Info("Using systemd socket activation")
+		err = svr.StartWithListener(listener)
+	} else {
+		err = svr.Start(*port)
+	}
+
+	if err != nil {
+		logger.Error("Server failed", "error", err)
+		os.Exit(1)
+	}
+}
+
+func setupLogging(debug bool) *slog.Logger {
+	logLevel := slog.LevelInfo
+	if debug {
+		logLevel = slog.LevelDebug
+	}
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+		Level: logLevel,
+	}))
+	slog.SetDefault(logger)
+	return logger
+}
+
+func setupDatabase(dbPath string, logger *slog.Logger) *db.DB {
+	database, err := db.New(db.Config{DSN: dbPath})
+	if err != nil {
+		logger.Error("Failed to initialize database", "error", err)
+		os.Exit(1)
+	}
+
+	// Run database migrations
+	if err := database.Migrate(context.Background()); err != nil {
+		logger.Error("Failed to run database migrations", "error", err)
+		os.Exit(1)
+	}
+	logger.Debug("Database migrations completed successfully")
+	return database
+}
+
+// runUnpackTemplate unpacks a project template to a directory
+func runUnpackTemplate(args []string) {
+	fs := flag.NewFlagSet("unpack-template", flag.ExitOnError)
+	fs.Usage = func() {
+		fmt.Fprintf(fs.Output(), "Usage: shelley unpack-template <template-name> <directory>\n\n")
+		fmt.Fprintf(fs.Output(), "Unpacks a project template to the specified directory.\n\n")
+		fmt.Fprintf(fs.Output(), "Available templates:\n")
+		names, err := templates.List()
+		if err != nil {
+			fmt.Fprintf(fs.Output(), "  (error listing templates: %v)\n", err)
+		} else if len(names) == 0 {
+			fmt.Fprintf(fs.Output(), "  (no templates available)\n")
+		} else {
+			for _, name := range names {
+				fmt.Fprintf(fs.Output(), "  %s\n", name)
+			}
+		}
+	}
+	fs.Parse(args)
+
+	if fs.NArg() < 2 {
+		fs.Usage()
+		os.Exit(1)
+	}
+
+	templateName := fs.Arg(0)
+	destDir := fs.Arg(1)
+
+	// Verify template exists
+	names, err := templates.List()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Error listing templates: %v\n", err)
+		os.Exit(1)
+	}
+	found := false
+	for _, name := range names {
+		if name == templateName {
+			found = true
+			break
+		}
+	}
+	if !found {
+		fmt.Fprintf(os.Stderr, "Error: template %q not found\n", templateName)
+		fmt.Fprintf(os.Stderr, "Available templates: %s\n", strings.Join(names, ", "))
+		os.Exit(1)
+	}
+
+	// Create destination directory if it doesn't exist
+	if err := os.MkdirAll(destDir, 0o755); err != nil {
+		fmt.Fprintf(os.Stderr, "Error creating directory %q: %v\n", destDir, err)
+		os.Exit(1)
+	}
+
+	// Unpack the template
+	if err := templates.Unpack(templateName, destDir); err != nil {
+		fmt.Fprintf(os.Stderr, "Error unpacking template: %v\n", err)
+		os.Exit(1)
+	}
+
+	fmt.Printf("Template %q unpacked to %s\n", templateName, destDir)
+}
+
+// runVersion prints version information as JSON
+func runVersion() {
+	info := version.GetInfo()
+	enc := json.NewEncoder(os.Stdout)
+	enc.SetIndent("", "  ")
+	if err := enc.Encode(info); err != nil {
+		fmt.Fprintf(os.Stderr, "Error encoding version: %v\n", err)
+		os.Exit(1)
+	}
+}
+
+func setupToolSetConfig(llmProvider claudetool.LLMServiceProvider) claudetool.ToolSetConfig {
+	wd, err := os.Getwd()
+	if err != nil {
+		// Fallback to "/" if we can't get working directory
+		wd = "/"
+	}
+	return claudetool.ToolSetConfig{
+		WorkingDir:       wd,
+		LLMProvider:      llmProvider,
+		EnableJITInstall: claudetool.EnableBashToolJITInstall,
+		EnableBrowser:    true,
+	}
+}
+
+// buildLLMConfig constructs LLMConfig from environment variables and optional config file
+func buildLLMConfig(logger *slog.Logger, configPath, terminalURL, defaultModel string) *server.LLMConfig {
+	llmCfg := &server.LLMConfig{
+		AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
+		OpenAIAPIKey:    os.Getenv("OPENAI_API_KEY"),
+		GeminiAPIKey:    os.Getenv("GEMINI_API_KEY"),
+		FireworksAPIKey: os.Getenv("FIREWORKS_API_KEY"),
+		TerminalURL:     terminalURL,
+		DefaultModel:    defaultModel,
+		Logger:          logger,
+	}
+
+	if configPath != "" {
+		data, err := os.ReadFile(configPath)
+		if err != nil {
+			if !os.IsNotExist(err) {
+				logger.Warn("Failed to read config file", "path", configPath, "error", err)
+			}
+			return llmCfg
+		}
+
+		var cfg struct {
+			LLMGateway   string        `json:"llm_gateway"`
+			TerminalURL  string        `json:"terminal_url"`
+			DefaultModel string        `json:"default_model"`
+			Links        []server.Link `json:"links"`
+		}
+		if err := json.Unmarshal(data, &cfg); err != nil {
+			logger.Warn("Failed to parse config file", "path", configPath, "error", err)
+			return llmCfg
+		}
+
+		if cfg.LLMGateway != "" {
+			gateway := strings.TrimSuffix(cfg.LLMGateway, "/")
+			llmCfg.Gateway = gateway
+			logger.Info("Using LLM gateway", "gateway", gateway)
+
+			// When using a gateway, default all API keys to "implicit" unless otherwise set
+			if llmCfg.AnthropicAPIKey == "" {
+				llmCfg.AnthropicAPIKey = "implicit"
+			}
+			if llmCfg.OpenAIAPIKey == "" {
+				llmCfg.OpenAIAPIKey = "implicit"
+			}
+			if llmCfg.GeminiAPIKey == "" {
+				llmCfg.GeminiAPIKey = "implicit"
+			}
+			if llmCfg.FireworksAPIKey == "" {
+				llmCfg.FireworksAPIKey = "implicit"
+			}
+		}
+
+		// Override terminal URL from config file if present and not already set via flag
+		if cfg.TerminalURL != "" && llmCfg.TerminalURL == "" {
+			llmCfg.TerminalURL = cfg.TerminalURL
+			logger.Info("Using terminal URL from config", "url", cfg.TerminalURL)
+		}
+
+		// Override default model from config file if present and not already set via flag
+		if cfg.DefaultModel != "" && llmCfg.DefaultModel == "" {
+			llmCfg.DefaultModel = cfg.DefaultModel
+			logger.Info("Using default model from config", "model", cfg.DefaultModel)
+		}
+
+		// Load links from config file if present
+		if len(cfg.Links) > 0 {
+			llmCfg.Links = cfg.Links
+			logger.Info("Loaded links from config", "count", len(cfg.Links))
+		}
+	}
+
+	return llmCfg
+}
+
+// systemdListener returns a net.Listener from systemd socket activation.
+// Systemd passes file descriptors starting at fd 3, with LISTEN_FDS indicating the count.
+func systemdListener() (net.Listener, error) {
+	// Check LISTEN_PID matches our PID (optional but recommended)
+	pidStr := os.Getenv("LISTEN_PID")
+	if pidStr != "" {
+		pid, err := strconv.Atoi(pidStr)
+		if err != nil {
+			return nil, fmt.Errorf("invalid LISTEN_PID: %w", err)
+		}
+		if pid != os.Getpid() {
+			return nil, fmt.Errorf("LISTEN_PID %d does not match current PID %d", pid, os.Getpid())
+		}
+	}
+
+	// Get the number of file descriptors passed
+	fdsStr := os.Getenv("LISTEN_FDS")
+	if fdsStr == "" {
+		return nil, fmt.Errorf("LISTEN_FDS not set; not running under systemd socket activation")
+	}
+	nfds, err := strconv.Atoi(fdsStr)
+	if err != nil {
+		return nil, fmt.Errorf("invalid LISTEN_FDS: %w", err)
+	}
+	if nfds < 1 {
+		return nil, fmt.Errorf("LISTEN_FDS=%d; expected at least 1", nfds)
+	}
+
+	// Systemd passes file descriptors starting at fd 3
+	const listenFDsStart = 3
+	fd := listenFDsStart
+
+	// Create a file from the descriptor
+	f := os.NewFile(uintptr(fd), "systemd-socket")
+	if f == nil {
+		return nil, fmt.Errorf("failed to create file from fd %d", fd)
+	}
+
+	// Create a listener from the file
+	listener, err := net.FileListener(f)
+	if err != nil {
+		f.Close()
+		return nil, fmt.Errorf("failed to create listener from fd %d: %w", fd, err)
+	}
+
+	// Close the original file; the listener now owns the descriptor
+	f.Close()
+
+	return listener, nil
+}

cmd/shelley/main_test.go πŸ”—

@@ -0,0 +1,234 @@
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/slug"
+)
+
+func TestSanitizeSlug(t *testing.T) {
+	tests := []struct {
+		input    string
+		expected string
+	}{
+		{"Simple Test", "simple-test"},
+		{"Create a Python Script", "create-a-python-script"},
+		{"Multiple   Spaces", "multiple-spaces"},
+		{"Special@#$%Characters", "specialcharacters"},
+		{"Under_Score_Test", "under-score-test"},
+		{"--multiple-hyphens--", "multiple-hyphens"},
+		{"CamelCase Example", "camelcase-example"},
+		{"123 Numbers Test 456", "123-numbers-test-456"},
+		{"   leading and trailing   ", "leading-and-trailing"},
+		{"", ""},
+	}
+
+	for _, test := range tests {
+		result := slug.Sanitize(test.input)
+		if result != test.expected {
+			t.Errorf("slug.Sanitize(%q) = %q, expected %q", test.input, result, test.expected)
+		}
+	}
+}
+
+func TestCLICommands(t *testing.T) {
+	// Build the binary once for this test and its subtests
+	tempDir := t.TempDir()
+	binary := filepath.Join(tempDir, "shelley")
+	cmd := exec.Command("go", "build", "-o", binary, ".")
+	if err := cmd.Run(); err != nil {
+		t.Fatalf("Failed to build binary: %v", err)
+	}
+
+	t.Run("help message", func(t *testing.T) {
+		cmd := exec.Command(binary)
+		output, err := cmd.CombinedOutput()
+		if err == nil {
+			t.Fatal("Expected command to fail with no arguments")
+		}
+		outputStr := string(output)
+		if !strings.Contains(outputStr, "Commands:") {
+			t.Errorf("Expected help message, got: %s", outputStr)
+		}
+	})
+
+	t.Run("serve flag parsing", func(t *testing.T) {
+		// Test that serve command accepts flags - we can't easily test the full server
+		// but we can test that it doesn't immediately error on flag parsing
+		cmd := exec.Command(binary, "serve", "-h")
+		output, err := cmd.CombinedOutput()
+		// With flag package, -h should cause exit with code 2
+		if err != nil {
+			if exitError, ok := err.(*exec.ExitError); ok {
+				if exitError.ExitCode() == 2 {
+					// This is expected for -h flag
+					outputStr := string(output)
+					if !strings.Contains(outputStr, "-port") || !strings.Contains(outputStr, "-db") {
+						t.Errorf("Expected serve help to show -port and -db flags, got: %s", outputStr)
+					}
+					if !strings.Contains(outputStr, "-systemd-activation") {
+						t.Errorf("Expected serve help to show -systemd-activation flag, got: %s", outputStr)
+					}
+					return
+				}
+			}
+		}
+		// If no error or different error, that's also fine for this basic test
+		t.Logf("Serve command output: %s", string(output))
+	})
+}
+
+func TestSystemdListenerErrors(t *testing.T) {
+	// Save original environment
+	origPID := os.Getenv("LISTEN_PID")
+	origFDs := os.Getenv("LISTEN_FDS")
+	defer func() {
+		os.Setenv("LISTEN_PID", origPID)
+		os.Setenv("LISTEN_FDS", origFDs)
+	}()
+
+	t.Run("no LISTEN_FDS", func(t *testing.T) {
+		os.Unsetenv("LISTEN_FDS")
+		os.Unsetenv("LISTEN_PID")
+		_, err := systemdListener()
+		if err == nil {
+			t.Fatal("Expected error when LISTEN_FDS not set")
+		}
+		if !strings.Contains(err.Error(), "LISTEN_FDS not set") {
+			t.Errorf("Unexpected error message: %v", err)
+		}
+	})
+
+	t.Run("wrong LISTEN_PID", func(t *testing.T) {
+		os.Setenv("LISTEN_FDS", "1")
+		os.Setenv("LISTEN_PID", "99999999") // Unlikely to match our PID
+		_, err := systemdListener()
+		if err == nil {
+			t.Fatal("Expected error when LISTEN_PID doesn't match")
+		}
+		if !strings.Contains(err.Error(), "does not match current PID") {
+			t.Errorf("Unexpected error message: %v", err)
+		}
+	})
+
+	t.Run("invalid LISTEN_FDS", func(t *testing.T) {
+		os.Setenv("LISTEN_FDS", "notanumber")
+		os.Unsetenv("LISTEN_PID")
+		_, err := systemdListener()
+		if err == nil {
+			t.Fatal("Expected error when LISTEN_FDS is invalid")
+		}
+		if !strings.Contains(err.Error(), "invalid LISTEN_FDS") {
+			t.Errorf("Unexpected error message: %v", err)
+		}
+	})
+
+	t.Run("zero LISTEN_FDS", func(t *testing.T) {
+		os.Setenv("LISTEN_FDS", "0")
+		os.Unsetenv("LISTEN_PID")
+		_, err := systemdListener()
+		if err == nil {
+			t.Fatal("Expected error when LISTEN_FDS is 0")
+		}
+		if !strings.Contains(err.Error(), "expected at least 1") {
+			t.Errorf("Unexpected error message: %v", err)
+		}
+	})
+}
+
+func TestSystemdListenerIntegration(t *testing.T) {
+	// This test simulates what systemd does: create a listener, get the fd,
+	// and pass it to a child process via environment and fd inheritance.
+	// Since we can't easily test in-process (fd 3 is likely already in use),
+	// we test by spawning a subprocess.
+
+	tempDir := t.TempDir()
+	binary := filepath.Join(tempDir, "shelley")
+	cmd := exec.Command("go", "build", "-o", binary, ".")
+	if err := cmd.Run(); err != nil {
+		t.Fatalf("Failed to build binary: %v", err)
+	}
+
+	// Create a listener on a random port
+	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("Failed to create listener: %v", err)
+	}
+	port := listener.Addr().(*net.TCPAddr).Port
+
+	// Get the file descriptor from the listener
+	tcpListener := listener.(*net.TCPListener)
+	file, err := tcpListener.File()
+	if err != nil {
+		listener.Close()
+		t.Fatalf("Failed to get file from listener: %v", err)
+	}
+	listener.Close() // Close original listener, file still holds the socket
+
+	// Create a temp database for the test
+	dbPath := filepath.Join(tempDir, "test.db")
+
+	// Spawn shelley with the file descriptor as fd 3
+	// Note: We don't set LISTEN_PID here because we don't know the child PID yet.
+	// The systemdListener function handles missing LISTEN_PID gracefully.
+	cmd = exec.Command(binary, "-db", dbPath, "serve", "-systemd-activation")
+	// Build environment without LISTEN_PID (will be inherited from parent otherwise)
+	// and add LISTEN_FDS=1
+	env := make([]string, 0, len(os.Environ()))
+	for _, e := range os.Environ() {
+		if !strings.HasPrefix(e, "LISTEN_PID=") {
+			env = append(env, e)
+		}
+	}
+	env = append(env, "LISTEN_FDS=1")
+	cmd.Env = env
+	cmd.ExtraFiles = []*os.File{file} // This makes the file fd 3 in the child
+	var stderrBuf, stdoutBuf bytes.Buffer
+	cmd.Stdout = &stdoutBuf
+	cmd.Stderr = &stderrBuf
+
+	// Start the process
+	if err := cmd.Start(); err != nil {
+		file.Close()
+		t.Fatalf("Failed to start shelley: %v", err)
+	}
+	file.Close() // Close our copy after child inherits it
+
+	// Wait a bit for the server to start
+	time.Sleep(500 * time.Millisecond)
+
+	// Try to connect to the server
+	var resp *http.Response
+	client := &http.Client{Timeout: 2 * time.Second}
+	for i := 0; i < 10; i++ {
+		resp, err = client.Get(fmt.Sprintf("http://127.0.0.1:%d/version", port))
+		if err == nil {
+			break
+		}
+		time.Sleep(100 * time.Millisecond)
+	}
+
+	// Kill the server
+	cmd.Process.Kill()
+	cmd.Wait()
+
+	if err != nil {
+		t.Fatalf("Failed to connect to server: %v\nstdout: %s\nstderr: %s", err, stdoutBuf.String(), stderrBuf.String())
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(resp.Body)
+		t.Errorf("Unexpected status code %d, body: %s", resp.StatusCode, body)
+	}
+}

cmd/shelley/prompt.go πŸ”—

@@ -0,0 +1,77 @@
+package main
+
+import (
+	_ "embed"
+	"fmt"
+	"os"
+	"os/exec"
+	"strings"
+	"text/template"
+)
+
+//go:embed prompt.txt
+var promptTemplate string
+
+// SystemPromptData contains all the data needed to render the system prompt template
+type SystemPromptData struct {
+	WorkingDirectory string
+	GitInfo          *GitInfo
+}
+
+type GitInfo struct {
+	Root string
+}
+
+// GenerateSystemPrompt generates the system prompt using the embedded template
+func GenerateSystemPrompt() (string, error) {
+	data, err := collectSystemData()
+	if err != nil {
+		return "", fmt.Errorf("failed to collect system data: %w", err)
+	}
+
+	tmpl, err := template.New("system_prompt").Parse(promptTemplate)
+	if err != nil {
+		return "", fmt.Errorf("failed to parse template: %w", err)
+	}
+
+	var buf strings.Builder
+	err = tmpl.Execute(&buf, data)
+	if err != nil {
+		return "", fmt.Errorf("failed to execute template: %w", err)
+	}
+
+	return buf.String(), nil
+}
+
+func collectSystemData() (*SystemPromptData, error) {
+	wd, err := os.Getwd()
+	if err != nil {
+		return nil, fmt.Errorf("failed to get working directory: %w", err)
+	}
+
+	data := &SystemPromptData{
+		WorkingDirectory: wd,
+	}
+
+	// Try to collect git info
+	gitInfo, err := collectGitInfo()
+	if err == nil {
+		data.GitInfo = gitInfo
+	}
+
+	return data, nil
+}
+
+func collectGitInfo() (*GitInfo, error) {
+	// Find git root
+	rootCmd := exec.Command("git", "rev-parse", "--show-toplevel")
+	rootOutput, err := rootCmd.Output()
+	if err != nil {
+		return nil, err
+	}
+	root := strings.TrimSpace(string(rootOutput))
+
+	return &GitInfo{
+		Root: root,
+	}, nil
+}

cmd/shelley/prompt.txt πŸ”—

@@ -0,0 +1,12 @@
+You are Shelley, a coding agent and assistant. You are an experienced software engineer and architect. You communicate with brevity.
+
+You have access to a variety of tools to get your job done. Be persistent and creative.
+
+Working directory: {{.WorkingDirectory}}
+
+{{if .GitInfo}}
+Git repository root: {{.GitInfo.Root}}
+
+If you are making code changes, make commits with good commit messages before returning to the user.
+{{else}}Not in a git repository.
+{{end}}

cmd/shelley/seccomp_test.go πŸ”—

@@ -0,0 +1,108 @@
+//go:build linux
+
+package main
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"strconv"
+	"strings"
+	"testing"
+
+	"shelley.exe.dev/seccomp"
+)
+
+// TestSeccompIntegration tests that the seccomp filter is installed
+// automatically and prevents child processes from killing the parent.
+func TestSeccompIntegration(t *testing.T) {
+	if os.Getenv("TEST_SECCOMP_HELPER") == "1" {
+		runSeccompHelper(t)
+		return
+	}
+
+	// Re-exec this test in a subprocess
+	cmd := exec.Command(os.Args[0], "-test.run=TestSeccompIntegration$", "-test.v")
+	cmd.Env = append(os.Environ(), "TEST_SECCOMP_HELPER=1")
+	output, err := cmd.CombinedOutput()
+	t.Logf("Helper output:\n%s", output)
+	if err != nil {
+		t.Fatalf("Helper failed: %v", err)
+	}
+}
+
+func runSeccompHelper(t *testing.T) {
+	pid := os.Getpid()
+	t.Logf("Helper PID: %d", pid)
+
+	// Install seccomp filter (same as -seccomp flag does in main)
+	if err := seccomp.BlockKillSelf(); err != nil {
+		t.Fatalf("BlockKillSelf failed: %v", err)
+	}
+	t.Log("Seccomp filter installed")
+
+	// Spawn a child that tries to kill us
+	script := fmt.Sprintf("kill -TERM %d 2>&1; echo exit=$?", pid)
+	cmd := exec.Command("sh", "-c", script)
+	output, _ := cmd.CombinedOutput()
+	t.Logf("Kill attempt output: %s", output)
+
+	// Verify the kill was blocked (output should contain "Operation not permitted" or exit=1)
+	outStr := string(output)
+	if !strings.Contains(outStr, "Operation not permitted") && !strings.Contains(outStr, "exit=1") {
+		t.Fatalf("Expected kill to fail with Operation not permitted, got: %s", outStr)
+	}
+
+	t.Log("SUCCESS: Child's kill attempt was blocked")
+}
+
+// TestSeccompPreservesKillOthers verifies that with seccomp enabled,
+// we can still kill other processes (not ourselves).
+func TestSeccompPreservesKillOthers(t *testing.T) {
+	if os.Getenv("TEST_SECCOMP_KILL_OTHERS") == "1" {
+		runSeccompKillOthersHelper(t)
+		return
+	}
+
+	// Re-exec this test in a subprocess
+	cmd := exec.Command(os.Args[0], "-test.run=TestSeccompPreservesKillOthers$", "-test.v")
+	cmd.Env = append(os.Environ(), "TEST_SECCOMP_KILL_OTHERS=1")
+	output, err := cmd.CombinedOutput()
+	t.Logf("Helper output:\n%s", output)
+	if err != nil {
+		t.Fatalf("Helper failed: %v", err)
+	}
+}
+
+func runSeccompKillOthersHelper(t *testing.T) {
+	// Install seccomp filter
+	if err := seccomp.BlockKillSelf(); err != nil {
+		t.Fatalf("BlockKillSelf failed: %v", err)
+	}
+	t.Log("Seccomp filter installed")
+
+	// Start a sleep process
+	sleepCmd := exec.Command("sleep", "60")
+	if err := sleepCmd.Start(); err != nil {
+		t.Fatalf("Failed to start sleep: %v", err)
+	}
+	sleepPid := sleepCmd.Process.Pid
+	t.Logf("Started sleep process with PID %d", sleepPid)
+
+	// Kill the sleep process via a child shell - this should work
+	script := fmt.Sprintf("kill -TERM %d 2>&1; echo exit=$?", sleepPid)
+	cmd := exec.Command("sh", "-c", script)
+	output, _ := cmd.CombinedOutput()
+	t.Logf("Kill output: %s", output)
+
+	// Verify the sleep process was killed (exit=0)
+	if !strings.Contains(string(output), "exit=0") {
+		t.Fatalf("Expected kill to succeed, got: %s", output)
+	}
+
+	sleepCmd.Wait()
+	t.Log("SUCCESS: Killing other processes still works")
+}
+
+// Silence unused import warning
+var _ = strconv.Itoa

db/README.md πŸ”—

@@ -0,0 +1,36 @@
+# Database Package
+
+This package provides database operations for the Shelley AI coding agent using SQLite and sqlc.
+
+## Architecture
+
+The database contains two main entities:
+
+- **Conversations**: Represent individual chat sessions with the AI agent
+- **Messages**: Individual messages within conversations (user, agent, or tool messages)
+
+## Testing
+
+Run tests with:
+
+```bash
+go test -v ./db/...
+```
+
+The tests use in-memory SQLite databases and cover all major operations including:
+
+- CRUD operations for conversations and messages
+- Pagination and search functionality
+- JSON data marshalling/unmarshalling
+- Foreign key constraints
+- Transaction handling
+
+## Code Generation
+
+This package uses [sqlc](https://sqlc.dev/) to generate type-safe Go code from SQL queries.
+
+To regenerate code after modifying SQL:
+
+```bash
+go run github.com/sqlc-dev/sqlc/cmd/sqlc generate
+```

db/conversations_test.go πŸ”—

@@ -0,0 +1,409 @@
+package db
+
+import (
+	"context"
+	"strings"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/db/generated"
+)
+
+func TestConversationService_Create(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	tests := []struct {
+		name string
+		slug *string
+	}{
+		{
+			name: "with slug",
+			slug: stringPtr("test-conversation"),
+		},
+		{
+			name: "without slug",
+			slug: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			conv, err := db.CreateConversation(ctx, tt.slug, true, nil)
+			if err != nil {
+				t.Errorf("Create() error = %v", err)
+				return
+			}
+
+			if conv.ConversationID == "" {
+				t.Error("Expected non-empty conversation ID")
+			}
+
+			if tt.slug != nil {
+				if conv.Slug == nil || *conv.Slug != *tt.slug {
+					t.Errorf("Expected slug %v, got %v", tt.slug, conv.Slug)
+				}
+			} else {
+				if conv.Slug != nil {
+					t.Errorf("Expected nil slug, got %v", conv.Slug)
+				}
+			}
+
+			if conv.CreatedAt.IsZero() {
+				t.Error("Expected non-zero created_at time")
+			}
+
+			if conv.UpdatedAt.IsZero() {
+				t.Error("Expected non-zero updated_at time")
+			}
+		})
+	}
+}
+
+func TestConversationService_GetByID(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create a test conversation
+	created, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create test conversation: %v", err)
+	}
+
+	// Test getting existing conversation
+	conv, err := db.GetConversationByID(ctx, created.ConversationID)
+	if err != nil {
+		t.Errorf("GetByID() error = %v", err)
+		return
+	}
+
+	if conv.ConversationID != created.ConversationID {
+		t.Errorf("Expected conversation ID %s, got %s", created.ConversationID, conv.ConversationID)
+	}
+
+	// Test getting non-existent conversation
+	_, err = db.GetConversationByID(ctx, "non-existent")
+	if err == nil {
+		t.Error("Expected error for non-existent conversation")
+	}
+	if !strings.Contains(err.Error(), "not found") {
+		t.Errorf("Expected 'not found' in error message, got: %v", err)
+	}
+}
+
+func TestConversationService_GetBySlug(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create a test conversation with slug
+	created, err := db.CreateConversation(ctx, stringPtr("test-slug"), true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create test conversation: %v", err)
+	}
+
+	// Test getting by existing slug
+	conv, err := db.GetConversationBySlug(ctx, "test-slug")
+	if err != nil {
+		t.Errorf("GetBySlug() error = %v", err)
+		return
+	}
+
+	if conv.ConversationID != created.ConversationID {
+		t.Errorf("Expected conversation ID %s, got %s", created.ConversationID, conv.ConversationID)
+	}
+
+	// Test getting by non-existent slug
+	_, err = db.GetConversationBySlug(ctx, "non-existent-slug")
+	if err == nil {
+		t.Error("Expected error for non-existent slug")
+	}
+	if !strings.Contains(err.Error(), "not found") {
+		t.Errorf("Expected 'not found' in error message, got: %v", err)
+	}
+}
+
+func TestConversationService_UpdateSlug(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create a test conversation
+	created, err := db.CreateConversation(ctx, nil, true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create test conversation: %v", err)
+	}
+
+	// Update the slug
+	newSlug := "updated-slug"
+	updated, err := db.UpdateConversationSlug(ctx, created.ConversationID, newSlug)
+	if err != nil {
+		t.Errorf("UpdateSlug() error = %v", err)
+		return
+	}
+
+	if updated.Slug == nil || *updated.Slug != newSlug {
+		t.Errorf("Expected slug %s, got %v", newSlug, updated.Slug)
+	}
+
+	// Note: SQLite CURRENT_TIMESTAMP has second precision, so we check >= instead of >
+	if updated.UpdatedAt.Before(created.UpdatedAt) {
+		t.Errorf("Expected updated_at %v to be >= created updated_at %v", updated.UpdatedAt, created.UpdatedAt)
+	}
+}
+
+func TestConversationService_List(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create multiple test conversations
+	for i := 0; i < 5; i++ {
+		slug := stringPtr("conversation-" + string(rune('a'+i)))
+		_, err := db.CreateConversation(ctx, slug, true, nil)
+		if err != nil {
+			t.Fatalf("Failed to create test conversation %d: %v", i, err)
+		}
+	}
+
+	// Test listing with pagination
+	conversations, err := db.ListConversations(ctx, 3, 0)
+	if err != nil {
+		t.Errorf("List() error = %v", err)
+		return
+	}
+
+	if len(conversations) != 3 {
+		t.Errorf("Expected 3 conversations, got %d", len(conversations))
+	}
+
+	// The query orders by updated_at DESC, but without sleeps all timestamps
+	// may be identical, so we just verify we got the expected count
+}
+
+func TestConversationService_Search(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create test conversations with different slugs
+	testCases := []string{"project-alpha", "project-beta", "work-task", "personal-note"}
+	for _, slug := range testCases {
+		_, err := db.CreateConversation(ctx, stringPtr(slug), true, nil)
+		if err != nil {
+			t.Fatalf("Failed to create test conversation with slug %s: %v", slug, err)
+		}
+	}
+
+	// Search for "project" should return 2 conversations
+	results, err := db.SearchConversations(ctx, "project", 10, 0)
+	if err != nil {
+		t.Errorf("Search() error = %v", err)
+		return
+	}
+
+	if len(results) != 2 {
+		t.Errorf("Expected 2 search results, got %d", len(results))
+	}
+
+	// Verify the results contain "project"
+	for _, conv := range results {
+		if conv.Slug == nil || !strings.Contains(*conv.Slug, "project") {
+			t.Errorf("Expected conversation slug to contain 'project', got %v", conv.Slug)
+		}
+	}
+}
+
+func TestConversationService_Touch(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create a test conversation
+	created, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create test conversation: %v", err)
+	}
+
+	// Touch the conversation
+	err = db.QueriesTx(ctx, func(q *generated.Queries) error {
+		return q.UpdateConversationTimestamp(ctx, created.ConversationID)
+	})
+	if err != nil {
+		t.Errorf("Touch() error = %v", err)
+		return
+	}
+
+	// Verify updated_at was changed
+	updated, err := db.GetConversationByID(ctx, created.ConversationID)
+	if err != nil {
+		t.Fatalf("Failed to get conversation after touch: %v", err)
+	}
+
+	// Note: SQLite CURRENT_TIMESTAMP has second precision, so we check >= instead of >
+	if updated.UpdatedAt.Before(created.UpdatedAt) {
+		t.Errorf("Expected updated_at %v to be >= created updated_at %v", updated.UpdatedAt, created.UpdatedAt)
+	}
+}
+
+func TestConversationService_Delete(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create a test conversation
+	created, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create test conversation: %v", err)
+	}
+
+	// Delete the conversation
+	err = db.QueriesTx(ctx, func(q *generated.Queries) error {
+		return q.DeleteConversation(ctx, created.ConversationID)
+	})
+	if err != nil {
+		t.Errorf("Delete() error = %v", err)
+		return
+	}
+
+	// Verify it's gone
+	_, err = db.GetConversationByID(ctx, created.ConversationID)
+	if err == nil {
+		t.Error("Expected error when getting deleted conversation")
+	}
+}
+
+func TestConversationService_Count(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Initial count should be 0
+	var count int64
+	err := db.Queries(ctx, func(q *generated.Queries) error {
+		var err error
+		count, err = q.CountConversations(ctx)
+		return err
+	})
+	if err != nil {
+		t.Errorf("Count() error = %v", err)
+		return
+	}
+	if count != 0 {
+		t.Errorf("Expected initial count 0, got %d", count)
+	}
+
+	// Create test conversations
+	for i := 0; i < 3; i++ {
+		_, err := db.CreateConversation(ctx, stringPtr("conversation-"+string(rune('a'+i))), true, nil)
+		if err != nil {
+			t.Fatalf("Failed to create test conversation %d: %v", i, err)
+		}
+	}
+
+	// Count should now be 3
+	err = db.Queries(ctx, func(q *generated.Queries) error {
+		var err error
+		count, err = q.CountConversations(ctx)
+		return err
+	})
+	if err != nil {
+		t.Errorf("Count() error = %v", err)
+		return
+	}
+	if count != 3 {
+		t.Errorf("Expected count 3, got %d", count)
+	}
+}
+
+func TestConversationService_MultipleNullSlugs(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create multiple conversations with null slugs - this should not fail
+	conv1, err := db.CreateConversation(ctx, nil, true, nil)
+	if err != nil {
+		t.Errorf("Create() first conversation error = %v", err)
+		return
+	}
+
+	conv2, err := db.CreateConversation(ctx, nil, true, nil)
+	if err != nil {
+		t.Errorf("Create() second conversation error = %v", err)
+		return
+	}
+
+	// Both should have null slugs
+	if conv1.Slug != nil {
+		t.Errorf("Expected first conversation slug to be nil, got %v", conv1.Slug)
+	}
+	if conv2.Slug != nil {
+		t.Errorf("Expected second conversation slug to be nil, got %v", conv2.Slug)
+	}
+
+	// They should have different IDs
+	if conv1.ConversationID == conv2.ConversationID {
+		t.Error("Expected different conversation IDs")
+	}
+}
+
+func TestConversationService_SlugUniquenessWhenNotNull(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create first conversation with a slug
+	_, err := db.CreateConversation(ctx, stringPtr("unique-slug"), true, nil)
+	if err != nil {
+		t.Errorf("Create() first conversation error = %v", err)
+		return
+	}
+
+	// Try to create second conversation with the same slug - this should fail
+	_, err = db.CreateConversation(ctx, stringPtr("unique-slug"), true, nil)
+	if err == nil {
+		t.Error("Expected error when creating conversation with duplicate slug")
+		return
+	}
+
+	// Verify the error is related to uniqueness constraint
+	if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
+		t.Errorf("Expected UNIQUE constraint error, got: %v", err)
+	}
+}

db/db.go πŸ”—

@@ -0,0 +1,579 @@
+// Package db provides database operations for the Shelley AI coding agent.
+package db
+
+//go:generate go tool github.com/sqlc-dev/sqlc/cmd/sqlc generate -f ../sqlc.yaml
+
+import (
+	"context"
+	"crypto/rand"
+	"database/sql"
+	"embed"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+
+	"github.com/google/uuid"
+	"shelley.exe.dev/db/generated"
+
+	_ "modernc.org/sqlite"
+)
+
+//go:embed schema/*.sql
+var schemaFS embed.FS
+
+// generateConversationID generates a conversation ID in the format "cXXXXXX"
+// where X are random alphanumeric characters
+func generateConversationID() (string, error) {
+	text := rand.Text()
+	if len(text) < 6 {
+		return "", fmt.Errorf("rand.Text() returned insufficient characters: %d", len(text))
+	}
+	return "c" + text[:6], nil
+}
+
+// DB wraps the database connection pool and provides high-level operations
+type DB struct {
+	pool *Pool
+}
+
+// Config holds database configuration
+type Config struct {
+	DSN string // Data Source Name for SQLite database
+}
+
+// New creates a new database connection with the given configuration
+func New(cfg Config) (*DB, error) {
+	if cfg.DSN == "" {
+		return nil, fmt.Errorf("database DSN cannot be empty")
+	}
+
+	if cfg.DSN == ":memory:" {
+		return nil, fmt.Errorf(":memory: database not supported (requires multiple connections); use a temp file")
+	}
+
+	// Ensure directory exists for file-based SQLite databases
+	if cfg.DSN != ":memory:" {
+		dir := filepath.Dir(cfg.DSN)
+		if dir != "." && dir != "" {
+			if err := os.MkdirAll(dir, 0o755); err != nil {
+				return nil, fmt.Errorf("failed to create database directory: %w", err)
+			}
+		}
+	}
+
+	// Create connection pool with 3 readers
+	dsn := cfg.DSN
+	if !strings.Contains(dsn, "?") {
+		dsn += "?_foreign_keys=on"
+	} else if !strings.Contains(dsn, "_foreign_keys") {
+		dsn += "&_foreign_keys=on"
+	}
+
+	pool, err := NewPool(dsn, 3)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create connection pool: %w", err)
+	}
+
+	return &DB{
+		pool: pool,
+	}, nil
+}
+
+// Close closes the database connection pool
+func (db *DB) Close() error {
+	return db.pool.Close()
+}
+
+// Migrate runs the database migrations
+func (db *DB) Migrate(ctx context.Context) error {
+	// Read all migration files
+	entries, err := schemaFS.ReadDir("schema")
+	if err != nil {
+		return fmt.Errorf("failed to read schema directory: %w", err)
+	}
+
+	// Filter and validate migration files
+	var migrations []string
+	migrationPattern := regexp.MustCompile(`^(\d{3})-.*\.sql$`)
+	for _, entry := range entries {
+		if entry.IsDir() {
+			continue
+		}
+		if !migrationPattern.MatchString(entry.Name()) {
+			continue
+		}
+		migrations = append(migrations, entry.Name())
+	}
+
+	// Sort migrations by number
+	sort.Strings(migrations)
+
+	// Get executed migrations
+	executedMigrations := make(map[int]bool)
+	var tableName string
+	err = db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+		row := rx.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'")
+		return row.Scan(&tableName)
+	})
+
+	if err == nil {
+		// Migrations table exists, load executed migrations
+		err = db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+			rows, err := rx.Query("SELECT migration_number FROM migrations")
+			if err != nil {
+				return fmt.Errorf("failed to query executed migrations: %w", err)
+			}
+			defer rows.Close()
+
+			for rows.Next() {
+				var migrationNumber int
+				if err := rows.Scan(&migrationNumber); err != nil {
+					return fmt.Errorf("failed to scan migration number: %w", err)
+				}
+				executedMigrations[migrationNumber] = true
+			}
+			return rows.Err()
+		})
+		if err != nil {
+			return fmt.Errorf("failed to load executed migrations: %w", err)
+		}
+	} else if !errors.Is(err, sql.ErrNoRows) {
+		// Migrations table doesn't exist - executedMigrations remains empty
+		slog.Info("migrations table not found, running all migrations")
+	}
+
+	// Run any migrations that haven't been executed
+	for _, migration := range migrations {
+		// Extract migration number from filename (e.g., "001-base.sql" -> 001)
+		matches := migrationPattern.FindStringSubmatch(migration)
+		if len(matches) != 2 {
+			return fmt.Errorf("invalid migration filename format: %s", migration)
+		}
+
+		migrationNumber, err := strconv.Atoi(matches[1])
+		if err != nil {
+			return fmt.Errorf("failed to parse migration number from %s: %w", migration, err)
+		}
+
+		if !executedMigrations[migrationNumber] {
+			slog.Info("running migration", "file", migration, "number", migrationNumber)
+			if err := db.executeMigration(ctx, migration); err != nil {
+				return fmt.Errorf("failed to execute migration %s: %w", migration, err)
+			}
+
+			err = db.pool.Exec(ctx, "INSERT INTO migrations (migration_number, migration_name) VALUES (?, ?)", migrationNumber, migration)
+			if err != nil {
+				return fmt.Errorf("failed to record migration %s in migrations table: %w", migration, err)
+			}
+		}
+	}
+
+	return nil
+}
+
+// executeMigration executes a single migration file
+func (db *DB) executeMigration(ctx context.Context, filename string) error {
+	content, err := schemaFS.ReadFile("schema/" + filename)
+	if err != nil {
+		return fmt.Errorf("failed to read migration file %s: %w", filename, err)
+	}
+
+	if err := db.pool.Exec(ctx, string(content)); err != nil {
+		return fmt.Errorf("failed to execute migration %s: %w", filename, err)
+	}
+
+	return nil
+}
+
+// Pool returns the underlying connection pool for advanced operations
+func (db *DB) Pool() *Pool {
+	return db.pool
+}
+
+// WithTx runs a function within a database transaction
+func (db *DB) WithTx(ctx context.Context, fn func(*generated.Queries) error) error {
+	return db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+		queries := generated.New(tx.Conn())
+		return fn(queries)
+	})
+}
+
+// WithTxRes runs a function within a database transaction and returns a value
+func WithTxRes[T any](db *DB, ctx context.Context, fn func(*generated.Queries) (T, error)) (T, error) {
+	var result T
+	err := db.WithTx(ctx, func(queries *generated.Queries) error {
+		var err error
+		result, err = fn(queries)
+		return err
+	})
+	return result, err
+}
+
+// Conversation methods (moved from ConversationService)
+
+// CreateConversation creates a new conversation with an optional slug
+func (db *DB) CreateConversation(ctx context.Context, slug *string, userInitiated bool, cwd *string) (*generated.Conversation, error) {
+	conversationID, err := generateConversationID()
+	if err != nil {
+		return nil, fmt.Errorf("failed to generate conversation ID: %w", err)
+	}
+	var conversation generated.Conversation
+	err = db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+		q := generated.New(tx.Conn())
+		conversation, err = q.CreateConversation(ctx, generated.CreateConversationParams{
+			ConversationID: conversationID,
+			Slug:           slug,
+			UserInitiated:  userInitiated,
+			Cwd:            cwd,
+		})
+		return err
+	})
+	return &conversation, err
+}
+
+// GetConversationByID retrieves a conversation by its ID
+func (db *DB) GetConversationByID(ctx context.Context, conversationID string) (*generated.Conversation, error) {
+	var conversation generated.Conversation
+	err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+		q := generated.New(rx.Conn())
+		var err error
+		conversation, err = q.GetConversation(ctx, conversationID)
+		return err
+	})
+	if err == sql.ErrNoRows {
+		return nil, fmt.Errorf("conversation not found: %s", conversationID)
+	}
+	return &conversation, err
+}
+
+// GetConversationBySlug retrieves a conversation by its slug
+func (db *DB) GetConversationBySlug(ctx context.Context, slug string) (*generated.Conversation, error) {
+	var conversation generated.Conversation
+	err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+		q := generated.New(rx.Conn())
+		var err error
+		conversation, err = q.GetConversationBySlug(ctx, &slug)
+		return err
+	})
+	if err == sql.ErrNoRows {
+		return nil, fmt.Errorf("conversation not found with slug: %s", slug)
+	}
+	return &conversation, err
+}
+
+// ListConversations retrieves conversations with pagination
+func (db *DB) ListConversations(ctx context.Context, limit, offset int64) ([]generated.Conversation, error) {
+	var conversations []generated.Conversation
+	err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+		q := generated.New(rx.Conn())
+		var err error
+		conversations, err = q.ListConversations(ctx, generated.ListConversationsParams{
+			Limit:  limit,
+			Offset: offset,
+		})
+		return err
+	})
+	return conversations, err
+}
+
+// SearchConversations searches for conversations containing the given query in their slug
+func (db *DB) SearchConversations(ctx context.Context, query string, limit, offset int64) ([]generated.Conversation, error) {
+	queryPtr := &query
+	var conversations []generated.Conversation
+	err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+		q := generated.New(rx.Conn())
+		var err error
+		conversations, err = q.SearchConversations(ctx, generated.SearchConversationsParams{
+			Column1: queryPtr,
+			Limit:   limit,
+			Offset:  offset,
+		})
+		return err
+	})
+	return conversations, err
+}
+
+// UpdateConversationSlug updates the slug of a conversation
+func (db *DB) UpdateConversationSlug(ctx context.Context, conversationID, slug string) (*generated.Conversation, error) {
+	var conversation generated.Conversation
+	err := db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+		q := generated.New(tx.Conn())
+		var err error
+		conversation, err = q.UpdateConversationSlug(ctx, generated.UpdateConversationSlugParams{
+			Slug:           &slug,
+			ConversationID: conversationID,
+		})
+		return err
+	})
+	return &conversation, err
+}
+
+// UpdateConversationCwd updates the working directory for a conversation
+func (db *DB) UpdateConversationCwd(ctx context.Context, conversationID, cwd string) error {
+	return db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+		q := generated.New(tx.Conn())
+		_, err := q.UpdateConversationCwd(ctx, generated.UpdateConversationCwdParams{
+			Cwd:            &cwd,
+			ConversationID: conversationID,
+		})
+		return err
+	})
+}
+
+// Message methods (moved from MessageService)
+
+// MessageType represents the type of message
+type MessageType string
+
+const (
+	MessageTypeUser   MessageType = "user"
+	MessageTypeAgent  MessageType = "agent"
+	MessageTypeTool   MessageType = "tool"
+	MessageTypeSystem MessageType = "system"
+	MessageTypeError  MessageType = "error"
+)
+
+// CreateMessageParams contains parameters for creating a message
+type CreateMessageParams struct {
+	ConversationID string
+	Type           MessageType
+	LLMData        interface{} // Will be JSON marshalled
+	UserData       interface{} // Will be JSON marshalled
+	UsageData      interface{} // Will be JSON marshalled
+	DisplayData    interface{} // Will be JSON marshalled, tool-specific display content
+}
+
+// CreateMessage creates a new message
+func (db *DB) CreateMessage(ctx context.Context, params CreateMessageParams) (*generated.Message, error) {
+	messageID := uuid.New().String()
+
+	// Marshal JSON fields
+	var llmDataJSON, userDataJSON, usageDataJSON, displayDataJSON *string
+
+	if params.LLMData != nil {
+		data, err := json.Marshal(params.LLMData)
+		if err != nil {
+			return nil, fmt.Errorf("failed to marshal LLM data: %w", err)
+		}
+		str := string(data)
+		llmDataJSON = &str
+	}
+
+	if params.UserData != nil {
+		data, err := json.Marshal(params.UserData)
+		if err != nil {
+			return nil, fmt.Errorf("failed to marshal user data: %w", err)
+		}
+		str := string(data)
+		userDataJSON = &str
+	}
+
+	if params.UsageData != nil {
+		data, err := json.Marshal(params.UsageData)
+		if err != nil {
+			return nil, fmt.Errorf("failed to marshal usage data: %w", err)
+		}
+		str := string(data)
+		usageDataJSON = &str
+	}
+
+	if params.DisplayData != nil {
+		data, err := json.Marshal(params.DisplayData)
+		if err != nil {
+			return nil, fmt.Errorf("failed to marshal display data: %w", err)
+		}
+		str := string(data)
+		displayDataJSON = &str
+	}
+
+	var message generated.Message
+	err := db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+		q := generated.New(tx.Conn())
+
+		// Get next sequence_id for this conversation
+		sequenceID, err := q.GetNextSequenceID(ctx, params.ConversationID)
+		if err != nil {
+			return fmt.Errorf("failed to get next sequence ID: %w", err)
+		}
+
+		message, err = q.CreateMessage(ctx, generated.CreateMessageParams{
+			MessageID:      messageID,
+			ConversationID: params.ConversationID,
+			SequenceID:     sequenceID,
+			Type:           string(params.Type),
+			LlmData:        llmDataJSON,
+			UserData:       userDataJSON,
+			UsageData:      usageDataJSON,
+			DisplayData:    displayDataJSON,
+		})
+		return err
+	})
+	return &message, err
+}
+
+// GetMessageByID retrieves a message by its ID
+func (db *DB) GetMessageByID(ctx context.Context, messageID string) (*generated.Message, error) {
+	var message generated.Message
+	err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+		q := generated.New(rx.Conn())
+		var err error
+		message, err = q.GetMessage(ctx, messageID)
+		return err
+	})
+	if err == sql.ErrNoRows {
+		return nil, fmt.Errorf("message not found: %s", messageID)
+	}
+	return &message, err
+}
+
+// ListMessagesByConversationPaginated retrieves messages in a conversation with pagination
+func (db *DB) ListMessagesByConversationPaginated(ctx context.Context, conversationID string, limit, offset int64) ([]generated.Message, error) {
+	var messages []generated.Message
+	err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+		q := generated.New(rx.Conn())
+		var err error
+		messages, err = q.ListMessagesPaginated(ctx, generated.ListMessagesPaginatedParams{
+			ConversationID: conversationID,
+			Limit:          limit,
+			Offset:         offset,
+		})
+		return err
+	})
+	return messages, err
+}
+
+// ListMessagesByType retrieves messages of a specific type in a conversation
+func (db *DB) ListMessagesByType(ctx context.Context, conversationID string, messageType MessageType) ([]generated.Message, error) {
+	var messages []generated.Message
+	err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+		q := generated.New(rx.Conn())
+		var err error
+		messages, err = q.ListMessagesByType(ctx, generated.ListMessagesByTypeParams{
+			ConversationID: conversationID,
+			Type:           string(messageType),
+		})
+		return err
+	})
+	return messages, err
+}
+
+// GetLatestMessage retrieves the latest message in a conversation
+func (db *DB) GetLatestMessage(ctx context.Context, conversationID string) (*generated.Message, error) {
+	var message generated.Message
+	err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+		q := generated.New(rx.Conn())
+		var err error
+		message, err = q.GetLatestMessage(ctx, conversationID)
+		return err
+	})
+	if err == sql.ErrNoRows {
+		return nil, fmt.Errorf("no messages found in conversation: %s", conversationID)
+	}
+	return &message, err
+}
+
+// CountMessagesByType returns the number of messages of a specific type in a conversation
+func (db *DB) CountMessagesByType(ctx context.Context, conversationID string, messageType MessageType) (int64, error) {
+	var count int64
+	err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+		q := generated.New(rx.Conn())
+		var err error
+		count, err = q.CountMessagesByType(ctx, generated.CountMessagesByTypeParams{
+			ConversationID: conversationID,
+			Type:           string(messageType),
+		})
+		return err
+	})
+	return count, err
+}
+
+// Queries provides read-only access to generated queries within a read transaction
+func (db *DB) Queries(ctx context.Context, fn func(*generated.Queries) error) error {
+	return db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+		q := generated.New(rx.Conn())
+		return fn(q)
+	})
+}
+
+// QueriesTx provides read-write access to generated queries within a write transaction
+func (db *DB) QueriesTx(ctx context.Context, fn func(*generated.Queries) error) error {
+	return db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+		q := generated.New(tx.Conn())
+		return fn(q)
+	})
+}
+
+// ListArchivedConversations retrieves archived conversations with pagination
+func (db *DB) ListArchivedConversations(ctx context.Context, limit, offset int64) ([]generated.Conversation, error) {
+	var conversations []generated.Conversation
+	err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+		q := generated.New(rx.Conn())
+		var err error
+		conversations, err = q.ListArchivedConversations(ctx, generated.ListArchivedConversationsParams{
+			Limit:  limit,
+			Offset: offset,
+		})
+		return err
+	})
+	return conversations, err
+}
+
+// SearchArchivedConversations searches for archived conversations containing the given query in their slug
+func (db *DB) SearchArchivedConversations(ctx context.Context, query string, limit, offset int64) ([]generated.Conversation, error) {
+	queryPtr := &query
+	var conversations []generated.Conversation
+	err := db.pool.Rx(ctx, func(ctx context.Context, rx *Rx) error {
+		q := generated.New(rx.Conn())
+		var err error
+		conversations, err = q.SearchArchivedConversations(ctx, generated.SearchArchivedConversationsParams{
+			Column1: queryPtr,
+			Limit:   limit,
+			Offset:  offset,
+		})
+		return err
+	})
+	return conversations, err
+}
+
+// ArchiveConversation archives a conversation
+func (db *DB) ArchiveConversation(ctx context.Context, conversationID string) (*generated.Conversation, error) {
+	var conversation generated.Conversation
+	err := db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+		q := generated.New(tx.Conn())
+		var err error
+		conversation, err = q.ArchiveConversation(ctx, conversationID)
+		return err
+	})
+	return &conversation, err
+}
+
+// UnarchiveConversation unarchives a conversation
+func (db *DB) UnarchiveConversation(ctx context.Context, conversationID string) (*generated.Conversation, error) {
+	var conversation generated.Conversation
+	err := db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+		q := generated.New(tx.Conn())
+		var err error
+		conversation, err = q.UnarchiveConversation(ctx, conversationID)
+		return err
+	})
+	return &conversation, err
+}
+
+// DeleteConversation deletes a conversation and all its messages
+func (db *DB) DeleteConversation(ctx context.Context, conversationID string) error {
+	return db.pool.Tx(ctx, func(ctx context.Context, tx *Tx) error {
+		q := generated.New(tx.Conn())
+		// Delete messages first (foreign key constraint)
+		if err := q.DeleteConversationMessages(ctx, conversationID); err != nil {
+			return fmt.Errorf("failed to delete messages: %w", err)
+		}
+		return q.DeleteConversation(ctx, conversationID)
+	})
+}

db/db_test.go πŸ”—

@@ -0,0 +1,178 @@
+package db
+
+import (
+	"context"
+	"strings"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/db/generated"
+)
+
+// setupTestDB creates a test database with schema migrated
+func setupTestDB(t *testing.T) *DB {
+	t.Helper()
+
+	// Use a temporary file instead of :memory: because the pool requires multiple connections
+	tmpDir := t.TempDir()
+	db, err := New(Config{DSN: tmpDir + "/test.db"})
+	if err != nil {
+		t.Fatalf("Failed to create test database: %v", err)
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	if err := db.Migrate(ctx); err != nil {
+		t.Fatalf("Failed to migrate test database: %v", err)
+	}
+
+	return db
+}
+
+func TestNew(t *testing.T) {
+	tests := []struct {
+		name    string
+		cfg     Config
+		wantErr bool
+	}{
+		{
+			name:    "memory database not supported",
+			cfg:     Config{DSN: ":memory:"},
+			wantErr: true,
+		},
+		{
+			name:    "empty DSN",
+			cfg:     Config{DSN: ""},
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			db, err := New(tt.cfg)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if db != nil {
+				defer db.Close()
+			}
+		})
+	}
+}
+
+func TestDB_Migrate(t *testing.T) {
+	tmpDir := t.TempDir()
+	db, err := New(Config{DSN: tmpDir + "/test.db"})
+	if err != nil {
+		t.Fatalf("Failed to create test database: %v", err)
+	}
+	defer db.Close()
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Run migrations first time
+	if err := db.Migrate(ctx); err != nil {
+		t.Errorf("Migrate() error = %v", err)
+	}
+
+	// Verify tables were created by trying to count conversations
+	var count int64
+	err = db.Queries(ctx, func(q *generated.Queries) error {
+		var err error
+		count, err = q.CountConversations(ctx)
+		return err
+	})
+	if err != nil {
+		t.Errorf("Failed to query conversations after migration: %v", err)
+	}
+	if count != 0 {
+		t.Errorf("Expected 0 conversations, got %d", count)
+	}
+
+	// Run migrations a second time to verify idempotency
+	if err := db.Migrate(ctx); err != nil {
+		t.Errorf("Second Migrate() error = %v", err)
+	}
+
+	// Verify we can still query after running migrations twice
+	err = db.Queries(ctx, func(q *generated.Queries) error {
+		var err error
+		count, err = q.CountConversations(ctx)
+		return err
+	})
+	if err != nil {
+		t.Errorf("Failed to query conversations after second migration: %v", err)
+	}
+}
+
+func TestDB_WithTx(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Test successful transaction
+	err := db.WithTx(ctx, func(q *generated.Queries) error {
+		_, err := q.CreateConversation(ctx, generated.CreateConversationParams{
+			ConversationID: "test-conv-1",
+			Slug:           stringPtr("test-slug"),
+			UserInitiated:  true,
+		})
+		return err
+	})
+	if err != nil {
+		t.Errorf("WithTx() error = %v", err)
+	}
+
+	// Verify the conversation was created
+	var conv generated.Conversation
+	err = db.Queries(ctx, func(q *generated.Queries) error {
+		var err error
+		conv, err = q.GetConversation(ctx, "test-conv-1")
+		return err
+	})
+	if err != nil {
+		t.Errorf("Failed to get conversation after transaction: %v", err)
+	}
+	if conv.ConversationID != "test-conv-1" {
+		t.Errorf("Expected conversation ID 'test-conv-1', got %s", conv.ConversationID)
+	}
+}
+
+// stringPtr returns a pointer to the given string
+func stringPtr(s string) *string {
+	return &s
+}
+
+func TestDB_ForeignKeyConstraints(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Try to create a message with a non-existent conversation_id
+	// This should fail due to foreign key constraint
+	err := db.QueriesTx(ctx, func(q *generated.Queries) error {
+		_, err := q.CreateMessage(ctx, generated.CreateMessageParams{
+			MessageID:      "test-msg-1",
+			ConversationID: "non-existent-conversation",
+			Type:           "user",
+		})
+		return err
+	})
+
+	if err == nil {
+		t.Error("Expected error when creating message with non-existent conversation_id")
+		return
+	}
+
+	// Verify the error is related to foreign key constraint
+	if !strings.Contains(err.Error(), "FOREIGN KEY constraint failed") {
+		t.Errorf("Expected foreign key constraint error, got: %v", err)
+	}
+}

db/generated/conversations.sql.go πŸ”—

@@ -0,0 +1,398 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+//   sqlc v1.30.0
+// source: conversations.sql
+
+package generated
+
+import (
+	"context"
+)
+
+const archiveConversation = `-- name: ArchiveConversation :one
+UPDATE conversations
+SET archived = TRUE, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+`
+
+func (q *Queries) ArchiveConversation(ctx context.Context, conversationID string) (Conversation, error) {
+	row := q.db.QueryRowContext(ctx, archiveConversation, conversationID)
+	var i Conversation
+	err := row.Scan(
+		&i.ConversationID,
+		&i.Slug,
+		&i.UserInitiated,
+		&i.CreatedAt,
+		&i.UpdatedAt,
+		&i.Cwd,
+		&i.Archived,
+	)
+	return i, err
+}
+
+const countArchivedConversations = `-- name: CountArchivedConversations :one
+SELECT COUNT(*) FROM conversations WHERE archived = TRUE
+`
+
+func (q *Queries) CountArchivedConversations(ctx context.Context) (int64, error) {
+	row := q.db.QueryRowContext(ctx, countArchivedConversations)
+	var count int64
+	err := row.Scan(&count)
+	return count, err
+}
+
+const countConversations = `-- name: CountConversations :one
+SELECT COUNT(*) FROM conversations WHERE archived = FALSE
+`
+
+func (q *Queries) CountConversations(ctx context.Context) (int64, error) {
+	row := q.db.QueryRowContext(ctx, countConversations)
+	var count int64
+	err := row.Scan(&count)
+	return count, err
+}
+
+const createConversation = `-- name: CreateConversation :one
+INSERT INTO conversations (conversation_id, slug, user_initiated, cwd)
+VALUES (?, ?, ?, ?)
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+`
+
+type CreateConversationParams struct {
+	ConversationID string  `json:"conversation_id"`
+	Slug           *string `json:"slug"`
+	UserInitiated  bool    `json:"user_initiated"`
+	Cwd            *string `json:"cwd"`
+}
+
+func (q *Queries) CreateConversation(ctx context.Context, arg CreateConversationParams) (Conversation, error) {
+	row := q.db.QueryRowContext(ctx, createConversation,
+		arg.ConversationID,
+		arg.Slug,
+		arg.UserInitiated,
+		arg.Cwd,
+	)
+	var i Conversation
+	err := row.Scan(
+		&i.ConversationID,
+		&i.Slug,
+		&i.UserInitiated,
+		&i.CreatedAt,
+		&i.UpdatedAt,
+		&i.Cwd,
+		&i.Archived,
+	)
+	return i, err
+}
+
+const deleteConversation = `-- name: DeleteConversation :exec
+DELETE FROM conversations
+WHERE conversation_id = ?
+`
+
+func (q *Queries) DeleteConversation(ctx context.Context, conversationID string) error {
+	_, err := q.db.ExecContext(ctx, deleteConversation, conversationID)
+	return err
+}
+
+const getConversation = `-- name: GetConversation :one
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+WHERE conversation_id = ?
+`
+
+func (q *Queries) GetConversation(ctx context.Context, conversationID string) (Conversation, error) {
+	row := q.db.QueryRowContext(ctx, getConversation, conversationID)
+	var i Conversation
+	err := row.Scan(
+		&i.ConversationID,
+		&i.Slug,
+		&i.UserInitiated,
+		&i.CreatedAt,
+		&i.UpdatedAt,
+		&i.Cwd,
+		&i.Archived,
+	)
+	return i, err
+}
+
+const getConversationBySlug = `-- name: GetConversationBySlug :one
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+WHERE slug = ?
+`
+
+func (q *Queries) GetConversationBySlug(ctx context.Context, slug *string) (Conversation, error) {
+	row := q.db.QueryRowContext(ctx, getConversationBySlug, slug)
+	var i Conversation
+	err := row.Scan(
+		&i.ConversationID,
+		&i.Slug,
+		&i.UserInitiated,
+		&i.CreatedAt,
+		&i.UpdatedAt,
+		&i.Cwd,
+		&i.Archived,
+	)
+	return i, err
+}
+
+const listArchivedConversations = `-- name: ListArchivedConversations :many
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+WHERE archived = TRUE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?
+`
+
+type ListArchivedConversationsParams struct {
+	Limit  int64 `json:"limit"`
+	Offset int64 `json:"offset"`
+}
+
+func (q *Queries) ListArchivedConversations(ctx context.Context, arg ListArchivedConversationsParams) ([]Conversation, error) {
+	rows, err := q.db.QueryContext(ctx, listArchivedConversations, arg.Limit, arg.Offset)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []Conversation{}
+	for rows.Next() {
+		var i Conversation
+		if err := rows.Scan(
+			&i.ConversationID,
+			&i.Slug,
+			&i.UserInitiated,
+			&i.CreatedAt,
+			&i.UpdatedAt,
+			&i.Cwd,
+			&i.Archived,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const listConversations = `-- name: ListConversations :many
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+WHERE archived = FALSE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?
+`
+
+type ListConversationsParams struct {
+	Limit  int64 `json:"limit"`
+	Offset int64 `json:"offset"`
+}
+
+func (q *Queries) ListConversations(ctx context.Context, arg ListConversationsParams) ([]Conversation, error) {
+	rows, err := q.db.QueryContext(ctx, listConversations, arg.Limit, arg.Offset)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []Conversation{}
+	for rows.Next() {
+		var i Conversation
+		if err := rows.Scan(
+			&i.ConversationID,
+			&i.Slug,
+			&i.UserInitiated,
+			&i.CreatedAt,
+			&i.UpdatedAt,
+			&i.Cwd,
+			&i.Archived,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const searchArchivedConversations = `-- name: SearchArchivedConversations :many
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+WHERE slug LIKE '%' || ? || '%' AND archived = TRUE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?
+`
+
+type SearchArchivedConversationsParams struct {
+	Column1 *string `json:"column_1"`
+	Limit   int64   `json:"limit"`
+	Offset  int64   `json:"offset"`
+}
+
+func (q *Queries) SearchArchivedConversations(ctx context.Context, arg SearchArchivedConversationsParams) ([]Conversation, error) {
+	rows, err := q.db.QueryContext(ctx, searchArchivedConversations, arg.Column1, arg.Limit, arg.Offset)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []Conversation{}
+	for rows.Next() {
+		var i Conversation
+		if err := rows.Scan(
+			&i.ConversationID,
+			&i.Slug,
+			&i.UserInitiated,
+			&i.CreatedAt,
+			&i.UpdatedAt,
+			&i.Cwd,
+			&i.Archived,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const searchConversations = `-- name: SearchConversations :many
+SELECT conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived FROM conversations
+WHERE slug LIKE '%' || ? || '%' AND archived = FALSE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?
+`
+
+type SearchConversationsParams struct {
+	Column1 *string `json:"column_1"`
+	Limit   int64   `json:"limit"`
+	Offset  int64   `json:"offset"`
+}
+
+func (q *Queries) SearchConversations(ctx context.Context, arg SearchConversationsParams) ([]Conversation, error) {
+	rows, err := q.db.QueryContext(ctx, searchConversations, arg.Column1, arg.Limit, arg.Offset)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []Conversation{}
+	for rows.Next() {
+		var i Conversation
+		if err := rows.Scan(
+			&i.ConversationID,
+			&i.Slug,
+			&i.UserInitiated,
+			&i.CreatedAt,
+			&i.UpdatedAt,
+			&i.Cwd,
+			&i.Archived,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const unarchiveConversation = `-- name: UnarchiveConversation :one
+UPDATE conversations
+SET archived = FALSE, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+`
+
+func (q *Queries) UnarchiveConversation(ctx context.Context, conversationID string) (Conversation, error) {
+	row := q.db.QueryRowContext(ctx, unarchiveConversation, conversationID)
+	var i Conversation
+	err := row.Scan(
+		&i.ConversationID,
+		&i.Slug,
+		&i.UserInitiated,
+		&i.CreatedAt,
+		&i.UpdatedAt,
+		&i.Cwd,
+		&i.Archived,
+	)
+	return i, err
+}
+
+const updateConversationCwd = `-- name: UpdateConversationCwd :one
+UPDATE conversations
+SET cwd = ?, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+`
+
+type UpdateConversationCwdParams struct {
+	Cwd            *string `json:"cwd"`
+	ConversationID string  `json:"conversation_id"`
+}
+
+func (q *Queries) UpdateConversationCwd(ctx context.Context, arg UpdateConversationCwdParams) (Conversation, error) {
+	row := q.db.QueryRowContext(ctx, updateConversationCwd, arg.Cwd, arg.ConversationID)
+	var i Conversation
+	err := row.Scan(
+		&i.ConversationID,
+		&i.Slug,
+		&i.UserInitiated,
+		&i.CreatedAt,
+		&i.UpdatedAt,
+		&i.Cwd,
+		&i.Archived,
+	)
+	return i, err
+}
+
+const updateConversationSlug = `-- name: UpdateConversationSlug :one
+UPDATE conversations
+SET slug = ?, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING conversation_id, slug, user_initiated, created_at, updated_at, cwd, archived
+`
+
+type UpdateConversationSlugParams struct {
+	Slug           *string `json:"slug"`
+	ConversationID string  `json:"conversation_id"`
+}
+
+func (q *Queries) UpdateConversationSlug(ctx context.Context, arg UpdateConversationSlugParams) (Conversation, error) {
+	row := q.db.QueryRowContext(ctx, updateConversationSlug, arg.Slug, arg.ConversationID)
+	var i Conversation
+	err := row.Scan(
+		&i.ConversationID,
+		&i.Slug,
+		&i.UserInitiated,
+		&i.CreatedAt,
+		&i.UpdatedAt,
+		&i.Cwd,
+		&i.Archived,
+	)
+	return i, err
+}
+
+const updateConversationTimestamp = `-- name: UpdateConversationTimestamp :exec
+UPDATE conversations
+SET updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+`
+
+func (q *Queries) UpdateConversationTimestamp(ctx context.Context, conversationID string) error {
+	_, err := q.db.ExecContext(ctx, updateConversationTimestamp, conversationID)
+	return err
+}

db/generated/db.go πŸ”—

@@ -0,0 +1,31 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+//   sqlc v1.30.0
+
+package generated
+
+import (
+	"context"
+	"database/sql"
+)
+
+type DBTX interface {
+	ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
+	PrepareContext(context.Context, string) (*sql.Stmt, error)
+	QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
+	QueryRowContext(context.Context, string, ...interface{}) *sql.Row
+}
+
+func New(db DBTX) *Queries {
+	return &Queries{db: db}
+}
+
+type Queries struct {
+	db DBTX
+}
+
+func (q *Queries) WithTx(tx *sql.Tx) *Queries {
+	return &Queries{
+		db: tx,
+	}
+}

db/generated/messages.sql.go πŸ”—

@@ -0,0 +1,334 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+//   sqlc v1.30.0
+// source: messages.sql
+
+package generated
+
+import (
+	"context"
+)
+
+const countMessagesByType = `-- name: CountMessagesByType :one
+SELECT COUNT(*) FROM messages
+WHERE conversation_id = ? AND type = ?
+`
+
+type CountMessagesByTypeParams struct {
+	ConversationID string `json:"conversation_id"`
+	Type           string `json:"type"`
+}
+
+func (q *Queries) CountMessagesByType(ctx context.Context, arg CountMessagesByTypeParams) (int64, error) {
+	row := q.db.QueryRowContext(ctx, countMessagesByType, arg.ConversationID, arg.Type)
+	var count int64
+	err := row.Scan(&count)
+	return count, err
+}
+
+const countMessagesInConversation = `-- name: CountMessagesInConversation :one
+SELECT COUNT(*) FROM messages
+WHERE conversation_id = ?
+`
+
+func (q *Queries) CountMessagesInConversation(ctx context.Context, conversationID string) (int64, error) {
+	row := q.db.QueryRowContext(ctx, countMessagesInConversation, conversationID)
+	var count int64
+	err := row.Scan(&count)
+	return count, err
+}
+
+const createMessage = `-- name: CreateMessage :one
+INSERT INTO messages (message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, display_data)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+RETURNING message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data
+`
+
+type CreateMessageParams struct {
+	MessageID      string  `json:"message_id"`
+	ConversationID string  `json:"conversation_id"`
+	SequenceID     int64   `json:"sequence_id"`
+	Type           string  `json:"type"`
+	LlmData        *string `json:"llm_data"`
+	UserData       *string `json:"user_data"`
+	UsageData      *string `json:"usage_data"`
+	DisplayData    *string `json:"display_data"`
+}
+
+func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
+	row := q.db.QueryRowContext(ctx, createMessage,
+		arg.MessageID,
+		arg.ConversationID,
+		arg.SequenceID,
+		arg.Type,
+		arg.LlmData,
+		arg.UserData,
+		arg.UsageData,
+		arg.DisplayData,
+	)
+	var i Message
+	err := row.Scan(
+		&i.MessageID,
+		&i.ConversationID,
+		&i.SequenceID,
+		&i.Type,
+		&i.LlmData,
+		&i.UserData,
+		&i.UsageData,
+		&i.CreatedAt,
+		&i.DisplayData,
+	)
+	return i, err
+}
+
+const deleteConversationMessages = `-- name: DeleteConversationMessages :exec
+DELETE FROM messages
+WHERE conversation_id = ?
+`
+
+func (q *Queries) DeleteConversationMessages(ctx context.Context, conversationID string) error {
+	_, err := q.db.ExecContext(ctx, deleteConversationMessages, conversationID)
+	return err
+}
+
+const deleteMessage = `-- name: DeleteMessage :exec
+DELETE FROM messages
+WHERE message_id = ?
+`
+
+func (q *Queries) DeleteMessage(ctx context.Context, messageID string) error {
+	_, err := q.db.ExecContext(ctx, deleteMessage, messageID)
+	return err
+}
+
+const getLatestMessage = `-- name: GetLatestMessage :one
+SELECT message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data FROM messages
+WHERE conversation_id = ?
+ORDER BY sequence_id DESC
+LIMIT 1
+`
+
+func (q *Queries) GetLatestMessage(ctx context.Context, conversationID string) (Message, error) {
+	row := q.db.QueryRowContext(ctx, getLatestMessage, conversationID)
+	var i Message
+	err := row.Scan(
+		&i.MessageID,
+		&i.ConversationID,
+		&i.SequenceID,
+		&i.Type,
+		&i.LlmData,
+		&i.UserData,
+		&i.UsageData,
+		&i.CreatedAt,
+		&i.DisplayData,
+	)
+	return i, err
+}
+
+const getMessage = `-- name: GetMessage :one
+SELECT message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data FROM messages
+WHERE message_id = ?
+`
+
+func (q *Queries) GetMessage(ctx context.Context, messageID string) (Message, error) {
+	row := q.db.QueryRowContext(ctx, getMessage, messageID)
+	var i Message
+	err := row.Scan(
+		&i.MessageID,
+		&i.ConversationID,
+		&i.SequenceID,
+		&i.Type,
+		&i.LlmData,
+		&i.UserData,
+		&i.UsageData,
+		&i.CreatedAt,
+		&i.DisplayData,
+	)
+	return i, err
+}
+
+const getNextSequenceID = `-- name: GetNextSequenceID :one
+SELECT COALESCE(MAX(sequence_id), 0) + 1 
+FROM messages 
+WHERE conversation_id = ?
+`
+
+func (q *Queries) GetNextSequenceID(ctx context.Context, conversationID string) (int64, error) {
+	row := q.db.QueryRowContext(ctx, getNextSequenceID, conversationID)
+	var column_1 int64
+	err := row.Scan(&column_1)
+	return column_1, err
+}
+
+const listMessages = `-- name: ListMessages :many
+SELECT message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data FROM messages
+WHERE conversation_id = ?
+ORDER BY sequence_id ASC
+`
+
+func (q *Queries) ListMessages(ctx context.Context, conversationID string) ([]Message, error) {
+	rows, err := q.db.QueryContext(ctx, listMessages, conversationID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []Message{}
+	for rows.Next() {
+		var i Message
+		if err := rows.Scan(
+			&i.MessageID,
+			&i.ConversationID,
+			&i.SequenceID,
+			&i.Type,
+			&i.LlmData,
+			&i.UserData,
+			&i.UsageData,
+			&i.CreatedAt,
+			&i.DisplayData,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const listMessagesByType = `-- name: ListMessagesByType :many
+SELECT message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data FROM messages
+WHERE conversation_id = ? AND type = ?
+ORDER BY sequence_id ASC
+`
+
+type ListMessagesByTypeParams struct {
+	ConversationID string `json:"conversation_id"`
+	Type           string `json:"type"`
+}
+
+func (q *Queries) ListMessagesByType(ctx context.Context, arg ListMessagesByTypeParams) ([]Message, error) {
+	rows, err := q.db.QueryContext(ctx, listMessagesByType, arg.ConversationID, arg.Type)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []Message{}
+	for rows.Next() {
+		var i Message
+		if err := rows.Scan(
+			&i.MessageID,
+			&i.ConversationID,
+			&i.SequenceID,
+			&i.Type,
+			&i.LlmData,
+			&i.UserData,
+			&i.UsageData,
+			&i.CreatedAt,
+			&i.DisplayData,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const listMessagesPaginated = `-- name: ListMessagesPaginated :many
+SELECT message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data FROM messages
+WHERE conversation_id = ?
+ORDER BY sequence_id ASC
+LIMIT ? OFFSET ?
+`
+
+type ListMessagesPaginatedParams struct {
+	ConversationID string `json:"conversation_id"`
+	Limit          int64  `json:"limit"`
+	Offset         int64  `json:"offset"`
+}
+
+func (q *Queries) ListMessagesPaginated(ctx context.Context, arg ListMessagesPaginatedParams) ([]Message, error) {
+	rows, err := q.db.QueryContext(ctx, listMessagesPaginated, arg.ConversationID, arg.Limit, arg.Offset)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []Message{}
+	for rows.Next() {
+		var i Message
+		if err := rows.Scan(
+			&i.MessageID,
+			&i.ConversationID,
+			&i.SequenceID,
+			&i.Type,
+			&i.LlmData,
+			&i.UserData,
+			&i.UsageData,
+			&i.CreatedAt,
+			&i.DisplayData,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
+const listMessagesSince = `-- name: ListMessagesSince :many
+SELECT message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at, display_data FROM messages
+WHERE conversation_id = ? AND sequence_id > ?
+ORDER BY sequence_id ASC
+`
+
+type ListMessagesSinceParams struct {
+	ConversationID string `json:"conversation_id"`
+	SequenceID     int64  `json:"sequence_id"`
+}
+
+func (q *Queries) ListMessagesSince(ctx context.Context, arg ListMessagesSinceParams) ([]Message, error) {
+	rows, err := q.db.QueryContext(ctx, listMessagesSince, arg.ConversationID, arg.SequenceID)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	items := []Message{}
+	for rows.Next() {
+		var i Message
+		if err := rows.Scan(
+			&i.MessageID,
+			&i.ConversationID,
+			&i.SequenceID,
+			&i.Type,
+			&i.LlmData,
+			&i.UserData,
+			&i.UsageData,
+			&i.CreatedAt,
+			&i.DisplayData,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Close(); err != nil {
+		return nil, err
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}

db/generated/models.go πŸ”—

@@ -0,0 +1,37 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+//   sqlc v1.30.0
+
+package generated
+
+import (
+	"time"
+)
+
+type Conversation struct {
+	ConversationID string    `json:"conversation_id"`
+	Slug           *string   `json:"slug"`
+	UserInitiated  bool      `json:"user_initiated"`
+	CreatedAt      time.Time `json:"created_at"`
+	UpdatedAt      time.Time `json:"updated_at"`
+	Cwd            *string   `json:"cwd"`
+	Archived       bool      `json:"archived"`
+}
+
+type Message struct {
+	MessageID      string    `json:"message_id"`
+	ConversationID string    `json:"conversation_id"`
+	SequenceID     int64     `json:"sequence_id"`
+	Type           string    `json:"type"`
+	LlmData        *string   `json:"llm_data"`
+	UserData       *string   `json:"user_data"`
+	UsageData      *string   `json:"usage_data"`
+	CreatedAt      time.Time `json:"created_at"`
+	DisplayData    *string   `json:"display_data"`
+}
+
+type Migration struct {
+	MigrationNumber int64      `json:"migration_number"`
+	MigrationName   string     `json:"migration_name"`
+	ExecutedAt      *time.Time `json:"executed_at"`
+}

db/messages_test.go πŸ”—

@@ -0,0 +1,457 @@
+package db
+
+import (
+	"context"
+	"encoding/json"
+	"strings"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/db/generated"
+)
+
+func TestMessageService_Create(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create a test conversation
+	conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create test conversation: %v", err)
+	}
+
+	tests := []struct {
+		name      string
+		msgType   MessageType
+		llmData   interface{}
+		userData  interface{}
+		usageData interface{}
+	}{
+		{
+			name:      "user message with data",
+			msgType:   MessageTypeUser,
+			llmData:   map[string]string{"content": "Hello, AI!"},
+			userData:  map[string]string{"display": "Hello, AI!"},
+			usageData: nil,
+		},
+		{
+			name:      "agent message with usage",
+			msgType:   MessageTypeAgent,
+			llmData:   map[string]string{"response": "Hello, human!"},
+			userData:  map[string]string{"formatted": "Hello, human!"},
+			usageData: map[string]int{"tokens": 42},
+		},
+		{
+			name:      "tool message minimal",
+			msgType:   MessageTypeTool,
+			llmData:   nil,
+			userData:  nil,
+			usageData: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			msg, err := db.CreateMessage(ctx, CreateMessageParams{
+				ConversationID: conv.ConversationID,
+				Type:           tt.msgType,
+				LLMData:        tt.llmData,
+				UserData:       tt.userData,
+				UsageData:      tt.usageData,
+			})
+			if err != nil {
+				t.Errorf("Create() error = %v", err)
+				return
+			}
+
+			if msg.MessageID == "" {
+				t.Error("Expected non-empty message ID")
+			}
+
+			if msg.ConversationID != conv.ConversationID {
+				t.Errorf("Expected conversation ID %s, got %s", conv.ConversationID, msg.ConversationID)
+			}
+
+			if msg.Type != string(tt.msgType) {
+				t.Errorf("Expected message type %s, got %s", tt.msgType, msg.Type)
+			}
+
+			// Test JSON data marshalling
+			if tt.llmData != nil {
+				if msg.LlmData == nil {
+					t.Error("Expected LLM data to be non-nil")
+				} else {
+					var unmarshalled map[string]interface{}
+					err := json.Unmarshal([]byte(*msg.LlmData), &unmarshalled)
+					if err != nil {
+						t.Errorf("Failed to unmarshal LLM data: %v", err)
+					}
+				}
+			} else {
+				if msg.LlmData != nil {
+					t.Error("Expected LLM data to be nil")
+				}
+			}
+
+			if msg.CreatedAt.IsZero() {
+				t.Error("Expected non-zero created_at time")
+			}
+		})
+	}
+}
+
+func TestMessageService_GetByID(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create a test conversation
+	conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create test conversation: %v", err)
+	}
+
+	// Create a test message
+	created, err := db.CreateMessage(ctx, CreateMessageParams{
+		ConversationID: conv.ConversationID,
+		Type:           MessageTypeUser,
+		LLMData:        map[string]string{"content": "test message"},
+	})
+	if err != nil {
+		t.Fatalf("Failed to create test message: %v", err)
+	}
+
+	// Test getting existing message
+	msg, err := db.GetMessageByID(ctx, created.MessageID)
+	if err != nil {
+		t.Errorf("GetByID() error = %v", err)
+		return
+	}
+
+	if msg.MessageID != created.MessageID {
+		t.Errorf("Expected message ID %s, got %s", created.MessageID, msg.MessageID)
+	}
+
+	// Test getting non-existent message
+	_, err = db.GetMessageByID(ctx, "non-existent")
+	if err == nil {
+		t.Error("Expected error for non-existent message")
+	}
+	if !strings.Contains(err.Error(), "not found") {
+		t.Errorf("Expected 'not found' in error message, got: %v", err)
+	}
+}
+
+func TestMessageService_ListByConversation(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create a test conversation
+	conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create test conversation: %v", err)
+	}
+
+	// Create multiple test messages
+	msgTypes := []MessageType{MessageTypeUser, MessageTypeAgent, MessageTypeTool}
+	for i, msgType := range msgTypes {
+		_, err := db.CreateMessage(ctx, CreateMessageParams{
+			ConversationID: conv.ConversationID,
+			Type:           msgType,
+			LLMData:        map[string]interface{}{"index": i, "type": string(msgType)},
+		})
+		if err != nil {
+			t.Fatalf("Failed to create test message %d: %v", i, err)
+		}
+	}
+
+	// List messages
+	var messages []generated.Message
+	err = db.Queries(ctx, func(q *generated.Queries) error {
+		var err error
+		messages, err = q.ListMessages(ctx, conv.ConversationID)
+		return err
+	})
+	if err != nil {
+		t.Errorf("ListByConversation() error = %v", err)
+		return
+	}
+
+	if len(messages) != 3 {
+		t.Errorf("Expected 3 messages, got %d", len(messages))
+	}
+
+	// Messages should be ordered by created_at ASC (oldest first) by the query
+	// We verify this by checking the message types are in the order we created them
+	expectedTypes := []string{"user", "agent", "tool"}
+	for i, msg := range messages {
+		if msg.Type != expectedTypes[i] {
+			t.Errorf("Expected message %d to be type %s, got %s", i, expectedTypes[i], msg.Type)
+		}
+	}
+}
+
+func TestMessageService_ListByType(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create a test conversation
+	conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create test conversation: %v", err)
+	}
+
+	// Create messages of different types
+	msgTypes := []MessageType{MessageTypeUser, MessageTypeAgent, MessageTypeUser, MessageTypeTool}
+	for i, msgType := range msgTypes {
+		_, err := db.CreateMessage(ctx, CreateMessageParams{
+			ConversationID: conv.ConversationID,
+			Type:           msgType,
+			LLMData:        map[string]interface{}{"index": i},
+		})
+		if err != nil {
+			t.Fatalf("Failed to create test message %d: %v", i, err)
+		}
+	}
+
+	// List only user messages
+	userMessages, err := db.ListMessagesByType(ctx, conv.ConversationID, MessageTypeUser)
+	if err != nil {
+		t.Errorf("ListByType() error = %v", err)
+		return
+	}
+
+	if len(userMessages) != 2 {
+		t.Errorf("Expected 2 user messages, got %d", len(userMessages))
+	}
+
+	// Verify all messages are user type
+	for _, msg := range userMessages {
+		if msg.Type != string(MessageTypeUser) {
+			t.Errorf("Expected user message, got %s", msg.Type)
+		}
+	}
+}
+
+func TestMessageService_GetLatest(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create a test conversation
+	conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create test conversation: %v", err)
+	}
+
+	// Test getting latest from empty conversation
+	_, err = db.GetLatestMessage(ctx, conv.ConversationID)
+	if err == nil {
+		t.Error("Expected error for conversation with no messages")
+	}
+
+	// Create multiple test messages
+	var lastCreated *generated.Message
+	for i := 0; i < 3; i++ {
+		created, err := db.CreateMessage(ctx, CreateMessageParams{
+			ConversationID: conv.ConversationID,
+			Type:           MessageTypeUser,
+			LLMData:        map[string]interface{}{"index": i},
+		})
+		if err != nil {
+			t.Fatalf("Failed to create test message %d: %v", i, err)
+		}
+		lastCreated = created
+	}
+
+	// Get the latest message
+	latest, err := db.GetLatestMessage(ctx, conv.ConversationID)
+	if err != nil {
+		t.Errorf("GetLatest() error = %v", err)
+		return
+	}
+
+	if latest.MessageID != lastCreated.MessageID {
+		t.Errorf("Expected latest message ID %s, got %s", lastCreated.MessageID, latest.MessageID)
+	}
+}
+
+func TestMessageService_Delete(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create a test conversation
+	conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create test conversation: %v", err)
+	}
+
+	// Create a test message
+	created, err := db.CreateMessage(ctx, CreateMessageParams{
+		ConversationID: conv.ConversationID,
+		Type:           MessageTypeUser,
+		LLMData:        map[string]string{"content": "test message"},
+	})
+	if err != nil {
+		t.Fatalf("Failed to create test message: %v", err)
+	}
+
+	// Delete the message
+	err = db.QueriesTx(ctx, func(q *generated.Queries) error {
+		return q.DeleteMessage(ctx, created.MessageID)
+	})
+	if err != nil {
+		t.Errorf("Delete() error = %v", err)
+		return
+	}
+
+	// Verify it's gone
+	_, err = db.GetMessageByID(ctx, created.MessageID)
+	if err == nil {
+		t.Error("Expected error when getting deleted message")
+	}
+}
+
+func TestMessageService_CountInConversation(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create a test conversation
+	conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create test conversation: %v", err)
+	}
+
+	// Initial count should be 0
+	var count int64
+	err = db.Queries(ctx, func(q *generated.Queries) error {
+		var err error
+		count, err = q.CountMessagesInConversation(ctx, conv.ConversationID)
+		return err
+	})
+	if err != nil {
+		t.Errorf("CountInConversation() error = %v", err)
+		return
+	}
+	if count != 0 {
+		t.Errorf("Expected initial count 0, got %d", count)
+	}
+
+	// Create test messages
+	for i := 0; i < 4; i++ {
+		_, err := db.CreateMessage(ctx, CreateMessageParams{
+			ConversationID: conv.ConversationID,
+			Type:           MessageTypeUser,
+			LLMData:        map[string]interface{}{"index": i},
+		})
+		if err != nil {
+			t.Fatalf("Failed to create test message %d: %v", i, err)
+		}
+	}
+
+	// Count should now be 4
+	err = db.Queries(ctx, func(q *generated.Queries) error {
+		var err error
+		count, err = q.CountMessagesInConversation(ctx, conv.ConversationID)
+		return err
+	})
+	if err != nil {
+		t.Errorf("CountInConversation() error = %v", err)
+		return
+	}
+	if count != 4 {
+		t.Errorf("Expected count 4, got %d", count)
+	}
+}
+
+func TestMessageService_CountByType(t *testing.T) {
+	db := setupTestDB(t)
+	defer db.Close()
+
+	// Using db directly instead of service
+	// Using db directly instead of service
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	// Create a test conversation
+	conv, err := db.CreateConversation(ctx, stringPtr("test-conversation"), true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create test conversation: %v", err)
+	}
+
+	// Create messages of different types
+	msgTypes := []MessageType{MessageTypeUser, MessageTypeAgent, MessageTypeUser, MessageTypeTool, MessageTypeUser}
+	for i, msgType := range msgTypes {
+		_, err := db.CreateMessage(ctx, CreateMessageParams{
+			ConversationID: conv.ConversationID,
+			Type:           msgType,
+			LLMData:        map[string]interface{}{"index": i},
+		})
+		if err != nil {
+			t.Fatalf("Failed to create test message %d: %v", i, err)
+		}
+	}
+
+	// Count user messages (should be 3)
+	userCount, err := db.CountMessagesByType(ctx, conv.ConversationID, MessageTypeUser)
+	if err != nil {
+		t.Errorf("CountByType() error = %v", err)
+		return
+	}
+	if userCount != 3 {
+		t.Errorf("Expected 3 user messages, got %d", userCount)
+	}
+
+	// Count agent messages (should be 1)
+	agentCount, err := db.CountMessagesByType(ctx, conv.ConversationID, MessageTypeAgent)
+	if err != nil {
+		t.Errorf("CountByType() error = %v", err)
+		return
+	}
+	if agentCount != 1 {
+		t.Errorf("Expected 1 agent message, got %d", agentCount)
+	}
+
+	// Count tool messages (should be 1)
+	toolCount, err := db.CountMessagesByType(ctx, conv.ConversationID, MessageTypeTool)
+	if err != nil {
+		t.Errorf("CountByType() error = %v", err)
+		return
+	}
+	if toolCount != 1 {
+		t.Errorf("Expected 1 tool message, got %d", toolCount)
+	}
+}

db/pool.go πŸ”—

@@ -0,0 +1,351 @@
+// Package db provides database operations for the Shelley AI coding agent.
+package db
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"runtime"
+	"strings"
+	"time"
+)
+
+// Pool is an SQLite connection pool.
+//
+// We deliberately minimize our use of database/sql machinery because
+// the semantics do not match SQLite well.
+//
+// Instead, we choose a single connection to use for writing (because
+// SQLite is single-writer) and use the rest as readers.
+type Pool struct {
+	db      *sql.DB
+	writer  chan *sql.Conn
+	readers chan *sql.Conn
+}
+
+func NewPool(dataSourceName string, readerCount int) (*Pool, error) {
+	if dataSourceName == ":memory:" {
+		return nil, fmt.Errorf(":memory: is not supported (because multiple conns are needed); use a temp file")
+	}
+	// TODO: a caller could override PRAGMA query_only.
+	// Consider opening two *sql.DBs, one configured as read-only,
+	// to ensure read-only transactions are always such.
+	db, err := sql.Open("sqlite", dataSourceName)
+	if err != nil {
+		return nil, fmt.Errorf("NewPool: %w", err)
+	}
+	numConns := readerCount + 1
+	if err := InitPoolDB(db, numConns); err != nil {
+		return nil, fmt.Errorf("NewPool: %w", err)
+	}
+
+	var conns []*sql.Conn
+	for i := 0; i < numConns; i++ {
+		conn, err := db.Conn(context.Background())
+		if err != nil {
+			db.Close()
+			return nil, fmt.Errorf("NewPool: %w", err)
+		}
+		conns = append(conns, conn)
+	}
+
+	p := &Pool{
+		db:      db,
+		writer:  make(chan *sql.Conn, 1),
+		readers: make(chan *sql.Conn, readerCount),
+	}
+	p.writer <- conns[0]
+	for _, conn := range conns[1:] {
+		if _, err := conn.ExecContext(context.Background(), "PRAGMA query_only=1;"); err != nil {
+			db.Close()
+			return nil, fmt.Errorf("NewPool query_only: %w", err)
+		}
+		p.readers <- conn
+	}
+
+	return p, nil
+}
+
+// InitPoolDB fixes the database/sql pool to a set of fixed connections.
+func InitPoolDB(db *sql.DB, numConns int) error {
+	db.SetMaxIdleConns(numConns)
+	db.SetMaxOpenConns(numConns)
+	db.SetConnMaxLifetime(-1)
+	db.SetConnMaxIdleTime(-1)
+
+	initQueries := []string{
+		"PRAGMA journal_mode=wal;",
+		"PRAGMA busy_timeout=1000;",
+		"PRAGMA foreign_keys=ON;",
+	}
+
+	var conns []*sql.Conn
+	for i := 0; i < numConns; i++ {
+		conn, err := db.Conn(context.Background())
+		if err != nil {
+			db.Close()
+			return fmt.Errorf("InitPoolDB: %w", err)
+		}
+		for _, q := range initQueries {
+			if _, err := conn.ExecContext(context.Background(), q); err != nil {
+				db.Close()
+				return fmt.Errorf("InitPoolDB %d: %w", i, err)
+			}
+		}
+		conns = append(conns, conn)
+	}
+	for _, conn := range conns {
+		if err := conn.Close(); err != nil {
+			db.Close()
+			return fmt.Errorf("InitPoolDB: %w", err)
+		}
+	}
+	return nil
+}
+
+func (p *Pool) Close() error {
+	return p.db.Close()
+}
+
+type ctxKeyType int
+
+// CtxKey is the context value key used to store the current *Tx or *Rx.
+// In general this should not be used, plumb the tx directly.
+// This code is here is used for an exception: the slog package.
+var CtxKey any = ctxKeyType(0)
+
+func checkNoTx(ctx context.Context, typ string) {
+	x := ctx.Value(CtxKey)
+	if x == nil {
+		return
+	}
+	orig := "unexpected"
+	switch x := x.(type) {
+	case *Tx:
+		orig = "Tx (" + x.caller + ")"
+	case *Rx:
+		orig = "Rx (" + x.caller + ")"
+	}
+	panic(typ + " inside " + orig)
+}
+
+// Exec executes a single statement outside of a transaction.
+// Useful in the rare case of PRAGMAs that cannot execute inside a tx,
+// such as PRAGMA wal_checkpoint.
+func (p *Pool) Exec(ctx context.Context, query string, args ...interface{}) error {
+	checkNoTx(ctx, "Tx")
+	var conn *sql.Conn
+	select {
+	case <-ctx.Done():
+		return fmt.Errorf("Pool.Exec: %w", ctx.Err())
+	case conn = <-p.writer:
+	}
+	var err error
+	defer func() {
+		p.writer <- conn
+	}()
+	_, err = conn.ExecContext(ctx, query, args...)
+	return wrapErr("pool.exec", err)
+}
+
+func (p *Pool) Tx(ctx context.Context, fn func(ctx context.Context, tx *Tx) error) error {
+	checkNoTx(ctx, "Tx")
+	var conn *sql.Conn
+	select {
+	case <-ctx.Done():
+		return fmt.Errorf("Tx: %w", ctx.Err())
+	case conn = <-p.writer:
+	}
+
+	// If the context is closed, we want BEGIN to succeed and then
+	// we roll it back later.
+	if _, err := conn.ExecContext(context.WithoutCancel(ctx), "BEGIN IMMEDIATE;"); err != nil {
+		if strings.Contains(err.Error(), "SQLITE_BUSY") {
+			p.writer <- conn
+			return fmt.Errorf("Tx begin: %w", err)
+		}
+		// unrecoverable error, this will lock everything up
+		return fmt.Errorf("Tx LEAK %w", err)
+	}
+	tx := &Tx{
+		Rx:  &Rx{conn: conn, p: p, caller: callerOfCaller(1)},
+		Now: time.Now(),
+	}
+	tx.ctx = context.WithValue(ctx, CtxKey, tx)
+
+	var err error
+	defer func() {
+		if err == nil {
+			_, err = tx.conn.ExecContext(tx.ctx, "COMMIT;")
+			if err != nil {
+				err = fmt.Errorf("Tx: commit: %w", err)
+			}
+		}
+		if err != nil {
+			err = p.rollback(tx.ctx, "Tx", err, tx.conn)
+			// always return conn,
+			// either the entire database is closed or the conn is fine.
+		}
+		tx.p.writer <- conn
+	}()
+	if ctxErr := tx.ctx.Err(); ctxErr != nil {
+		return ctxErr // fast path for canceled context
+	}
+	err = fn(tx.ctx, tx)
+
+	return err
+}
+
+func (p *Pool) Rx(ctx context.Context, fn func(ctx context.Context, rx *Rx) error) error {
+	checkNoTx(ctx, "Rx")
+	var conn *sql.Conn
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	case conn = <-p.readers:
+	}
+
+	// If the context is closed, we want BEGIN to succeed and then
+	// we roll it back later.
+	if _, err := conn.ExecContext(context.WithoutCancel(ctx), "BEGIN;"); err != nil {
+		if strings.Contains(err.Error(), "SQLITE_BUSY") {
+			p.readers <- conn
+			return fmt.Errorf("Rx begin: %w", err)
+		}
+		// an unrecoverable error, e.g. tx-inside-tx misuse or IOERR
+		return fmt.Errorf("Rx LEAK: %w", err)
+	}
+	rx := &Rx{conn: conn, p: p, caller: callerOfCaller(1)}
+	rx.ctx = context.WithValue(ctx, CtxKey, rx)
+
+	var err error
+	defer func() {
+		err = p.rollback(rx.ctx, "Rx", err, rx.conn)
+		// always return conn,
+		// either the entire database is closed or the conn is fine.
+		rx.p.readers <- conn
+	}()
+	if ctxErr := rx.ctx.Err(); ctxErr != nil {
+		return ctxErr // fast path for canceled context
+	}
+	err = fn(rx.ctx, rx)
+	return err
+}
+
+func (p *Pool) rollback(ctx context.Context, txType string, txErr error, conn *sql.Conn) error {
+	// Even if the context is cancelled,
+	// we still need to rollback to finish up the transaction.
+	_, err := conn.ExecContext(context.WithoutCancel(ctx), "ROLLBACK;")
+	if err != nil && !strings.Contains(err.Error(), "no transaction is active") {
+		// There are a few cases where an error during a transaction
+		// will be reported as a rollback error:
+		// 	https://sqlite.org/lang_transaction.html#response_to_errors_within_a_transaction
+		// In good operation, we should never see any of these.
+		//
+		// TODO: confirm this check works on all sqlite drivers.
+		if !strings.Contains(err.Error(), "SQLITE_BUSY") {
+			conn.Close()
+			p.db.Close()
+		}
+		return fmt.Errorf("%s: %v: rollback failed: %w", txType, txErr, err)
+	}
+	return txErr
+}
+
+type Tx struct {
+	*Rx
+	Now time.Time
+}
+
+func (tx *Tx) Exec(query string, args ...interface{}) (sql.Result, error) {
+	res, err := tx.conn.ExecContext(tx.ctx, query, args...)
+	return res, wrapErr("exec", err)
+}
+
+type Rx struct {
+	ctx    context.Context
+	conn   *sql.Conn
+	p      *Pool
+	caller string // for debugging
+}
+
+func (rx *Rx) Context() context.Context {
+	return rx.ctx
+}
+
+func (rx *Rx) Query(query string, args ...interface{}) (*sql.Rows, error) {
+	rows, err := rx.conn.QueryContext(rx.ctx, query, args...)
+	return rows, wrapErr("query", err)
+}
+
+func (rx *Rx) QueryRow(query string, args ...interface{}) *Row {
+	rows, err := rx.conn.QueryContext(rx.ctx, query, args...)
+	return &Row{err: err, rows: rows}
+}
+
+// Conn returns the underlying sql.Conn for use with external libraries like sqlc
+func (rx *Rx) Conn() *sql.Conn {
+	return rx.conn
+}
+
+// Row is equivalent to *sql.Row, but we provide a more useful error.
+type Row struct {
+	err  error
+	rows *sql.Rows
+}
+
+func (r *Row) Scan(dest ...any) error {
+	if r.err != nil {
+		return wrapErr("QueryRow", r.err)
+	}
+
+	defer r.rows.Close()
+	if !r.rows.Next() {
+		if err := r.rows.Err(); err != nil {
+			return wrapErr("QueryRow.Scan", err)
+		}
+		return wrapErr("QueryRow.Scan", sql.ErrNoRows)
+	}
+	err := r.rows.Scan(dest...)
+	if err != nil {
+		return wrapErr("QueryRow.Scan", err)
+	}
+	return wrapErr("QueryRow.Scan", r.rows.Close())
+}
+
+func wrapErr(prefix string, err error) error {
+	if err == nil {
+		return nil
+	}
+	return fmt.Errorf("%s: %s: %w", callerOfCaller(2), prefix, err)
+}
+
+func callerOfCaller(depth int) string {
+	caller := "unknown"
+	pc := make([]uintptr, 3)
+	const addedSkip = 3 // runtime.Callers, callerOfCaller, our caller (e.g. wrapErr or Rx)
+	if n := runtime.Callers(addedSkip+depth-1, pc[:]); n > 0 {
+		frames := runtime.CallersFrames(pc[:n])
+		frame, _ := frames.Next()
+		if frame.Function != "" {
+			caller = frame.Function
+		}
+		// This is a special case.
+		//
+		// We expect people to wrap the Tx/Rx objects
+		// in another domain-specific Tx/Rx object. That means
+		// they almost certainly have matching Tx/Rx methods,
+		// which aren't useful for debugging. So if we see that,
+		// we remove it.
+		if strings.HasSuffix(caller, ".Tx") || strings.HasSuffix(caller, ".Rx") {
+			frame, more := frames.Next()
+			if more && frame.Function != "" {
+				caller = frame.Function
+			}
+		}
+	}
+	if i := strings.LastIndexByte(caller, '/'); i >= 0 {
+		caller = caller[i+1:]
+	}
+	return caller
+}

db/query/conversations.sql πŸ”—

@@ -0,0 +1,75 @@
+-- name: CreateConversation :one
+INSERT INTO conversations (conversation_id, slug, user_initiated, cwd)
+VALUES (?, ?, ?, ?)
+RETURNING *;
+
+-- name: GetConversation :one
+SELECT * FROM conversations
+WHERE conversation_id = ?;
+
+-- name: GetConversationBySlug :one
+SELECT * FROM conversations
+WHERE slug = ?;
+
+-- name: ListConversations :many
+SELECT * FROM conversations
+WHERE archived = FALSE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?;
+
+-- name: ListArchivedConversations :many
+SELECT * FROM conversations
+WHERE archived = TRUE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?;
+
+-- name: SearchConversations :many
+SELECT * FROM conversations
+WHERE slug LIKE '%' || ? || '%' AND archived = FALSE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?;
+
+-- name: SearchArchivedConversations :many
+SELECT * FROM conversations
+WHERE slug LIKE '%' || ? || '%' AND archived = TRUE
+ORDER BY updated_at DESC
+LIMIT ? OFFSET ?;
+
+-- name: UpdateConversationSlug :one
+UPDATE conversations
+SET slug = ?, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING *;
+
+-- name: UpdateConversationTimestamp :exec
+UPDATE conversations
+SET updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?;
+
+-- name: DeleteConversation :exec
+DELETE FROM conversations
+WHERE conversation_id = ?;
+
+-- name: CountConversations :one
+SELECT COUNT(*) FROM conversations WHERE archived = FALSE;
+
+-- name: CountArchivedConversations :one
+SELECT COUNT(*) FROM conversations WHERE archived = TRUE;
+
+-- name: ArchiveConversation :one
+UPDATE conversations
+SET archived = TRUE, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING *;
+
+-- name: UnarchiveConversation :one
+UPDATE conversations
+SET archived = FALSE, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING *;
+
+-- name: UpdateConversationCwd :one
+UPDATE conversations
+SET cwd = ?, updated_at = CURRENT_TIMESTAMP
+WHERE conversation_id = ?
+RETURNING *;

db/query/messages.sql πŸ”—

@@ -0,0 +1,56 @@
+-- name: CreateMessage :one
+INSERT INTO messages (message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, display_data)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+RETURNING *;
+
+-- name: GetNextSequenceID :one
+SELECT COALESCE(MAX(sequence_id), 0) + 1 
+FROM messages 
+WHERE conversation_id = ?;
+
+-- name: GetMessage :one
+SELECT * FROM messages
+WHERE message_id = ?;
+
+-- name: ListMessages :many
+SELECT * FROM messages
+WHERE conversation_id = ?
+ORDER BY sequence_id ASC;
+
+-- name: ListMessagesPaginated :many
+SELECT * FROM messages
+WHERE conversation_id = ?
+ORDER BY sequence_id ASC
+LIMIT ? OFFSET ?;
+
+-- name: ListMessagesByType :many
+SELECT * FROM messages
+WHERE conversation_id = ? AND type = ?
+ORDER BY sequence_id ASC;
+
+-- name: GetLatestMessage :one
+SELECT * FROM messages
+WHERE conversation_id = ?
+ORDER BY sequence_id DESC
+LIMIT 1;
+
+-- name: DeleteMessage :exec
+DELETE FROM messages
+WHERE message_id = ?;
+
+-- name: DeleteConversationMessages :exec
+DELETE FROM messages
+WHERE conversation_id = ?;
+
+-- name: CountMessagesInConversation :one
+SELECT COUNT(*) FROM messages
+WHERE conversation_id = ?;
+
+-- name: CountMessagesByType :one
+SELECT COUNT(*) FROM messages
+WHERE conversation_id = ? AND type = ?;
+
+-- name: ListMessagesSince :many
+SELECT * FROM messages
+WHERE conversation_id = ? AND sequence_id > ?
+ORDER BY sequence_id ASC;

db/schema/001-conversations.sql πŸ”—

@@ -0,0 +1,22 @@
+-- Conversations table
+-- Each conversation represents a single chat session with the AI agent
+
+-- Create migrations tracking table
+CREATE TABLE migrations (
+    migration_number INTEGER PRIMARY KEY,
+    migration_name TEXT NOT NULL,
+    executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE conversations (
+    conversation_id TEXT PRIMARY KEY,
+    slug TEXT, -- human-readable identifier, can be null initially
+    user_initiated BOOLEAN NOT NULL DEFAULT TRUE, -- FALSE for subagent/tool conversations
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Partial unique index on slug (only for non-NULL values) for uniqueness and faster lookups
+CREATE UNIQUE INDEX idx_conversations_slug_unique ON conversations(slug) WHERE slug IS NOT NULL;
+-- Index on updated_at for ordering by recent activity
+CREATE INDEX idx_conversations_updated_at ON conversations(updated_at DESC);

db/schema/002-messages.sql πŸ”—

@@ -0,0 +1,19 @@
+-- Messages table
+-- Each message is part of a conversation and can be from user, agent, or tool
+CREATE TABLE messages (
+    message_id TEXT PRIMARY KEY,
+    conversation_id TEXT NOT NULL,
+    type TEXT NOT NULL CHECK (type IN ('user', 'agent', 'tool', 'system')),
+    llm_data TEXT, -- JSON data sent to/from LLM
+    user_data TEXT, -- JSON data for UI display
+    usage_data TEXT, -- JSON data about token usage, etc.
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id) ON DELETE CASCADE
+);
+
+-- Index on conversation_id for efficient message retrieval
+CREATE INDEX idx_messages_conversation_id ON messages(conversation_id);
+-- Index on conversation_id and created_at for chronological ordering
+CREATE INDEX idx_messages_conversation_created ON messages(conversation_id, created_at);
+-- Index on type for filtering by message type
+CREATE INDEX idx_messages_type ON messages(type);

db/schema/003-add-message-sequence.sql πŸ”—

@@ -0,0 +1,42 @@
+-- Add autoincrementing sequence_id to messages table for reliable ordering
+-- This eliminates timestamp collision issues when multiple messages are created simultaneously
+
+-- Create new table with sequence_id column
+CREATE TABLE messages_new (
+    message_id TEXT PRIMARY KEY,
+    conversation_id TEXT NOT NULL,
+    sequence_id INTEGER NOT NULL,
+    type TEXT NOT NULL CHECK (type IN ('user', 'agent', 'tool', 'system')),
+    llm_data TEXT, -- JSON data sent to/from LLM
+    user_data TEXT, -- JSON data for UI display
+    usage_data TEXT, -- JSON data about token usage, etc.
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id) ON DELETE CASCADE
+);
+
+-- Copy data from old table to new table with sequence_id based on created_at order
+-- Only run if the new table is empty (idempotent)
+INSERT INTO messages_new (message_id, conversation_id, sequence_id, type, llm_data, user_data, usage_data, created_at)
+SELECT 
+    message_id, 
+    conversation_id,
+    ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY created_at, message_id) as sequence_id,
+    type, 
+    llm_data, 
+    user_data, 
+    usage_data, 
+    created_at
+FROM messages
+WHERE NOT EXISTS (SELECT 1 FROM messages_new LIMIT 1);
+
+-- Replace old table with new table (only if we have data in the new table)
+-- Check if we need to do the table swap
+DROP TABLE IF EXISTS messages_old;
+ALTER TABLE messages RENAME TO messages_old;
+ALTER TABLE messages_new RENAME TO messages;
+DROP TABLE messages_old;
+
+-- Recreate indexes with sequence_id instead of created_at for ordering
+CREATE INDEX idx_messages_conversation_id ON messages(conversation_id);
+CREATE INDEX idx_messages_conversation_sequence ON messages(conversation_id, sequence_id);
+CREATE INDEX idx_messages_type ON messages(type);

db/schema/004-add-error-message-type.sql πŸ”—

@@ -0,0 +1,30 @@
+-- Add 'error' to the message type check constraint
+-- This requires dropping and recreating the messages table with the new constraint
+-- SQLite doesn't support ALTER TABLE to modify CHECK constraints
+
+-- Step 1: Create a new messages table with the updated constraint
+CREATE TABLE messages_new (
+    message_id TEXT PRIMARY KEY,
+    conversation_id TEXT NOT NULL,
+    sequence_id INTEGER NOT NULL,
+    type TEXT NOT NULL CHECK (type IN ('user', 'agent', 'tool', 'system', 'error')),
+    llm_data TEXT, -- JSON data sent to/from LLM
+    user_data TEXT, -- JSON data for UI display
+    usage_data TEXT, -- JSON data about token usage, etc.
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id) ON DELETE CASCADE
+);
+
+-- Step 2: Copy data from old table to new table
+INSERT INTO messages_new SELECT * FROM messages;
+
+-- Step 3: Drop the old table
+DROP TABLE messages;
+
+-- Step 4: Rename the new table
+ALTER TABLE messages_new RENAME TO messages;
+
+-- Step 5: Recreate indexes
+CREATE INDEX idx_messages_conversation_id ON messages(conversation_id);
+CREATE INDEX idx_messages_conversation_sequence ON messages(conversation_id, sequence_id);
+CREATE INDEX idx_messages_type ON messages(type);

db/schema/005-add-display-data.sql πŸ”—

@@ -0,0 +1,4 @@
+-- Add display_data column to messages table for tool-specific UI rendering
+-- This allows us to separate what's sent to the LLM from what's displayed to the user
+
+ALTER TABLE messages ADD COLUMN display_data TEXT; -- JSON data for tool-specific display

db/schema/006-add-cwd.sql πŸ”—

@@ -0,0 +1,4 @@
+-- Add cwd (current working directory) column to conversations
+-- This allows each conversation to have its own working directory for tools
+
+ALTER TABLE conversations ADD COLUMN cwd TEXT;

db/schema/007-add-archived.sql πŸ”—

@@ -0,0 +1,5 @@
+-- Add archived column to conversations
+ALTER TABLE conversations ADD COLUMN archived BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- Index on archived for filtering
+CREATE INDEX idx_conversations_archived ON conversations(archived);

go.mod πŸ”—

@@ -0,0 +1,97 @@
+module shelley.exe.dev
+
+go 1.25.5
+
+require (
+	github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
+	github.com/chromedp/chromedp v0.14.1
+	github.com/google/uuid v1.6.0
+	github.com/oklog/ulid/v2 v2.1.1
+	github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c
+	github.com/richardlehane/crock32 v1.0.1
+	github.com/samber/slog-http v1.8.2
+	github.com/sashabaranov/go-openai v1.41.1
+	go.skia.org/infra v0.0.0-20250421160028-59e18403fd4a
+	golang.org/x/sync v0.19.0
+	mvdan.cc/sh/v3 v3.12.0
+	sketch.dev v0.0.33
+	tailscale.com v1.84.3
+)
+
+require (
+	cel.dev/expr v0.24.0 // indirect
+	filippo.io/edwards25519 v1.1.0 // indirect
+	github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
+	github.com/bitfield/gotestdox v0.2.2 // indirect
+	github.com/cubicdaiya/gonp v1.0.4 // indirect
+	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+	github.com/dnephin/pflag v1.0.7 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/fatih/color v1.18.0 // indirect
+	github.com/fatih/structtag v1.2.0 // indirect
+	github.com/fsnotify/fsnotify v1.9.0 // indirect
+	github.com/go-sql-driver/mysql v1.9.3 // indirect
+	github.com/google/cel-go v0.26.1 // indirect
+	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/jackc/pgpassfile v1.0.0 // indirect
+	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+	github.com/jackc/pgx/v5 v5.7.5 // indirect
+	github.com/jackc/puddle/v2 v2.2.2 // indirect
+	github.com/jinzhu/inflection v1.0.0 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/ncruces/go-strftime v0.1.9 // indirect
+	github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect
+	github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect
+	github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect
+	github.com/pingcap/log v1.1.0 // indirect
+	github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	github.com/riza-io/grpc-go v0.2.0 // indirect
+	github.com/spf13/cobra v1.9.1 // indirect
+	github.com/spf13/pflag v1.0.7 // indirect
+	github.com/sqlc-dev/sqlc v1.30.0 // indirect
+	github.com/stoewer/go-strcase v1.2.0 // indirect
+	github.com/tetratelabs/wazero v1.9.0 // indirect
+	github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect
+	github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect
+	go.opentelemetry.io/otel v1.37.0 // indirect
+	go.opentelemetry.io/otel/trace v1.37.0 // indirect
+	go.uber.org/atomic v1.11.0 // indirect
+	go.uber.org/multierr v1.11.0 // indirect
+	go.uber.org/zap v1.27.0 // indirect
+	golang.org/x/crypto v0.46.0 // indirect
+	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
+	golang.org/x/mod v0.31.0 // indirect
+	golang.org/x/net v0.48.0 // indirect
+	golang.org/x/text v0.32.0 // indirect
+	golang.org/x/tools v0.40.0 // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
+	google.golang.org/grpc v1.75.0 // indirect
+	google.golang.org/protobuf v1.36.8 // indirect
+	gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+	gotest.tools/gotestsum v1.13.0 // indirect
+	modernc.org/libc v1.66.3 // indirect
+	modernc.org/mathutil v1.7.1 // indirect
+	modernc.org/memory v1.11.0 // indirect
+)
+
+require (
+	github.com/chromedp/sysutil v1.1.0 // indirect
+	github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
+	github.com/gobwas/httphead v0.1.0 // indirect
+	github.com/gobwas/pool v0.2.1 // indirect
+	github.com/gobwas/ws v1.4.0 // indirect
+	golang.org/x/sys v0.39.0
+	golang.org/x/term v0.38.0 // indirect
+	modernc.org/sqlite v1.38.2
+)
+
+tool (
+	github.com/sqlc-dev/sqlc/cmd/sqlc
+	golang.org/x/tools/cmd/stringer
+	gotest.tools/gotestsum
+)

go.sum πŸ”—

@@ -0,0 +1,272 @@
+cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
+cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
+github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
+github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE=
+github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
+github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
+github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
+github.com/chromedp/chromedp v0.14.1 h1:0uAbnxewy/Q+Bg7oafVePE/6EXEho9hnaC38f+TTENg=
+github.com/chromedp/chromedp v0.14.1/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws=
+github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
+github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
+github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
+github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
+github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
+github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
+github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
+github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
+github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
+github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
+github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls=
+github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50=
+github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
+github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb h1:3pSi4EDG6hg0orE1ndHkXvX6Qdq2cZn8gAPir8ymKZk=
+github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg=
+github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE=
+github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4=
+github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8=
+github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4=
+github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0=
+github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE=
+github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c h1:8TRxBMS/YsupXoOiGKHr9ZOXo+5DezGWPgBAhBHEHto=
+github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/richardlehane/crock32 v1.0.1 h1:GV9EqtAr7RminQ8oGrDt3gYXkzDDPJ5fROaO1Mux14g=
+github.com/richardlehane/crock32 v1.0.1/go.mod h1:xUIlLABtHBgs1bNIBdUQR9F2xtRzS0TujtbR68hmEWU=
+github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ=
+github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/samber/slog-http v1.8.2 h1:4UJ5n+kw8BYo1pn+mu03M/DTqAZj6FFOawhLj8MYENk=
+github.com/samber/slog-http v1.8.2/go.mod h1:PAcQQrYFo5KM7Qbk50gNNwKEAMGCyfsw6GN5dI0iv9g=
+github.com/sashabaranov/go-openai v1.41.1 h1:zf5tM+GuxpyiyD9XZg8nCqu52eYFQg9OOew0gnIuDy4=
+github.com/sashabaranov/go-openai v1.41.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
+github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/sqlc-dev/sqlc v1.30.0 h1:H4HrNwPc0hntxGWzAbhlfplPRN4bQpXFx+CaEMcKz6c=
+github.com/sqlc-dev/sqlc v1.30.0/go.mod h1:QnEN+npugyhUg1A+1kkYM3jc2OMOFsNlZ1eh8mdhad0=
+github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
+github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
+github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
+github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo=
+github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM=
+github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4=
+github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
+go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
+go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
+go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
+go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
+go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
+go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
+go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
+go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
+go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+go.skia.org/infra v0.0.0-20250421160028-59e18403fd4a h1:XqDi+8oE4eakFiXZXmQlsPaZTTdsPOy54jP3my6lIcU=
+go.skia.org/infra v0.0.0-20250421160028-59e18403fd4a/go.mod h1:itQeLiwIYtXPJJEqdxRpOlS77LNv/quHjkyy+SaXrkw=
+go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
+go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
+go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
+golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
+golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
+golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
+golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
+golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU=
+google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
+google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
+google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo=
+gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q=
+gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
+modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
+modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
+modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
+modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
+modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
+modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
+modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI=
+mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg=
+sketch.dev v0.0.33 h1:HbsXZt5NgRzlEQhWyzRtw7zf1r5t+0ek7xJihcklfPw=
+sketch.dev v0.0.33/go.mod h1:/IfRuZ0JiI0nVEx20JKAOCCcZh3dsqmjpjZJ4W2jLC0=
+tailscale.com v1.84.3 h1:Ur9LMedSgicwbqpy5xn7t49G8490/s6rqAJOk5Q5AYE=
+tailscale.com v1.84.3/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo=

llm/ant/ant.go πŸ”—

@@ -0,0 +1,623 @@
+package ant
+
+import (
+	"bytes"
+	"cmp"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"math/rand/v2"
+	"net/http"
+	"strings"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/llm"
+)
+
+const (
+	DefaultModel = Claude45Sonnet
+	// See https://docs.anthropic.com/en/docs/about-claude/models/all-models for
+	// current maximums. There's currently a flag to enable 128k output (output-128k-2025-02-19)
+	DefaultMaxTokens = 8192
+	APIKeyEnv        = "ANTHROPIC_API_KEY"
+	DefaultURL       = "https://api.anthropic.com/v1/messages"
+)
+
+const (
+	Claude45Haiku  = "claude-haiku-4-5-20251001"
+	Claude37Sonnet = "claude-3-7-sonnet-20250219"
+	Claude4Sonnet  = "claude-sonnet-4-20250514"
+	Claude45Sonnet = "claude-sonnet-4-5-20250929"
+	Claude45Opus   = "claude-opus-4-5-20251101"
+)
+
+// IsClaudeModel reports whether userName is a user-friendly Claude model.
+// It uses ClaudeModelName under the hood.
+func IsClaudeModel(userName string) bool {
+	return ClaudeModelName(userName) != ""
+}
+
+// ClaudeModelName returns the Anthropic Claude model name for userName.
+// It returns an empty string if userName is not a recognized Claude model.
+func ClaudeModelName(userName string) string {
+	switch userName {
+	case "claude", "sonnet":
+		return Claude45Sonnet
+	case "opus":
+		return Claude45Opus
+	default:
+		return ""
+	}
+}
+
+// TokenContextWindow returns the maximum token context window size for this service
+func (s *Service) TokenContextWindow() int {
+	model := s.Model
+	if model == "" {
+		model = DefaultModel
+	}
+
+	switch model {
+	case Claude37Sonnet, Claude4Sonnet, Claude45Sonnet:
+		return 200000
+	case Claude45Haiku:
+		return 200000
+	case Claude45Opus:
+		return 200000
+	default:
+		// Default for unknown models
+		return 200000
+	}
+}
+
+// HTTPRecorder is a callback for recording HTTP request/response data for debugging
+type HTTPRecorder func(url string, requestBody, responseBody []byte, statusCode int, err error, duration time.Duration)
+
+// Service provides Claude completions.
+// Fields should not be altered concurrently with calling any method on Service.
+type Service struct {
+	HTTPC        *http.Client // defaults to http.DefaultClient if nil
+	URL          string       // defaults to DefaultURL if empty
+	APIKey       string       // must be non-empty
+	Model        string       // defaults to DefaultModel if empty
+	MaxTokens    int          // defaults to DefaultMaxTokens if zero
+	DumpLLM      bool         // whether to dump request/response text to files for debugging; defaults to false
+	HTTPRecorder HTTPRecorder // optional callback for recording HTTP requests/responses
+}
+
+var _ llm.Service = (*Service)(nil)
+
+type content struct {
+	// https://docs.anthropic.com/en/api/messages
+	ID   string `json:"id,omitempty"`
+	Type string `json:"type,omitempty"`
+
+	// Subtly, an empty string appears in tool results often, so we have
+	// to distinguish between empty string and no string.
+	// Underlying error looks like one of:
+	//   "messages.46.content.0.tool_result.content.0.text.text: Field required""
+	//   "messages.1.content.1.tool_use.text: Extra inputs are not permitted"
+	//
+	// I haven't found a super great source for the API, but
+	// https://github.com/anthropics/anthropic-sdk-typescript/blob/main/src/resources/messages/messages.ts
+	// is somewhat acceptable but hard to read.
+	Text      *string         `json:"text,omitempty"`
+	MediaType string          `json:"media_type,omitempty"` // for image
+	Source    json.RawMessage `json:"source,omitempty"`     // for image
+
+	// for thinking
+	Thinking  string `json:"thinking,omitempty"`
+	Data      string `json:"data,omitempty"`      // for redacted_thinking or image
+	Signature string `json:"signature,omitempty"` // for thinking
+
+	// for tool_use
+	ToolName  string          `json:"name,omitempty"`
+	ToolInput json.RawMessage `json:"input,omitempty"`
+
+	// for tool_result
+	ToolUseID string `json:"tool_use_id,omitempty"`
+	ToolError bool   `json:"is_error,omitempty"`
+	// note the recursive nature here; message looks like:
+	// {
+	//  "role": "user",
+	//  "content": [
+	//    {
+	//      "type": "tool_result",
+	//      "tool_use_id": "toolu_01A09q90qw90lq917835lq9",
+	//      "content": [
+	//        {"type": "text", "text": "15 degrees"},
+	//        {
+	//          "type": "image",
+	//          "source": {
+	//            "type": "base64",
+	//            "media_type": "image/jpeg",
+	//            "data": "/9j/4AAQSkZJRg...",
+	//          }
+	//        }
+	//      ]
+	//    }
+	//  ]
+	//}
+	ToolResult []content `json:"content,omitempty"`
+
+	// timing information for tool_result; not sent to Claude
+	StartTime *time.Time `json:"-"`
+	EndTime   *time.Time `json:"-"`
+
+	CacheControl json.RawMessage `json:"cache_control,omitempty"`
+}
+
+// message represents a message in the conversation.
+type message struct {
+	Role    string    `json:"role"`
+	Content []content `json:"content"`
+	ToolUse *toolUse  `json:"tool_use,omitempty"` // use to control whether/which tool to use
+}
+
+// toolUse represents a tool use in the message content.
+type toolUse struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+}
+
+// tool represents a tool available to Claude.
+type tool struct {
+	Name string `json:"name"`
+	// Type is used by the text editor tool; see
+	// https://docs.anthropic.com/en/docs/build-with-claude/tool-use/text-editor-tool
+	Type         string          `json:"type,omitempty"`
+	Description  string          `json:"description,omitempty"`
+	InputSchema  json.RawMessage `json:"input_schema,omitempty"`
+	CacheControl json.RawMessage `json:"cache_control,omitempty"`
+}
+
+// usage represents the billing and rate-limit usage.
+type usage struct {
+	InputTokens              uint64  `json:"input_tokens"`
+	CacheCreationInputTokens uint64  `json:"cache_creation_input_tokens"`
+	CacheReadInputTokens     uint64  `json:"cache_read_input_tokens"`
+	OutputTokens             uint64  `json:"output_tokens"`
+	CostUSD                  float64 `json:"cost_usd"`
+}
+
+func (u *usage) Add(other usage) {
+	u.InputTokens += other.InputTokens
+	u.CacheCreationInputTokens += other.CacheCreationInputTokens
+	u.CacheReadInputTokens += other.CacheReadInputTokens
+	u.OutputTokens += other.OutputTokens
+	u.CostUSD += other.CostUSD
+}
+
+// response represents the response from the message API.
+type response struct {
+	ID           string    `json:"id"`
+	Type         string    `json:"type"`
+	Role         string    `json:"role"`
+	Model        string    `json:"model"`
+	Content      []content `json:"content"`
+	StopReason   string    `json:"stop_reason"`
+	StopSequence *string   `json:"stop_sequence,omitempty"`
+	Usage        usage     `json:"usage"`
+}
+
+type toolChoice struct {
+	Type string `json:"type"`
+	Name string `json:"name,omitempty"`
+}
+
+// https://docs.anthropic.com/en/api/messages#body-system
+type systemContent struct {
+	Text         string          `json:"text,omitempty"`
+	Type         string          `json:"type,omitempty"`
+	CacheControl json.RawMessage `json:"cache_control,omitempty"`
+}
+
+// request represents the request payload for creating a message.
+type request struct {
+	Model         string          `json:"model"`
+	Messages      []message       `json:"messages"`
+	ToolChoice    *toolChoice     `json:"tool_choice,omitempty"`
+	MaxTokens     int             `json:"max_tokens"`
+	Tools         []*tool         `json:"tools,omitempty"`
+	Stream        bool            `json:"stream,omitempty"`
+	System        []systemContent `json:"system,omitempty"`
+	Temperature   float64         `json:"temperature,omitempty"`
+	TopK          int             `json:"top_k,omitempty"`
+	TopP          float64         `json:"top_p,omitempty"`
+	StopSequences []string        `json:"stop_sequences,omitempty"`
+
+	TokenEfficientToolUse bool `json:"-"` // DO NOT USE, broken on Anthropic's side as of 2025-02-28
+}
+
+func mapped[Slice ~[]E, E, T any](s Slice, f func(E) T) []T {
+	out := make([]T, len(s))
+	for i, v := range s {
+		out[i] = f(v)
+	}
+	return out
+}
+
+func inverted[K, V cmp.Ordered](m map[K]V) map[V]K {
+	inv := make(map[V]K)
+	for k, v := range m {
+		if _, ok := inv[v]; ok {
+			panic(fmt.Errorf("inverted map has multiple keys for value %v", v))
+		}
+		inv[v] = k
+	}
+	return inv
+}
+
+var (
+	fromLLMRole = map[llm.MessageRole]string{
+		llm.MessageRoleAssistant: "assistant",
+		llm.MessageRoleUser:      "user",
+	}
+	toLLMRole = inverted(fromLLMRole)
+
+	fromLLMContentType = map[llm.ContentType]string{
+		llm.ContentTypeText:             "text",
+		llm.ContentTypeThinking:         "thinking",
+		llm.ContentTypeRedactedThinking: "redacted_thinking",
+		llm.ContentTypeToolUse:          "tool_use",
+		llm.ContentTypeToolResult:       "tool_result",
+	}
+	toLLMContentType = inverted(fromLLMContentType)
+
+	fromLLMToolChoiceType = map[llm.ToolChoiceType]string{
+		llm.ToolChoiceTypeAuto: "auto",
+		llm.ToolChoiceTypeAny:  "any",
+		llm.ToolChoiceTypeNone: "none",
+		llm.ToolChoiceTypeTool: "tool",
+	}
+
+	toLLMStopReason = map[string]llm.StopReason{
+		"stop_sequence": llm.StopReasonStopSequence,
+		"max_tokens":    llm.StopReasonMaxTokens,
+		"end_turn":      llm.StopReasonEndTurn,
+		"tool_use":      llm.StopReasonToolUse,
+		"refusal":       llm.StopReasonRefusal,
+	}
+)
+
+func fromLLMCache(c bool) json.RawMessage {
+	if !c {
+		return nil
+	}
+	return json.RawMessage(`{"type":"ephemeral"}`)
+}
+
+func fromLLMContent(c llm.Content) content {
+	var toolResult []content
+	if len(c.ToolResult) > 0 {
+		toolResult = make([]content, len(c.ToolResult))
+		for i, tr := range c.ToolResult {
+			// For image content inside a tool_result, we need to map it to "image" type
+			if tr.MediaType != "" && tr.MediaType == "image/jpeg" || tr.MediaType == "image/png" {
+				// Format as an image for Claude
+				toolResult[i] = content{
+					Type: "image",
+					Source: json.RawMessage(fmt.Sprintf(`{"type":"base64","media_type":"%s","data":"%s"}`,
+						tr.MediaType, tr.Data)),
+				}
+			} else {
+				toolResult[i] = fromLLMContent(tr)
+			}
+		}
+	}
+
+	d := content{
+		Type:         fromLLMContentType[c.Type],
+		CacheControl: fromLLMCache(c.Cache),
+	}
+
+	// Set fields based on content type to avoid sending invalid fields
+	switch c.Type {
+	case llm.ContentTypeText:
+		// Images are represented as text with MediaType and Data
+		if c.MediaType != "" {
+			d.Type = "image"
+			d.Source = json.RawMessage(fmt.Sprintf(`{"type":"base64","media_type":"%s","data":"%s"}`,
+				c.MediaType, c.Data))
+		} else {
+			d.Text = &c.Text
+		}
+	case llm.ContentTypeThinking:
+		d.Thinking = c.Thinking
+		d.Signature = c.Signature
+	case llm.ContentTypeRedactedThinking:
+		d.Data = c.Data
+		d.Signature = c.Signature
+	case llm.ContentTypeToolUse:
+		d.ID = c.ID
+		d.ToolName = c.ToolName
+		d.ToolInput = c.ToolInput
+	case llm.ContentTypeToolResult:
+		d.ToolUseID = c.ToolUseID
+		d.ToolError = c.ToolError
+		d.ToolResult = toolResult
+	}
+
+	return d
+}
+
+func fromLLMToolUse(tu *llm.ToolUse) *toolUse {
+	if tu == nil {
+		return nil
+	}
+	return &toolUse{
+		ID:   tu.ID,
+		Name: tu.Name,
+	}
+}
+
+func fromLLMMessage(msg llm.Message) message {
+	return message{
+		Role:    fromLLMRole[msg.Role],
+		Content: mapped(msg.Content, fromLLMContent),
+		ToolUse: fromLLMToolUse(msg.ToolUse),
+	}
+}
+
+func fromLLMToolChoice(tc *llm.ToolChoice) *toolChoice {
+	if tc == nil {
+		return nil
+	}
+	return &toolChoice{
+		Type: fromLLMToolChoiceType[tc.Type],
+		Name: tc.Name,
+	}
+}
+
+func fromLLMTool(t *llm.Tool) *tool {
+	return &tool{
+		Name:         t.Name,
+		Type:         t.Type,
+		Description:  t.Description,
+		InputSchema:  t.InputSchema,
+		CacheControl: fromLLMCache(t.Cache),
+	}
+}
+
+func fromLLMSystem(s llm.SystemContent) systemContent {
+	return systemContent{
+		Text:         s.Text,
+		Type:         s.Type,
+		CacheControl: fromLLMCache(s.Cache),
+	}
+}
+
+func (s *Service) fromLLMRequest(r *llm.Request) *request {
+	return &request{
+		Model:      cmp.Or(s.Model, DefaultModel),
+		Messages:   mapped(r.Messages, fromLLMMessage),
+		MaxTokens:  cmp.Or(s.MaxTokens, DefaultMaxTokens),
+		ToolChoice: fromLLMToolChoice(r.ToolChoice),
+		Tools:      mapped(r.Tools, fromLLMTool),
+		System:     mapped(r.System, fromLLMSystem),
+	}
+}
+
+func toLLMUsage(u usage) llm.Usage {
+	return llm.Usage{
+		InputTokens:              u.InputTokens,
+		CacheCreationInputTokens: u.CacheCreationInputTokens,
+		CacheReadInputTokens:     u.CacheReadInputTokens,
+		OutputTokens:             u.OutputTokens,
+		CostUSD:                  u.CostUSD,
+	}
+}
+
+func toLLMContent(c content) llm.Content {
+	// Convert toolResult from []content to []llm.Content
+	var toolResultContents []llm.Content
+	if len(c.ToolResult) > 0 {
+		toolResultContents = make([]llm.Content, len(c.ToolResult))
+		for i, tr := range c.ToolResult {
+			toolResultContents[i] = toLLMContent(tr)
+		}
+	}
+
+	ret := llm.Content{
+		ID:         c.ID,
+		Type:       toLLMContentType[c.Type],
+		MediaType:  c.MediaType,
+		Thinking:   c.Thinking,
+		Data:       c.Data,
+		Signature:  c.Signature,
+		ToolName:   c.ToolName,
+		ToolInput:  c.ToolInput,
+		ToolUseID:  c.ToolUseID,
+		ToolError:  c.ToolError,
+		ToolResult: toolResultContents,
+	}
+	if c.Text != nil {
+		ret.Text = *c.Text
+	}
+	return ret
+}
+
+func toLLMResponse(r *response) *llm.Response {
+	return &llm.Response{
+		ID:           r.ID,
+		Type:         r.Type,
+		Role:         toLLMRole[r.Role],
+		Model:        r.Model,
+		Content:      mapped(r.Content, toLLMContent),
+		StopReason:   toLLMStopReason[r.StopReason],
+		StopSequence: r.StopSequence,
+		Usage:        toLLMUsage(r.Usage),
+	}
+}
+
+// Do sends a request to Anthropic.
+func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error) {
+	startTime := time.Now()
+	request := s.fromLLMRequest(ir)
+	var payload []byte
+	var err error
+	if s.DumpLLM || testing.Testing() {
+		payload, err = json.MarshalIndent(request, "", " ")
+	} else {
+		payload, err = json.Marshal(request)
+		payload = append(payload, '\n')
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	if false {
+		fmt.Printf("claude request payload:\n%s\n", payload)
+	}
+
+	backoff := []time.Duration{15 * time.Second, 30 * time.Second, time.Minute}
+	largerMaxTokens := false
+	var partialUsage usage
+
+	url := cmp.Or(s.URL, DefaultURL)
+	httpc := cmp.Or(s.HTTPC, http.DefaultClient)
+
+	// For recording the last attempt's response
+	var lastResponseBody []byte
+	var lastStatusCode int
+	var finalErr error
+	defer func() {
+		if s.HTTPRecorder != nil {
+			s.HTTPRecorder(url, payload, lastResponseBody, lastStatusCode, finalErr, time.Since(startTime))
+		}
+	}()
+
+	// retry loop
+	var errs error // accumulated errors across all attempts
+	for attempts := 0; ; attempts++ {
+		if attempts > 10 {
+			return nil, fmt.Errorf("anthropic request failed after %d attempts: %w", attempts, errs)
+		}
+		if attempts > 0 {
+			sleep := backoff[min(attempts, len(backoff)-1)] + time.Duration(rand.Int64N(int64(time.Second)))
+			slog.WarnContext(ctx, "anthropic request sleep before retry", "sleep", sleep, "attempts", attempts)
+			time.Sleep(sleep)
+		}
+		if s.DumpLLM {
+			if err := llm.DumpToFile("request", url, payload); err != nil {
+				slog.WarnContext(ctx, "failed to dump request to file", "error", err)
+			}
+		}
+		req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
+		if err != nil {
+			return nil, errors.Join(errs, err)
+		}
+
+		req.Header.Set("Content-Type", "application/json")
+		req.Header.Set("X-API-Key", s.APIKey)
+		req.Header.Set("Anthropic-Version", "2023-06-01")
+
+		var features []string
+		if request.TokenEfficientToolUse {
+			features = append(features, "token-efficient-tool-use-2025-02-19")
+		}
+		if largerMaxTokens {
+			features = append(features, "output-128k-2025-02-19")
+			request.MaxTokens = 128 * 1024
+		}
+		if len(features) > 0 {
+			req.Header.Set("anthropic-beta", strings.Join(features, ","))
+		}
+
+		resp, err := httpc.Do(req)
+		if err != nil {
+			// Don't retry httprr cache misses
+			if strings.Contains(err.Error(), "cached HTTP response not found") {
+				return nil, err
+			}
+			errs = errors.Join(errs, err)
+			continue
+		}
+		buf, err := io.ReadAll(resp.Body)
+		resp.Body.Close()
+		if err != nil {
+			errs = errors.Join(errs, err)
+			continue
+		}
+
+		// Record response for HTTPRecorder callback
+		lastResponseBody = buf
+		lastStatusCode = resp.StatusCode
+
+		switch {
+		case resp.StatusCode == http.StatusOK:
+			if s.DumpLLM {
+				if err := llm.DumpToFile("response", "", buf); err != nil {
+					slog.WarnContext(ctx, "failed to dump response to file", "error", err)
+				}
+			}
+			var response response
+			err = json.NewDecoder(bytes.NewReader(buf)).Decode(&response)
+			if err != nil {
+				return nil, errors.Join(errs, err)
+			}
+			if response.StopReason == "max_tokens" && !largerMaxTokens {
+				slog.InfoContext(ctx, "anthropic_retrying_with_larger_tokens", "message", "Retrying Anthropic API call with larger max tokens size")
+				// Retry with more output tokens.
+				largerMaxTokens = true
+				response.Usage.CostUSD = llm.CostUSDFromResponse(resp.Header)
+				partialUsage = response.Usage
+				continue
+			}
+
+			// Calculate and set the cost_usd field
+			if largerMaxTokens {
+				response.Usage.Add(partialUsage)
+			}
+			response.Usage.CostUSD = llm.CostUSDFromResponse(resp.Header)
+
+			endTime := time.Now()
+			result := toLLMResponse(&response)
+			result.StartTime = &startTime
+			result.EndTime = &endTime
+			return result, nil
+		case resp.StatusCode >= 500 && resp.StatusCode < 600:
+			// server error, retry
+			slog.WarnContext(ctx, "anthropic_request_failed", "response", string(buf), "status_code", resp.StatusCode, "url", url, "model", s.Model)
+			errs = errors.Join(errs, fmt.Errorf("status %v (url=%s, model=%s): %s", resp.Status, url, cmp.Or(s.Model, DefaultModel), buf))
+			finalErr = errs
+			continue
+		case resp.StatusCode == 429:
+			// rate limited, retry
+			slog.WarnContext(ctx, "anthropic_request_rate_limited", "response", string(buf), "url", url, "model", s.Model)
+			errs = errors.Join(errs, fmt.Errorf("status %v (url=%s, model=%s): %s", resp.Status, url, cmp.Or(s.Model, DefaultModel), buf))
+			finalErr = errs
+			continue
+		case resp.StatusCode >= 400 && resp.StatusCode < 500:
+			// some other 400, probably unrecoverable
+			slog.WarnContext(ctx, "anthropic_request_failed", "response", string(buf), "status_code", resp.StatusCode, "url", url, "model", s.Model)
+			return nil, errors.Join(errs, fmt.Errorf("status %v (url=%s, model=%s): %s", resp.Status, url, cmp.Or(s.Model, DefaultModel), buf))
+		default:
+			// ...retry, I guess?
+			slog.WarnContext(ctx, "anthropic_request_failed", "response", string(buf), "status_code", resp.StatusCode, "url", url, "model", s.Model)
+			errs = errors.Join(errs, fmt.Errorf("status %v (url=%s, model=%s): %s", resp.Status, url, cmp.Or(s.Model, DefaultModel), buf))
+			finalErr = errs
+			continue
+		}
+	}
+}
+
+// For debugging only, Claude can definitely handle the full patch tool.
+// func (s *Service) UseSimplifiedPatch() bool {
+// 	return true
+// }
+
+// ConfigDetails returns configuration information for logging
+func (s *Service) ConfigDetails() map[string]string {
+	model := cmp.Or(s.Model, DefaultModel)
+	url := cmp.Or(s.URL, DefaultURL)
+	return map[string]string{
+		"url":             url,
+		"model":           model,
+		"has_api_key_set": fmt.Sprintf("%v", s.APIKey != ""),
+	}
+}

llm/ant/ant_image_test.go πŸ”—

@@ -0,0 +1,78 @@
+package ant
+
+import (
+	"encoding/json"
+	"testing"
+
+	"shelley.exe.dev/llm"
+)
+
+func TestAnthropicImageToolResult(t *testing.T) {
+	// Create a tool result with both text and image content
+	textContent := llm.Content{
+		Type: llm.ContentTypeText,
+		Text: "15 degrees",
+	}
+
+	imageContent := llm.Content{
+		Type:      llm.ContentTypeText, // Will be mapped to "image" in Anthropic format
+		MediaType: "image/jpeg",
+		Data:      "/9j/4AAQSkZJRg...", // Shortened base64 encoded image
+	}
+
+	toolResult := llm.Content{
+		Type:       llm.ContentTypeToolResult,
+		ToolUseID:  "toolu_01A09q90qw90lq917835lq9",
+		ToolResult: []llm.Content{textContent, imageContent},
+	}
+
+	// Convert to Anthropic format
+	anthropicContent := fromLLMContent(toolResult)
+
+	// Check the type
+	if anthropicContent.Type != "tool_result" {
+		t.Errorf("Expected type to be 'tool_result', got '%s'", anthropicContent.Type)
+	}
+
+	// Check the tool_use_id
+	if anthropicContent.ToolUseID != "toolu_01A09q90qw90lq917835lq9" {
+		t.Errorf("Expected tool_use_id to be 'toolu_01A09q90qw90lq917835lq9', got '%s'", anthropicContent.ToolUseID)
+	}
+
+	// Check that we have two content items in the tool result
+	if len(anthropicContent.ToolResult) != 2 {
+		t.Errorf("Expected 2 content items, got %d", len(anthropicContent.ToolResult))
+	}
+
+	// Check that the first item is text
+	if anthropicContent.ToolResult[0].Type != "text" {
+		t.Errorf("Expected first content type to be 'text', got '%s'", anthropicContent.ToolResult[0].Type)
+	}
+
+	if *anthropicContent.ToolResult[0].Text != "15 degrees" {
+		t.Errorf("Expected first content text to be '15 degrees', got '%s'", *anthropicContent.ToolResult[0].Text)
+	}
+
+	// Check that the second item is an image
+	if anthropicContent.ToolResult[1].Type != "image" {
+		t.Errorf("Expected second content type to be 'image', got '%s'", anthropicContent.ToolResult[1].Type)
+	}
+
+	// Check that the image source contains the expected format
+	var source map[string]any
+	if err := json.Unmarshal(anthropicContent.ToolResult[1].Source, &source); err != nil {
+		t.Errorf("Failed to unmarshal image source: %v", err)
+	}
+
+	if source["type"] != "base64" {
+		t.Errorf("Expected source type to be 'base64', got '%s'", source["type"])
+	}
+
+	if source["media_type"] != "image/jpeg" {
+		t.Errorf("Expected media_type to be 'image/jpeg', got '%s'", source["media_type"])
+	}
+
+	if source["data"] != "/9j/4AAQSkZJRg..." {
+		t.Errorf("Expected data to be '/9j/4AAQSkZJRg...', got '%s'", source["data"])
+	}
+}

llm/ant/content_fields_test.go πŸ”—

@@ -0,0 +1,94 @@
+package ant
+
+import (
+	"encoding/json"
+	"testing"
+
+	"shelley.exe.dev/llm"
+)
+
+// TestTextContentNoExtraFields verifies that text content doesn't include fields from other content types
+func TestTextContentNoExtraFields(t *testing.T) {
+	tests := []struct {
+		name          string
+		content       llm.Content
+		allowedFields map[string]bool
+	}{
+		{
+			name: "text content",
+			content: llm.Content{
+				Type: llm.ContentTypeText,
+				Text: "Hello world",
+			},
+			allowedFields: map[string]bool{
+				"type": true,
+				"text": true,
+			},
+		},
+		{
+			name: "tool_use content",
+			content: llm.Content{
+				Type:      llm.ContentTypeToolUse,
+				ID:        "toolu_123",
+				ToolName:  "bash",
+				ToolInput: json.RawMessage(`{"command":"ls"}`),
+			},
+			allowedFields: map[string]bool{
+				"type":  true,
+				"id":    true,
+				"name":  true,
+				"input": true,
+			},
+		},
+		{
+			name: "tool_result content",
+			content: llm.Content{
+				Type:      llm.ContentTypeToolResult,
+				ToolUseID: "toolu_123",
+				ToolResult: []llm.Content{
+					{Type: llm.ContentTypeText, Text: "result"},
+				},
+			},
+			allowedFields: map[string]bool{
+				"type":        true,
+				"tool_use_id": true,
+				"content":     true,
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			antContent := fromLLMContent(tt.content)
+			jsonBytes, err := json.Marshal(antContent)
+			if err != nil {
+				t.Fatalf("failed to marshal content: %v", err)
+			}
+
+			var result map[string]interface{}
+			if err := json.Unmarshal(jsonBytes, &result); err != nil {
+				t.Fatalf("failed to unmarshal JSON: %v", err)
+			}
+
+			// Check that only allowed fields are present
+			for field := range result {
+				if !tt.allowedFields[field] {
+					t.Errorf("unexpected field %q in %s content: %s", field, tt.name, string(jsonBytes))
+				}
+			}
+
+			// Check that all required fields are present
+			for field := range tt.allowedFields {
+				if _, ok := result[field]; !ok && field != "cache_control" {
+					// cache_control is optional, so we don't require it
+					if field != "content" || tt.content.Type == llm.ContentTypeToolResult {
+						// Only check for content field if it's a tool_result
+						if field == "content" && tt.content.Type == llm.ContentTypeToolResult {
+							t.Errorf("missing required field %q in %s content: %s", field, tt.name, string(jsonBytes))
+						}
+					}
+				}
+			}
+		})
+	}
+}

llm/conversation/convo.go πŸ”—

@@ -0,0 +1,656 @@
+package conversation
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log/slog"
+	"maps"
+	"math/rand/v2"
+	"slices"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/oklog/ulid/v2"
+	"github.com/richardlehane/crock32"
+	"shelley.exe.dev/llm"
+	"sketch.dev/skribe"
+)
+
+type Listener interface {
+	// TODO: Content is leaking an anthropic API; should we avoid it?
+	// TODO: Where should we include start/end time and usage?
+	OnToolCall(ctx context.Context, convo *Convo, toolCallID, toolName string, toolInput json.RawMessage, content llm.Content)
+	OnToolResult(ctx context.Context, convo *Convo, toolCallID, toolName string, toolInput json.RawMessage, content llm.Content, result *string, err error)
+	OnRequest(ctx context.Context, convo *Convo, requestID string, msg *llm.Message)
+	OnResponse(ctx context.Context, convo *Convo, requestID string, msg *llm.Response)
+}
+
+type NoopListener struct{}
+
+func (n *NoopListener) OnToolCall(ctx context.Context, convo *Convo, id, toolName string, toolInput json.RawMessage, content llm.Content) {
+}
+
+func (n *NoopListener) OnToolResult(ctx context.Context, convo *Convo, id, toolName string, toolInput json.RawMessage, content llm.Content, result *string, err error) {
+}
+
+func (n *NoopListener) OnResponse(ctx context.Context, convo *Convo, id string, msg *llm.Response) {
+}
+func (n *NoopListener) OnRequest(ctx context.Context, convo *Convo, id string, msg *llm.Message) {}
+
+var ErrDoNotRespond = errors.New("do not respond")
+
+// A Convo is a managed conversation with Claude.
+// It automatically manages the state of the conversation,
+// including appending messages send/received,
+// calling tools and sending their results,
+// tracking usage, etc.
+//
+// Exported fields must not be altered concurrently with calling any method on Convo.
+// Typical usage is to configure a Convo once before using it.
+type Convo struct {
+	// ID is a unique ID for the conversation
+	ID string
+	// Ctx is the context for the entire conversation.
+	Ctx context.Context
+	// Service is the LLM service to use.
+	Service llm.Service
+	// Tools are the tools available during the conversation.
+	Tools []*llm.Tool
+	// SystemPrompt is the system prompt for the conversation.
+	SystemPrompt string
+	// PromptCaching indicates whether to use Anthropic's prompt caching.
+	// See https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#continuing-a-multi-turn-conversation
+	// for the documentation. At request send time, we set the cache_control field on the
+	// last message. We also cache the system prompt.
+	// Default: true.
+	PromptCaching bool
+	// ToolUseOnly indicates whether Claude may only use tools during this conversation.
+	// TODO: add more fine-grained control over tool use?
+	ToolUseOnly bool
+	// Parent is the parent conversation, if any.
+	// It is non-nil for "subagent" calls.
+	// It is set automatically when calling SubConvo,
+	// and usually should not be set manually.
+	Parent *Convo
+	// Budget is the budget for this conversation (and all sub-conversations).
+	// The Conversation DOES NOT automatically enforce the budget.
+	// It is up to the caller to call OverBudget() as appropriate.
+	Budget Budget
+	// Hidden indicates that the output of this conversation should be hidden in the UI.
+	// This is useful for subconversations that can generate noisy, uninteresting output.
+	Hidden bool
+	// ExtraData is extra data to make available to all tool calls.
+	ExtraData map[string]any
+
+	// messages tracks the messages so far in the conversation.
+	messages []llm.Message
+
+	// Listener receives messages being sent.
+	Listener Listener
+
+	toolUseCancelMu sync.Mutex
+	toolUseCancel   map[string]context.CancelCauseFunc
+
+	// Protects usage. This is used for subconversations (that share part of CumulativeUsage) as well.
+	mu *sync.Mutex
+	// usage tracks usage for this conversation and all sub-conversations.
+	usage *CumulativeUsage
+	// lastUsage tracks the usage from the most recent API call
+	lastUsage llm.Usage
+}
+
+// newConvoID generates a new 8-byte random id.
+// The uniqueness/collision requirements here are very low.
+// They are not global identifiers,
+// just enough to distinguish different convos in a single session.
+func newConvoID() string {
+	u1 := rand.Uint32()
+	s := crock32.Encode(uint64(u1))
+	if len(s) < 7 {
+		s += strings.Repeat("0", 7-len(s))
+	}
+	return s[:3] + "-" + s[3:]
+}
+
+// New creates a new conversation with Claude with sensible defaults.
+// ctx is the context for the entire conversation.
+func New(ctx context.Context, srv llm.Service, usage *CumulativeUsage) *Convo {
+	id := newConvoID()
+	if usage == nil {
+		usage = newUsage()
+	}
+	return &Convo{
+		Ctx:           skribe.ContextWithAttr(ctx, slog.String("convo_id", id)),
+		Service:       srv,
+		PromptCaching: true,
+		usage:         usage,
+		Listener:      &NoopListener{},
+		ID:            id,
+		toolUseCancel: map[string]context.CancelCauseFunc{},
+		mu:            &sync.Mutex{},
+	}
+}
+
+// SubConvo creates a sub-conversation with the same configuration as the parent conversation.
+// (This propagates context for cancellation, HTTP client, API key, etc.)
+// The sub-conversation shares no messages with the parent conversation.
+// It does not inherit tools from the parent conversation.
+func (c *Convo) SubConvo() *Convo {
+	id := newConvoID()
+	return &Convo{
+		Ctx:           skribe.ContextWithAttr(c.Ctx, slog.String("convo_id", id), slog.String("parent_convo_id", c.ID)),
+		Service:       c.Service,
+		PromptCaching: c.PromptCaching,
+		Parent:        c,
+		// For convenience, sub-convo usage shares tool uses map with parent,
+		// all other fields separate, propagated in AddResponse
+		usage:         newUsageWithSharedToolUses(c.usage),
+		mu:            c.mu,
+		Listener:      c.Listener,
+		ID:            id,
+		toolUseCancel: map[string]context.CancelCauseFunc{},
+		// Do not copy Budget. Each budget is independent,
+		// and OverBudget checks whether any ancestor is over budget.
+	}
+}
+
+func (c *Convo) SubConvoWithHistory() *Convo {
+	id := newConvoID()
+	return &Convo{
+		Ctx:           skribe.ContextWithAttr(c.Ctx, slog.String("convo_id", id), slog.String("parent_convo_id", c.ID)),
+		Service:       c.Service,
+		PromptCaching: c.PromptCaching,
+		Parent:        c,
+		// For convenience, sub-convo usage shares tool uses map with parent,
+		// all other fields separate, propagated in AddResponse
+		usage:    newUsageWithSharedToolUses(c.usage),
+		mu:       c.mu,
+		Listener: c.Listener,
+		ID:       id,
+		// Do not copy Budget. Each budget is independent,
+		// and OverBudget checks whether any ancestor is over budget.
+		messages: slices.Clone(c.messages),
+	}
+}
+
+// Depth reports how many "sub-conversations" deep this conversation is.
+// That it, it walks up parents until it finds a root.
+func (c *Convo) Depth() int {
+	x := c
+	var depth int
+	for x.Parent != nil {
+		x = x.Parent
+		depth++
+	}
+	return depth
+}
+
+// SendUserTextMessage sends a text message to the LLM in this conversation.
+// otherContents contains additional contents to send with the message, usually tool results.
+func (c *Convo) SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error) {
+	contents := slices.Clone(otherContents)
+	if s != "" {
+		contents = append(contents, llm.Content{Type: llm.ContentTypeText, Text: s})
+	}
+	msg := llm.Message{
+		Role:    llm.MessageRoleUser,
+		Content: contents,
+	}
+	return c.SendMessage(msg)
+}
+
+func (c *Convo) messageRequest(msg llm.Message) *llm.Request {
+	system := []llm.SystemContent{}
+	if c.SystemPrompt != "" {
+		d := llm.SystemContent{Type: "text", Text: c.SystemPrompt}
+		if c.PromptCaching {
+			d.Cache = true
+		}
+		system = []llm.SystemContent{d}
+	}
+
+	// Claude is happy to return an empty response in response to our Done() call,
+	// and, if so, you'll see something like:
+	// API request failed with status 400 Bad Request
+	// {"type":"error","error":  {"type":"invalid_request_error",
+	// "message":"messages.5: all messages must have non-empty content except for the optional final assistant message"}}
+	// So, we filter out those empty messages.
+	var nonEmptyMessages []llm.Message
+	for _, m := range c.messages {
+		if len(m.Content) > 0 {
+			nonEmptyMessages = append(nonEmptyMessages, m)
+		}
+	}
+
+	// Also validate the new message being sent - don't add it if empty
+	messagesToSend := nonEmptyMessages
+	if len(msg.Content) > 0 {
+		messagesToSend = append(messagesToSend, msg)
+	}
+
+	mr := &llm.Request{
+		Messages: messagesToSend,
+		System:   system,
+		Tools:    c.Tools,
+	}
+	if c.ToolUseOnly {
+		mr.ToolChoice = &llm.ToolChoice{Type: llm.ToolChoiceTypeAny}
+	}
+	return mr
+}
+
+func (c *Convo) findTool(name string) (*llm.Tool, error) {
+	for _, tool := range c.Tools {
+		if tool.Name == name {
+			return tool, nil
+		}
+	}
+	return nil, fmt.Errorf("tool %q not found", name)
+}
+
+// insertMissingToolResults adds error results for tool uses that were requested
+// but not included in the message, which can happen in error paths like "out of budget."
+// We only insert these if there were no tool responses at all, since an incorrect
+// number of tool results would be a programmer error. Mutates inputs.
+func (c *Convo) insertMissingToolResults(mr *llm.Request, msg *llm.Message) {
+	if len(mr.Messages) < 2 {
+		return
+	}
+	prev := mr.Messages[len(mr.Messages)-2]
+	var toolUsePrev int
+	for _, c := range prev.Content {
+		if c.Type == llm.ContentTypeToolUse {
+			toolUsePrev++
+		}
+	}
+	if toolUsePrev == 0 {
+		return
+	}
+	var toolUseCurrent int
+	for _, c := range msg.Content {
+		if c.Type == llm.ContentTypeToolResult {
+			toolUseCurrent++
+		}
+	}
+	if toolUseCurrent != 0 {
+		return
+	}
+	var prefix []llm.Content
+	for _, part := range prev.Content {
+		if part.Type != llm.ContentTypeToolUse {
+			continue
+		}
+		content := llm.Content{
+			Type:      llm.ContentTypeToolResult,
+			ToolUseID: part.ID,
+			ToolError: true,
+			ToolResult: []llm.Content{{
+				Type: llm.ContentTypeText,
+				Text: "not executed; retry possible",
+			}},
+		}
+		prefix = append(prefix, content)
+	}
+	msg.Content = append(prefix, msg.Content...)
+	mr.Messages[len(mr.Messages)-1].Content = msg.Content
+	slog.DebugContext(c.Ctx, "inserted missing tool results")
+}
+
+// SendMessage sends a message to Claude.
+// The conversation records (internally) all messages succesfully sent and received.
+func (c *Convo) SendMessage(msg llm.Message) (*llm.Response, error) {
+	id := ulid.Make().String()
+	mr := c.messageRequest(msg)
+	var lastMessage *llm.Message
+	if c.PromptCaching {
+		lastMessage = &mr.Messages[len(mr.Messages)-1]
+		if len(lastMessage.Content) > 0 {
+			lastMessage.Content[len(lastMessage.Content)-1].Cache = true
+		}
+	}
+	defer func() {
+		if lastMessage == nil {
+			return
+		}
+		if len(lastMessage.Content) > 0 {
+			lastMessage.Content[len(lastMessage.Content)-1].Cache = false
+		}
+	}()
+	c.insertMissingToolResults(mr, &msg)
+	c.Listener.OnRequest(c.Ctx, c, id, &msg)
+
+	startTime := time.Now()
+	resp, err := c.Service.Do(c.Ctx, mr)
+	if resp != nil {
+		resp.StartTime = &startTime
+		endTime := time.Now()
+		resp.EndTime = &endTime
+	}
+
+	if err != nil {
+		c.Listener.OnResponse(c.Ctx, c, id, nil)
+		return nil, err
+	}
+	c.messages = append(c.messages, msg, resp.ToMessage())
+	// Propagate usage to all ancestors (including us).
+	for x := c; x != nil; x = x.Parent {
+		x.usage.Add(resp.Usage)
+		// Store the most recent usage (only on the current conversation, not ancestors)
+		if x == c {
+			x.lastUsage = resp.Usage
+		}
+	}
+	c.Listener.OnResponse(c.Ctx, c, id, resp)
+	return resp, err
+}
+
+type toolCallInfoKeyType string
+
+var toolCallInfoKey toolCallInfoKeyType
+
+type ToolCallInfo struct {
+	ToolUseID string
+}
+
+func ToolCallInfoFromContext(ctx context.Context) ToolCallInfo {
+	v := ctx.Value(toolCallInfoKey)
+	i, _ := v.(ToolCallInfo)
+	return i
+}
+
+func (c *Convo) ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error) {
+	if resp.StopReason != llm.StopReasonToolUse {
+		return nil, nil
+	}
+	var toolResults []llm.Content
+
+	for _, part := range resp.Content {
+		if part.Type != llm.ContentTypeToolUse {
+			continue
+		}
+		c.incrementToolUse(part.ToolName)
+
+		content := llm.Content{
+			Type:      llm.ContentTypeToolResult,
+			ToolUseID: part.ID,
+		}
+
+		content.ToolError = true
+		content.ToolResult = []llm.Content{{
+			Type: llm.ContentTypeText,
+			Text: "user canceled this tool_use",
+		}}
+		toolResults = append(toolResults, content)
+	}
+	return toolResults, nil
+}
+
+// GetID returns the conversation ID
+func (c *Convo) GetID() string {
+	return c.ID
+}
+
+func (c *Convo) CancelToolUse(toolUseID string, err error) error {
+	c.toolUseCancelMu.Lock()
+	defer c.toolUseCancelMu.Unlock()
+	cancel, ok := c.toolUseCancel[toolUseID]
+	if !ok {
+		return fmt.Errorf("cannot cancel %s: no cancel function registered for this tool_use_id. All I have is %+v", toolUseID, c.toolUseCancel)
+	}
+	delete(c.toolUseCancel, toolUseID)
+	cancel(err)
+	return nil
+}
+
+func (c *Convo) newToolUseContext(ctx context.Context, toolUseID string) (context.Context, context.CancelFunc) {
+	c.toolUseCancelMu.Lock()
+	defer c.toolUseCancelMu.Unlock()
+	ctx, cancel := context.WithCancelCause(ctx)
+	c.toolUseCancel[toolUseID] = cancel
+	return ctx, func() { c.CancelToolUse(toolUseID, nil) }
+}
+
+// ToolResultContents runs all tool uses requested by the response and returns their results.
+// Cancelling ctx will cancel any running tool calls.
+// The boolean return value indicates whether any of the executed tools should end the turn.
+func (c *Convo) ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error) {
+	if resp.StopReason != llm.StopReasonToolUse {
+		return nil, false, nil
+	}
+	// Extract all tool calls from the response, call the tools, and gather the results.
+	var wg sync.WaitGroup
+	toolResultC := make(chan llm.Content, len(resp.Content))
+
+	endsTurn := false
+	for _, part := range resp.Content {
+		if part.Type != llm.ContentTypeToolUse {
+			continue
+		}
+		tool, err := c.findTool(part.ToolName)
+		if err == nil && tool.EndsTurn {
+			endsTurn = true
+		}
+		c.incrementToolUse(part.ToolName)
+		startTime := time.Now()
+
+		c.Listener.OnToolCall(ctx, c, part.ID, part.ToolName, part.ToolInput, llm.Content{
+			Type:             llm.ContentTypeToolUse,
+			ToolUseID:        part.ID,
+			ToolUseStartTime: &startTime,
+		})
+
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+
+			content := llm.Content{
+				Type:             llm.ContentTypeToolResult,
+				ToolUseID:        part.ID,
+				ToolUseStartTime: &startTime,
+			}
+			sendErr := func(err error) {
+				// Record end time
+				endTime := time.Now()
+				content.ToolUseEndTime = &endTime
+
+				content.ToolError = true
+				content.ToolResult = []llm.Content{{
+					Type: llm.ContentTypeText,
+					Text: err.Error(),
+				}}
+				c.Listener.OnToolResult(ctx, c, part.ID, part.ToolName, part.ToolInput, content, nil, err)
+				toolResultC <- content
+			}
+			sendRes := func(toolOut llm.ToolOut) {
+				// Record end time
+				endTime := time.Now()
+				content.ToolUseEndTime = &endTime
+
+				content.ToolResult = toolOut.LLMContent
+				content.Display = toolOut.Display
+				var firstText string
+				if len(toolOut.LLMContent) > 0 {
+					firstText = toolOut.LLMContent[0].Text
+				}
+				c.Listener.OnToolResult(ctx, c, part.ID, part.ToolName, part.ToolInput, content, &firstText, nil)
+				toolResultC <- content
+			}
+
+			tool, err := c.findTool(part.ToolName)
+			if err != nil {
+				sendErr(err)
+				return
+			}
+			// Create a new context for just this tool_use call, and register its
+			// cancel function so that it can be canceled individually.
+			toolUseCtx, cancel := c.newToolUseContext(ctx, part.ID)
+			defer cancel()
+			// TODO: move this into newToolUseContext?
+			toolUseCtx = context.WithValue(toolUseCtx, toolCallInfoKey, ToolCallInfo{ToolUseID: part.ID})
+			toolOut := tool.Run(toolUseCtx, part.ToolInput)
+			if errors.Is(toolOut.Error, ErrDoNotRespond) {
+				return
+			}
+			if toolUseCtx.Err() != nil {
+				sendErr(context.Cause(toolUseCtx))
+				return
+			}
+
+			if toolOut.Error != nil {
+				sendErr(toolOut.Error)
+				return
+			}
+			sendRes(toolOut)
+		}()
+	}
+	wg.Wait()
+	close(toolResultC)
+	var toolResults []llm.Content
+	for toolResult := range toolResultC {
+		toolResults = append(toolResults, toolResult)
+	}
+	if ctx.Err() != nil {
+		return nil, false, ctx.Err()
+	}
+	return toolResults, endsTurn, nil
+}
+
+func (c *Convo) incrementToolUse(name string) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	c.usage.ToolUses[name]++
+}
+
+// CumulativeUsage represents cumulative usage across a Convo, including all sub-conversations.
+type CumulativeUsage struct {
+	StartTime                time.Time      `json:"start_time"`
+	Responses                uint64         `json:"messages"` // count of responses
+	InputTokens              uint64         `json:"input_tokens"`
+	OutputTokens             uint64         `json:"output_tokens"`
+	CacheReadInputTokens     uint64         `json:"cache_read_input_tokens"`
+	CacheCreationInputTokens uint64         `json:"cache_creation_input_tokens"`
+	TotalCostUSD             float64        `json:"total_cost_usd"`
+	ToolUses                 map[string]int `json:"tool_uses"` // tool name -> number of uses
+}
+
+func newUsage() *CumulativeUsage {
+	return &CumulativeUsage{ToolUses: make(map[string]int), StartTime: time.Now()}
+}
+
+func newUsageWithSharedToolUses(parent *CumulativeUsage) *CumulativeUsage {
+	return &CumulativeUsage{ToolUses: parent.ToolUses, StartTime: time.Now()}
+}
+
+func (u *CumulativeUsage) Clone() CumulativeUsage {
+	v := *u
+	v.ToolUses = maps.Clone(u.ToolUses)
+	return v
+}
+
+func (c *Convo) CumulativeUsage() CumulativeUsage {
+	if c == nil {
+		return CumulativeUsage{}
+	}
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	return c.usage.Clone()
+}
+
+// LastUsage returns the usage from the most recent API call
+func (c *Convo) LastUsage() llm.Usage {
+	if c == nil {
+		return llm.Usage{}
+	}
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	return c.lastUsage
+}
+
+func (u *CumulativeUsage) WallTime() time.Duration {
+	return time.Since(u.StartTime)
+}
+
+func (u *CumulativeUsage) DollarsPerHour() float64 {
+	hours := u.WallTime().Hours()
+	// Prevent division by very small numbers that could cause issues
+	if hours < 1e-6 {
+		return 0
+	}
+	return u.TotalCostUSD / hours
+}
+
+func (u *CumulativeUsage) Add(usage llm.Usage) {
+	u.Responses++
+	u.InputTokens += usage.InputTokens
+	u.OutputTokens += usage.OutputTokens
+	u.CacheReadInputTokens += usage.CacheReadInputTokens
+	u.CacheCreationInputTokens += usage.CacheCreationInputTokens
+	u.TotalCostUSD += usage.CostUSD
+}
+
+// TotalInputTokens returns the grand total cumulative input tokens in u.
+func (u *CumulativeUsage) TotalInputTokens() uint64 {
+	return u.InputTokens + u.CacheReadInputTokens + u.CacheCreationInputTokens
+}
+
+// Attr returns the cumulative usage as a slog.Attr with key "usage".
+func (u CumulativeUsage) Attr() slog.Attr {
+	elapsed := time.Since(u.StartTime)
+	return slog.Group("usage",
+		slog.Duration("wall_time", elapsed),
+		slog.Uint64("responses", u.Responses),
+		slog.Uint64("input_tokens", u.InputTokens),
+		slog.Uint64("output_tokens", u.OutputTokens),
+		slog.Uint64("cache_read_input_tokens", u.CacheReadInputTokens),
+		slog.Uint64("cache_creation_input_tokens", u.CacheCreationInputTokens),
+		slog.Float64("total_cost_usd", u.TotalCostUSD),
+		slog.Float64("dollars_per_hour", u.TotalCostUSD/elapsed.Hours()),
+		slog.Any("tool_uses", maps.Clone(u.ToolUses)),
+	)
+}
+
+// A Budget represents the maximum amount of resources that may be spent on a conversation.
+// Note that the default (zero) budget is unlimited.
+type Budget struct {
+	MaxDollars float64 // if > 0, max dollars that may be spent
+}
+
+// OverBudget returns an error if the convo (or any of its parents) has exceeded its budget.
+// TODO: document parent vs sub budgets, multiple errors, etc, once we know the desired behavior.
+func (c *Convo) OverBudget() error {
+	for x := c; x != nil; x = x.Parent {
+		if err := x.overBudget(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// ResetBudget sets the budget to the passed in budget and
+// adjusts it by what's been used so far.
+func (c *Convo) ResetBudget(budget Budget) {
+	c.Budget = budget
+	if c.Budget.MaxDollars > 0 {
+		c.Budget.MaxDollars += c.CumulativeUsage().TotalCostUSD
+	}
+}
+
+func (c *Convo) overBudget() error {
+	usage := c.CumulativeUsage()
+	// TODO: stop before we exceed the budget instead of after?
+	var err error
+	cont := "Continuing to chat will reset the budget."
+	if c.Budget.MaxDollars > 0 && usage.TotalCostUSD >= c.Budget.MaxDollars {
+		err = errors.Join(err, fmt.Errorf("$%.2f spent, budget is $%.2f. %s", usage.TotalCostUSD, c.Budget.MaxDollars, cont))
+	}
+	return err
+}
+
+// DebugJSON returns the conversation history as JSON for debugging purposes.
+func (c *Convo) DebugJSON() ([]byte, error) {
+	return json.MarshalIndent(c.messages, "", "  ")
+}

llm/conversation/convo_test.go πŸ”—

@@ -0,0 +1,299 @@
+package conversation
+
+import (
+	"cmp"
+	"context"
+	"net/http"
+	"os"
+	"slices"
+	"strings"
+	"testing"
+
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/llm/ant"
+	"sketch.dev/httprr"
+)
+
+func TestBasicConvo(t *testing.T) {
+	ctx := context.Background()
+	rr, err := httprr.Open("testdata/basic_convo.httprr", http.DefaultTransport)
+	if err != nil {
+		t.Fatal(err)
+	}
+	rr.ScrubReq(func(req *http.Request) error {
+		req.Header.Del("x-api-key")
+		return nil
+	})
+
+	apiKey := cmp.Or(os.Getenv("OUTER_SKETCH_MODEL_API_KEY"), os.Getenv("ANTHROPIC_API_KEY"))
+	srv := &ant.Service{
+		APIKey: apiKey,
+		Model:  ant.Claude4Sonnet, // Use specific model to match cached responses
+		HTTPC:  rr.Client(),
+	}
+	convo := New(ctx, srv, nil)
+
+	const name = "Cornelius"
+	res, err := convo.SendUserTextMessage("Hi, my name is " + name)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, part := range res.Content {
+		t.Logf("%s", part.Text)
+	}
+	res, err = convo.SendUserTextMessage("What is my name?")
+	if err != nil {
+		t.Fatal(err)
+	}
+	got := ""
+	for _, part := range res.Content {
+		got += part.Text
+	}
+	if !strings.Contains(got, name) {
+		t.Errorf("model does not know the given name %s: %q", name, got)
+	}
+}
+
+// TestCancelToolUse tests the CancelToolUse function of the Convo struct
+func TestCancelToolUse(t *testing.T) {
+	tests := []struct {
+		name         string
+		setupToolUse bool
+		toolUseID    string
+		cancelErr    error
+		expectError  bool
+		expectCancel bool
+	}{
+		{
+			name:         "Cancel existing tool use",
+			setupToolUse: true,
+			toolUseID:    "tool123",
+			cancelErr:    nil,
+			expectError:  false,
+			expectCancel: true,
+		},
+		{
+			name:         "Cancel existing tool use with error",
+			setupToolUse: true,
+			toolUseID:    "tool456",
+			cancelErr:    context.Canceled,
+			expectError:  false,
+			expectCancel: true,
+		},
+		{
+			name:         "Cancel non-existent tool use",
+			setupToolUse: false,
+			toolUseID:    "tool789",
+			cancelErr:    nil,
+			expectError:  true,
+			expectCancel: false,
+		},
+	}
+
+	srv := &ant.Service{}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			convo := New(context.Background(), srv, nil)
+
+			var cancelCalled bool
+			var cancelledWithErr error
+
+			if tt.setupToolUse {
+				// Setup a mock cancel function to track calls
+				mockCancel := func(err error) {
+					cancelCalled = true
+					cancelledWithErr = err
+				}
+
+				convo.toolUseCancelMu.Lock()
+				convo.toolUseCancel[tt.toolUseID] = mockCancel
+				convo.toolUseCancelMu.Unlock()
+			}
+
+			err := convo.CancelToolUse(tt.toolUseID, tt.cancelErr)
+
+			// Check if we got the expected error state
+			if (err != nil) != tt.expectError {
+				t.Errorf("CancelToolUse() error = %v, expectError %v", err, tt.expectError)
+			}
+
+			// Check if the cancel function was called as expected
+			if cancelCalled != tt.expectCancel {
+				t.Errorf("Cancel function called = %v, expectCancel %v", cancelCalled, tt.expectCancel)
+			}
+
+			// If we expected the cancel to be called, verify it was called with the right error
+			if tt.expectCancel && cancelledWithErr != tt.cancelErr {
+				t.Errorf("Cancel function called with error = %v, expected %v", cancelledWithErr, tt.cancelErr)
+			}
+
+			// Verify the toolUseID was removed from the map if it was initially added
+			if tt.setupToolUse {
+				convo.toolUseCancelMu.Lock()
+				_, exists := convo.toolUseCancel[tt.toolUseID]
+				convo.toolUseCancelMu.Unlock()
+
+				if exists {
+					t.Errorf("toolUseID %s still exists in the map after cancellation", tt.toolUseID)
+				}
+			}
+		})
+	}
+}
+
+// TestInsertMissingToolResults tests the insertMissingToolResults function
+// to ensure it doesn't create duplicate tool results when multiple tool uses are missing results.
+func TestInsertMissingToolResults(t *testing.T) {
+	tests := []struct {
+		name            string
+		messages        []llm.Message
+		currentMsg      llm.Message
+		expectedCount   int
+		expectedToolIDs []string
+	}{
+		{
+			name: "Single missing tool result",
+			messages: []llm.Message{
+				{
+					Role: llm.MessageRoleAssistant,
+					Content: []llm.Content{
+						{
+							Type: llm.ContentTypeToolUse,
+							ID:   "tool1",
+						},
+					},
+				},
+			},
+			currentMsg: llm.Message{
+				Role:    llm.MessageRoleUser,
+				Content: []llm.Content{},
+			},
+			expectedCount:   1,
+			expectedToolIDs: []string{"tool1"},
+		},
+		{
+			name: "Multiple missing tool results",
+			messages: []llm.Message{
+				{
+					Role: llm.MessageRoleAssistant,
+					Content: []llm.Content{
+						{
+							Type: llm.ContentTypeToolUse,
+							ID:   "tool1",
+						},
+						{
+							Type: llm.ContentTypeToolUse,
+							ID:   "tool2",
+						},
+						{
+							Type: llm.ContentTypeToolUse,
+							ID:   "tool3",
+						},
+					},
+				},
+			},
+			currentMsg: llm.Message{
+				Role:    llm.MessageRoleUser,
+				Content: []llm.Content{},
+			},
+			expectedCount:   3,
+			expectedToolIDs: []string{"tool1", "tool2", "tool3"},
+		},
+		{
+			name: "No missing tool results when results already present",
+			messages: []llm.Message{
+				{
+					Role: llm.MessageRoleAssistant,
+					Content: []llm.Content{
+						{
+							Type: llm.ContentTypeToolUse,
+							ID:   "tool1",
+						},
+					},
+				},
+			},
+			currentMsg: llm.Message{
+				Role: llm.MessageRoleUser,
+				Content: []llm.Content{
+					{
+						Type:      llm.ContentTypeToolResult,
+						ToolUseID: "tool1",
+					},
+				},
+			},
+			expectedCount:   1, // Only the existing one
+			expectedToolIDs: []string{"tool1"},
+		},
+		{
+			name: "No tool uses in previous message",
+			messages: []llm.Message{
+				{
+					Role: llm.MessageRoleAssistant,
+					Content: []llm.Content{
+						{
+							Type: llm.ContentTypeText,
+							Text: "Just some text",
+						},
+					},
+				},
+			},
+			currentMsg: llm.Message{
+				Role:    llm.MessageRoleUser,
+				Content: []llm.Content{},
+			},
+			expectedCount:   0,
+			expectedToolIDs: []string{},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			srv := &ant.Service{}
+			convo := New(context.Background(), srv, nil)
+
+			// Create request with messages
+			req := &llm.Request{
+				Messages: append(tt.messages, tt.currentMsg),
+			}
+
+			// Call insertMissingToolResults
+			msg := tt.currentMsg
+			convo.insertMissingToolResults(req, &msg)
+
+			// Count tool results in the message
+			toolResultCount := 0
+			toolIDs := []string{}
+			for _, content := range msg.Content {
+				if content.Type == llm.ContentTypeToolResult {
+					toolResultCount++
+					toolIDs = append(toolIDs, content.ToolUseID)
+				}
+			}
+
+			// Verify count
+			if toolResultCount != tt.expectedCount {
+				t.Errorf("Expected %d tool results, got %d", tt.expectedCount, toolResultCount)
+			}
+
+			// Verify no duplicates by checking unique tool IDs
+			seenIDs := make(map[string]int)
+			for _, id := range toolIDs {
+				seenIDs[id]++
+			}
+
+			// Check for duplicates
+			for id, count := range seenIDs {
+				if count > 1 {
+					t.Errorf("Duplicate tool result for ID %s: found %d times", id, count)
+				}
+			}
+
+			// Verify all expected tool IDs are present
+			for _, expectedID := range tt.expectedToolIDs {
+				if !slices.Contains(toolIDs, expectedID) {
+					t.Errorf("Expected tool ID %s not found in results", expectedID)
+				}
+			}
+		})
+	}
+}

llm/conversation/testdata/basic_convo.httprr πŸ”—

@@ -0,0 +1,118 @@
+httprr trace v1
+455 1424
+POST https://api.anthropic.com/v1/messages HTTP/1.1

+Host: api.anthropic.com

+User-Agent: Go-http-client/1.1

+Content-Length: 259

+Anthropic-Version: 2023-06-01

+Content-Type: application/json

+

+{
+ "model": "claude-sonnet-4-20250514",
+ "messages": [
+  {
+   "role": "user",
+   "content": [
+    {
+     "type": "text",
+     "text": "Hi, my name is Cornelius",
+     "cache_control": {
+      "type": "ephemeral"
+     }
+    }
+   ]
+  }
+ ],
+ "max_tokens": 8192
+}HTTP/2.0 200 OK

+Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b

+Anthropic-Ratelimit-Input-Tokens-Limit: 200000

+Anthropic-Ratelimit-Input-Tokens-Remaining: 200000

+Anthropic-Ratelimit-Input-Tokens-Reset: 2025-05-24T19:27:38Z

+Anthropic-Ratelimit-Output-Tokens-Limit: 80000

+Anthropic-Ratelimit-Output-Tokens-Remaining: 80000

+Anthropic-Ratelimit-Output-Tokens-Reset: 2025-05-24T19:27:38Z

+Anthropic-Ratelimit-Requests-Limit: 4000

+Anthropic-Ratelimit-Requests-Remaining: 3999

+Anthropic-Ratelimit-Requests-Reset: 2025-05-24T19:27:36Z

+Anthropic-Ratelimit-Tokens-Limit: 280000

+Anthropic-Ratelimit-Tokens-Remaining: 280000

+Anthropic-Ratelimit-Tokens-Reset: 2025-05-24T19:27:38Z

+Cf-Cache-Status: DYNAMIC

+Cf-Ray: 944f30fd0f0a15d4-SJC

+Content-Type: application/json

+Date: Sat, 24 May 2025 19:27:38 GMT

+Request-Id: req_011CPSuX337qwfNzNzGSwG3b

+Server: cloudflare

+Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

+Via: 1.1 google

+X-Robots-Tag: none

+

+{"id":"msg_01L127Hi3H8X613Fh8HojDgk","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Hello Cornelius! It's nice to meet you. How are you doing today? Is there anything I can help you with?"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":15,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":30,"service_tier":"standard"}}775 1394
+POST https://api.anthropic.com/v1/messages HTTP/1.1

+Host: api.anthropic.com

+User-Agent: Go-http-client/1.1

+Content-Length: 579

+Anthropic-Version: 2023-06-01

+Content-Type: application/json

+

+{
+ "model": "claude-sonnet-4-20250514",
+ "messages": [
+  {
+   "role": "user",
+   "content": [
+    {
+     "type": "text",
+     "text": "Hi, my name is Cornelius"
+    }
+   ]
+  },
+  {
+   "role": "assistant",
+   "content": [
+    {
+     "type": "text",
+     "text": "Hello Cornelius! It's nice to meet you. How are you doing today? Is there anything I can help you with?"
+    }
+   ]
+  },
+  {
+   "role": "user",
+   "content": [
+    {
+     "type": "text",
+     "text": "What is my name?",
+     "cache_control": {
+      "type": "ephemeral"
+     }
+    }
+   ]
+  }
+ ],
+ "max_tokens": 8192
+}HTTP/2.0 200 OK

+Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b

+Anthropic-Ratelimit-Input-Tokens-Limit: 200000

+Anthropic-Ratelimit-Input-Tokens-Remaining: 200000

+Anthropic-Ratelimit-Input-Tokens-Reset: 2025-05-24T19:27:39Z

+Anthropic-Ratelimit-Output-Tokens-Limit: 80000

+Anthropic-Ratelimit-Output-Tokens-Remaining: 80000

+Anthropic-Ratelimit-Output-Tokens-Reset: 2025-05-24T19:27:40Z

+Anthropic-Ratelimit-Requests-Limit: 4000

+Anthropic-Ratelimit-Requests-Remaining: 3999

+Anthropic-Ratelimit-Requests-Reset: 2025-05-24T19:27:38Z

+Anthropic-Ratelimit-Tokens-Limit: 280000

+Anthropic-Ratelimit-Tokens-Remaining: 280000

+Anthropic-Ratelimit-Tokens-Reset: 2025-05-24T19:27:39Z

+Cf-Cache-Status: DYNAMIC

+Cf-Ray: 944f31098c9e15d4-SJC

+Content-Type: application/json

+Date: Sat, 24 May 2025 19:27:40 GMT

+Request-Id: req_011CPSuXBim8ntiKJDjvFUWG

+Server: cloudflare

+Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

+Via: 1.1 google

+X-Robots-Tag: none

+

+{"id":"msg_01TiEuRrzLgJEfBUNhZ9Am3B","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Your name is Cornelius, as you introduced yourself in your first message."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":53,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":19,"service_tier":"standard"}}

llm/gem/gem.go πŸ”—

@@ -0,0 +1,607 @@
+package gem
+
+import (
+	"cmp"
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"math/rand"
+	"net/http"
+	"strings"
+	"time"
+
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/llm/gem/gemini"
+)
+
+const (
+	DefaultModel    = "gemini-2.5-pro-preview-03-25"
+	GeminiAPIKeyEnv = "GEMINI_API_KEY"
+)
+
+// Service provides Gemini completions.
+// Fields should not be altered concurrently with calling any method on Service.
+type Service struct {
+	HTTPC   *http.Client // defaults to http.DefaultClient if nil
+	URL     string       // Gemini API URL, uses the gemini package default if empty
+	APIKey  string       // must be non-empty
+	Model   string       // defaults to DefaultModel if empty
+	DumpLLM bool         // whether to dump request/response text to files for debugging; defaults to false
+}
+
+var _ llm.Service = (*Service)(nil)
+
+// These maps convert between Sketch's llm package and Gemini API formats
+var fromLLMRole = map[llm.MessageRole]string{
+	llm.MessageRoleAssistant: "model",
+	llm.MessageRoleUser:      "user",
+}
+
+// convertToolSchemas converts Sketch's llm.Tool schemas to Gemini's schema format
+func convertToolSchemas(tools []*llm.Tool) ([]gemini.FunctionDeclaration, error) {
+	if len(tools) == 0 {
+		return nil, nil
+	}
+
+	var decls []gemini.FunctionDeclaration
+	for _, tool := range tools {
+		// Parse the schema from raw JSON
+		var schemaJSON map[string]any
+		if err := json.Unmarshal(tool.InputSchema, &schemaJSON); err != nil {
+			return nil, fmt.Errorf("failed to unmarshal tool %s schema: %w", tool.Name, err)
+		}
+		decls = append(decls, gemini.FunctionDeclaration{
+			Name:        tool.Name,
+			Description: tool.Description,
+			Parameters:  convertJSONSchemaToGeminiSchema(schemaJSON),
+		})
+	}
+
+	return decls, nil
+}
+
+// convertJSONSchemaToGeminiSchema converts a JSON schema to Gemini's schema format
+func convertJSONSchemaToGeminiSchema(schemaJSON map[string]any) gemini.Schema {
+	schema := gemini.Schema{}
+
+	// Set the type based on the JSON schema type
+	if typeVal, ok := schemaJSON["type"].(string); ok {
+		switch typeVal {
+		case "string":
+			schema.Type = gemini.DataTypeSTRING
+		case "number":
+			schema.Type = gemini.DataTypeNUMBER
+		case "integer":
+			schema.Type = gemini.DataTypeINTEGER
+		case "boolean":
+			schema.Type = gemini.DataTypeBOOLEAN
+		case "array":
+			schema.Type = gemini.DataTypeARRAY
+		case "object":
+			schema.Type = gemini.DataTypeOBJECT
+		default:
+			schema.Type = gemini.DataTypeSTRING // Default to string for unknown types
+		}
+	}
+
+	// Set description if available
+	if desc, ok := schemaJSON["description"].(string); ok {
+		schema.Description = desc
+	}
+
+	// Handle enum values
+	if enumValues, ok := schemaJSON["enum"].([]any); ok {
+		schema.Enum = make([]string, len(enumValues))
+		for i, v := range enumValues {
+			if strVal, ok := v.(string); ok {
+				schema.Enum[i] = strVal
+			} else {
+				// Convert non-string values to string
+				valBytes, _ := json.Marshal(v)
+				schema.Enum[i] = string(valBytes)
+			}
+		}
+	}
+
+	// Handle object properties
+	if properties, ok := schemaJSON["properties"].(map[string]any); ok && schema.Type == gemini.DataTypeOBJECT {
+		schema.Properties = make(map[string]gemini.Schema)
+		for propName, propSchema := range properties {
+			if propSchemaMap, ok := propSchema.(map[string]any); ok {
+				schema.Properties[propName] = convertJSONSchemaToGeminiSchema(propSchemaMap)
+			}
+		}
+	}
+
+	// Handle required properties
+	if required, ok := schemaJSON["required"].([]any); ok {
+		schema.Required = make([]string, len(required))
+		for i, r := range required {
+			if strVal, ok := r.(string); ok {
+				schema.Required[i] = strVal
+			}
+		}
+	}
+
+	// Handle array items
+	if items, ok := schemaJSON["items"].(map[string]any); ok && schema.Type == gemini.DataTypeARRAY {
+		itemSchema := convertJSONSchemaToGeminiSchema(items)
+		schema.Items = &itemSchema
+	}
+
+	// Handle minimum/maximum items for arrays
+	if minItems, ok := schemaJSON["minItems"].(float64); ok {
+		schema.MinItems = fmt.Sprintf("%d", int(minItems))
+	}
+	if maxItems, ok := schemaJSON["maxItems"].(float64); ok {
+		schema.MaxItems = fmt.Sprintf("%d", int(maxItems))
+	}
+
+	return schema
+}
+
+// buildGeminiRequest converts Sketch's llm.Request to Gemini's request format
+func (s *Service) buildGeminiRequest(req *llm.Request) (*gemini.Request, error) {
+	gemReq := &gemini.Request{}
+
+	// Add system instruction if provided
+	if len(req.System) > 0 {
+		// Combine all system messages into a single system instruction
+		systemText := ""
+		for i, sys := range req.System {
+			if i > 0 && systemText != "" && sys.Text != "" {
+				systemText += "\n"
+			}
+			systemText += sys.Text
+		}
+
+		if systemText != "" {
+			gemReq.SystemInstruction = &gemini.Content{
+				Parts: []gemini.Part{{Text: systemText}},
+			}
+		}
+	}
+
+	// Convert messages to Gemini content format
+	for _, msg := range req.Messages {
+		// Set the role based on the message role
+		role, ok := fromLLMRole[msg.Role]
+		if !ok {
+			return nil, fmt.Errorf("unsupported message role: %v", msg.Role)
+		}
+
+		content := gemini.Content{
+			Role: role,
+		}
+
+		// Store tool usage information to correlate tool uses with responses
+		toolNameToID := make(map[string]string)
+
+		// First pass: collect tool use IDs for correlation
+		for _, c := range msg.Content {
+			if c.Type == llm.ContentTypeToolUse && c.ID != "" {
+				toolNameToID[c.ToolName] = c.ID
+			}
+		}
+
+		// Map each content item to Gemini's format
+		for _, c := range msg.Content {
+			switch c.Type {
+			case llm.ContentTypeText, llm.ContentTypeThinking, llm.ContentTypeRedactedThinking:
+				// Simple text content
+				content.Parts = append(content.Parts, gemini.Part{
+					Text: c.Text,
+				})
+			case llm.ContentTypeToolUse:
+				// Tool use becomes a function call
+				var args map[string]any
+				if err := json.Unmarshal(c.ToolInput, &args); err != nil {
+					return nil, fmt.Errorf("failed to unmarshal tool input: %w", err)
+				}
+
+				// Make sure we have a valid ID for this tool use
+				if c.ID == "" {
+					c.ID = fmt.Sprintf("gemini_tool_%s_%d", c.ToolName, time.Now().UnixNano())
+				}
+
+				// Save the ID for this tool name for future correlation
+				toolNameToID[c.ToolName] = c.ID
+
+				slog.DebugContext(context.Background(), "gemini_preparing_tool_use",
+					"tool_name", c.ToolName,
+					"tool_id", c.ID,
+					"input", string(c.ToolInput))
+
+				content.Parts = append(content.Parts, gemini.Part{
+					FunctionCall: &gemini.FunctionCall{
+						Name: c.ToolName,
+						Args: args,
+					},
+				})
+			case llm.ContentTypeToolResult:
+				// Tool result becomes a function response
+				// Create a map for the response
+				response := map[string]any{
+					"error": c.ToolError,
+				}
+
+				// Handle tool results: Gemini only supports string results
+				// Combine all text content into a single string
+				var resultText string
+				if len(c.ToolResult) > 0 {
+					// Collect all text from content objects
+					texts := make([]string, 0, len(c.ToolResult))
+					for _, result := range c.ToolResult {
+						if result.Text != "" {
+							texts = append(texts, result.Text)
+						}
+					}
+					resultText = strings.Join(texts, "\n")
+				}
+				response["result"] = resultText
+
+				// Determine the function name to use - this is critical
+				funcName := ""
+
+				// First try to find the function name from a stored toolUseID if we have one
+				if c.ToolUseID != "" {
+					// Try to derive the tool name from the previous tools we've seen
+					for name, id := range toolNameToID {
+						if id == c.ToolUseID {
+							funcName = name
+							break
+						}
+					}
+				}
+
+				// Fallback options if we couldn't find the tool name
+				if funcName == "" {
+					// Try the tool name directly
+					if c.ToolName != "" {
+						funcName = c.ToolName
+					} else {
+						// Last resort fallback
+						funcName = "default_tool"
+					}
+				}
+
+				slog.DebugContext(context.Background(), "gemini_preparing_tool_result",
+					"tool_use_id", c.ToolUseID,
+					"mapped_func_name", funcName,
+					"result_count", len(c.ToolResult))
+
+				content.Parts = append(content.Parts, gemini.Part{
+					FunctionResponse: &gemini.FunctionResponse{
+						Name:     funcName,
+						Response: response,
+					},
+				})
+			}
+		}
+
+		gemReq.Contents = append(gemReq.Contents, content)
+	}
+
+	// Handle tools/functions
+	if len(req.Tools) > 0 {
+		// Convert tool schemas
+		decls, err := convertToolSchemas(req.Tools)
+		if err != nil {
+			return nil, fmt.Errorf("failed to convert tool schemas: %w", err)
+		}
+		if len(decls) > 0 {
+			gemReq.Tools = []gemini.Tool{{FunctionDeclarations: decls}}
+		}
+	}
+
+	return gemReq, nil
+}
+
+// convertGeminiResponsesToContent converts a Gemini response to llm.Content
+func convertGeminiResponseToContent(res *gemini.Response) []llm.Content {
+	if res == nil || len(res.Candidates) == 0 || len(res.Candidates[0].Content.Parts) == 0 {
+		return []llm.Content{{
+			Type: llm.ContentTypeText,
+			Text: "",
+		}}
+	}
+
+	var contents []llm.Content
+
+	// Process each part in the first candidate's content
+	for i, part := range res.Candidates[0].Content.Parts {
+		// Log the part type for debugging
+		slog.DebugContext(context.Background(), "processing_gemini_part",
+			"index", i,
+			"has_text", part.Text != "",
+			"has_function_call", part.FunctionCall != nil,
+			"has_function_response", part.FunctionResponse != nil)
+
+		if part.Text != "" {
+			// Simple text response
+			contents = append(contents, llm.Content{
+				Type: llm.ContentTypeText,
+				Text: part.Text,
+			})
+		} else if part.FunctionCall != nil {
+			// Function call (tool use)
+			args, err := json.Marshal(part.FunctionCall.Args)
+			if err != nil {
+				// If we can't marshal, use empty args
+				slog.DebugContext(context.Background(), "gemini_failed_to_markshal_args",
+					"tool_name", part.FunctionCall.Name,
+					"args", string(args),
+					"err", err.Error(),
+				)
+				args = []byte("{}")
+			}
+
+			// Generate a unique ID for this tool use that includes the function name
+			// to make it easier to correlate with responses
+			toolID := fmt.Sprintf("gemini_tool_%s_%d", part.FunctionCall.Name, time.Now().UnixNano())
+
+			contents = append(contents, llm.Content{
+				ID:        toolID,
+				Type:      llm.ContentTypeToolUse,
+				ToolName:  part.FunctionCall.Name,
+				ToolInput: json.RawMessage(args),
+			})
+
+			slog.DebugContext(context.Background(), "gemini_tool_call",
+				"tool_id", toolID,
+				"tool_name", part.FunctionCall.Name,
+				"args", string(args))
+		} else if part.FunctionResponse != nil {
+			// We shouldn't normally get function responses from the model, but just in case
+			respData, _ := json.Marshal(part.FunctionResponse.Response)
+			slog.DebugContext(context.Background(), "unexpected_function_response",
+				"name", part.FunctionResponse.Name,
+				"response", string(respData))
+		}
+	}
+
+	// If no content was added, add an empty text content
+	if len(contents) == 0 {
+		slog.DebugContext(context.Background(), "empty_gemini_response", "adding_empty_text", true)
+		contents = append(contents, llm.Content{
+			Type: llm.ContentTypeText,
+			Text: "",
+		})
+	}
+
+	return contents
+}
+
+// Gemini doesn't provide usage info directly, so we need to estimate it
+// ensureToolIDs makes sure all tool uses have proper IDs
+func ensureToolIDs(contents []llm.Content) {
+	for i, content := range contents {
+		if content.Type == llm.ContentTypeToolUse && content.ID == "" {
+			// Generate a stable ID using the tool name and timestamp
+			contents[i].ID = fmt.Sprintf("gemini_tool_%s_%d", content.ToolName, time.Now().UnixNano())
+			slog.DebugContext(context.Background(), "assigned_missing_tool_id",
+				"tool_name", content.ToolName,
+				"new_id", contents[i].ID)
+		}
+	}
+}
+
+func calculateUsage(req *gemini.Request, res *gemini.Response) llm.Usage {
+	// Very rough estimation of token counts
+	var inputTokens uint64
+	var outputTokens uint64
+
+	// Count system tokens
+	if req.SystemInstruction != nil {
+		for _, part := range req.SystemInstruction.Parts {
+			if part.Text != "" {
+				// Very rough estimation: 1 token per 4 characters
+				inputTokens += uint64(len(part.Text)) / 4
+			}
+		}
+	}
+
+	// Count input tokens
+	for _, content := range req.Contents {
+		for _, part := range content.Parts {
+			if part.Text != "" {
+				inputTokens += uint64(len(part.Text)) / 4
+			} else if part.FunctionCall != nil {
+				// Estimate function call tokens
+				argBytes, _ := json.Marshal(part.FunctionCall.Args)
+				inputTokens += uint64(len(part.FunctionCall.Name)+len(argBytes)) / 4
+			} else if part.FunctionResponse != nil {
+				// Estimate function response tokens
+				resBytes, _ := json.Marshal(part.FunctionResponse.Response)
+				inputTokens += uint64(len(part.FunctionResponse.Name)+len(resBytes)) / 4
+			}
+		}
+	}
+
+	// Count output tokens
+	if res != nil && len(res.Candidates) > 0 {
+		for _, part := range res.Candidates[0].Content.Parts {
+			if part.Text != "" {
+				outputTokens += uint64(len(part.Text)) / 4
+			} else if part.FunctionCall != nil {
+				// Estimate function call tokens
+				argBytes, _ := json.Marshal(part.FunctionCall.Args)
+				outputTokens += uint64(len(part.FunctionCall.Name)+len(argBytes)) / 4
+			}
+		}
+	}
+
+	return llm.Usage{
+		InputTokens:  inputTokens,
+		OutputTokens: outputTokens,
+	}
+}
+
+// TokenContextWindow returns the maximum token context window size for this service
+func (s *Service) TokenContextWindow() int {
+	model := s.Model
+	if model == "" {
+		model = DefaultModel
+	}
+
+	// Gemini models generally have large context windows
+	switch model {
+	case "gemini-2.5-pro-preview-03-25":
+		return 1000000 // 1M tokens for Gemini 2.5 Pro
+	case "gemini-2.0-flash-exp":
+		return 1000000 // 1M tokens for Gemini 2.0 Flash
+	case "gemini-1.5-pro", "gemini-1.5-pro-latest":
+		return 2000000 // 2M tokens for Gemini 1.5 Pro
+	case "gemini-1.5-flash", "gemini-1.5-flash-latest":
+		return 1000000 // 1M tokens for Gemini 1.5 Flash
+	default:
+		// Default for unknown models
+		return 1000000
+	}
+}
+
+// Do sends a request to Gemini.
+func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error) {
+	// Log the incoming request for debugging
+	slog.DebugContext(ctx, "gemini_request",
+		"message_count", len(ir.Messages),
+		"tool_count", len(ir.Tools),
+		"system_count", len(ir.System))
+
+	// Log tool-related information if any tools are present
+	if len(ir.Tools) > 0 {
+		var toolNames []string
+		for _, tool := range ir.Tools {
+			toolNames = append(toolNames, tool.Name)
+		}
+		slog.DebugContext(ctx, "gemini_tools", "tools", toolNames)
+	}
+
+	// Log details about the messages being sent
+	for i, msg := range ir.Messages {
+		contentTypes := make([]string, len(msg.Content))
+		for j, c := range msg.Content {
+			contentTypes[j] = c.Type.String()
+
+			// Log tool-related content with more details
+			if c.Type == llm.ContentTypeToolUse {
+				slog.DebugContext(ctx, "gemini_tool_use",
+					"message_idx", i,
+					"content_idx", j,
+					"tool_name", c.ToolName,
+					"tool_input", string(c.ToolInput))
+			} else if c.Type == llm.ContentTypeToolResult {
+				slog.DebugContext(ctx, "gemini_tool_result",
+					"message_idx", i,
+					"content_idx", j,
+					"tool_use_id", c.ToolUseID,
+					"tool_error", c.ToolError,
+					"result_count", len(c.ToolResult))
+			}
+		}
+		slog.DebugContext(ctx, "gemini_message",
+			"idx", i,
+			"role", msg.Role.String(),
+			"content_types", contentTypes)
+	}
+	// Build the Gemini request
+	gemReq, err := s.buildGeminiRequest(ir)
+	if err != nil {
+		return nil, fmt.Errorf("failed to build Gemini request: %w", err)
+	}
+
+	// Log the structured Gemini request for debugging
+	if reqJSON, err := json.MarshalIndent(gemReq, "", "  "); err == nil {
+		slog.DebugContext(ctx, "gemini_request_json", "request", string(reqJSON))
+		if s.DumpLLM {
+			// Construct the same URL that the Gemini client will use
+			endpoint := cmp.Or(s.URL, "https://generativelanguage.googleapis.com/v1beta")
+			url := fmt.Sprintf("%s/models/%s:generateContent?key=%s", endpoint, cmp.Or(s.Model, DefaultModel), s.APIKey)
+			if err := llm.DumpToFile("request", url, reqJSON); err != nil {
+				slog.WarnContext(ctx, "failed to dump gemini request to file", "error", err)
+			}
+		}
+	}
+
+	// Create a Gemini model instance
+	model := gemini.Model{
+		Model:    "models/" + cmp.Or(s.Model, DefaultModel),
+		Endpoint: s.URL,
+		APIKey:   s.APIKey,
+		HTTPC:    cmp.Or(s.HTTPC, http.DefaultClient),
+	}
+
+	// Send the request to Gemini with retry logic
+	startTime := time.Now()
+	endTime := startTime // Initialize endTime
+	var gemRes *gemini.Response
+
+	// Retry mechanism for handling server errors and rate limiting
+	backoff := []time.Duration{1 * time.Second, 3 * time.Second, 5 * time.Second, 10 * time.Second}
+	for attempts := 0; attempts <= len(backoff); attempts++ {
+		gemApiErr := error(nil)
+		gemRes, gemApiErr = model.GenerateContent(ctx, gemReq)
+		endTime = time.Now()
+
+		if gemApiErr == nil {
+			// Successful response
+			// Log the structured Gemini response
+			if resJSON, err := json.MarshalIndent(gemRes, "", "  "); err == nil {
+				slog.DebugContext(ctx, "gemini_response_json", "response", string(resJSON))
+				if s.DumpLLM {
+					if err := llm.DumpToFile("response", "", resJSON); err != nil {
+						slog.WarnContext(ctx, "failed to dump gemini response to file", "error", err)
+					}
+				}
+			}
+			break
+		}
+
+		if attempts == len(backoff) {
+			// We've exhausted all retry attempts
+			return nil, fmt.Errorf("gemini: API error after %d attempts: %w", attempts, gemApiErr)
+		}
+
+		// Check if the error is retryable (e.g., server error or rate limiting)
+		if strings.Contains(gemApiErr.Error(), "429") || strings.Contains(gemApiErr.Error(), "5") {
+			// Rate limited or server error - wait and retry
+			random := time.Duration(rand.Int63n(int64(time.Second)))
+			sleep := backoff[attempts] + random
+			slog.WarnContext(ctx, "gemini_request_retry", "error", gemApiErr.Error(), "attempt", attempts+1, "sleep", sleep)
+			time.Sleep(sleep)
+			continue
+		}
+
+		// Non-retryable error
+		return nil, fmt.Errorf("gemini: API error: %w", gemApiErr)
+	}
+
+	content := convertGeminiResponseToContent(gemRes)
+
+	ensureToolIDs(content)
+
+	usage := calculateUsage(gemReq, gemRes)
+	usage.CostUSD = llm.CostUSDFromResponse(gemRes.Header())
+
+	stopReason := llm.StopReasonEndTurn
+	for _, part := range content {
+		if part.Type == llm.ContentTypeToolUse {
+			stopReason = llm.StopReasonToolUse
+			slog.DebugContext(ctx, "gemini_tool_use_detected",
+				"setting_stop_reason", "llm.StopReasonToolUse",
+				"tool_name", part.ToolName)
+			break
+		}
+	}
+
+	return &llm.Response{
+		Role:       llm.MessageRoleAssistant,
+		Model:      s.Model,
+		Content:    content,
+		StopReason: stopReason,
+		Usage:      usage,
+		StartTime:  &startTime,
+		EndTime:    &endTime,
+	}, nil
+}

llm/gem/gem_test.go πŸ”—

@@ -0,0 +1,366 @@
+package gem
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+	"testing"
+
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/llm/gem/gemini"
+)
+
+func TestBuildGeminiRequest(t *testing.T) {
+	// Create a service
+	service := &Service{
+		Model:  DefaultModel,
+		APIKey: "test-api-key",
+	}
+
+	// Create a simple request
+	req := &llm.Request{
+		Messages: []llm.Message{
+			{
+				Role: llm.MessageRoleUser,
+				Content: []llm.Content{
+					{
+						Type: llm.ContentTypeText,
+						Text: "Hello, world!",
+					},
+				},
+			},
+		},
+		System: []llm.SystemContent{
+			{
+				Text: "You are a helpful assistant.",
+			},
+		},
+	}
+
+	// Build the Gemini request
+	gemReq, err := service.buildGeminiRequest(req)
+	if err != nil {
+		t.Fatalf("Failed to build Gemini request: %v", err)
+	}
+
+	// Verify the system instruction
+	if gemReq.SystemInstruction == nil {
+		t.Fatalf("Expected system instruction, got nil")
+	}
+	if len(gemReq.SystemInstruction.Parts) != 1 {
+		t.Fatalf("Expected 1 system part, got %d", len(gemReq.SystemInstruction.Parts))
+	}
+	if gemReq.SystemInstruction.Parts[0].Text != "You are a helpful assistant." {
+		t.Fatalf("Expected system text 'You are a helpful assistant.', got '%s'", gemReq.SystemInstruction.Parts[0].Text)
+	}
+
+	// Verify the contents
+	if len(gemReq.Contents) != 1 {
+		t.Fatalf("Expected 1 content, got %d", len(gemReq.Contents))
+	}
+	if len(gemReq.Contents[0].Parts) != 1 {
+		t.Fatalf("Expected 1 part, got %d", len(gemReq.Contents[0].Parts))
+	}
+	if gemReq.Contents[0].Parts[0].Text != "Hello, world!" {
+		t.Fatalf("Expected text 'Hello, world!', got '%s'", gemReq.Contents[0].Parts[0].Text)
+	}
+	// Verify the role is set correctly
+	if gemReq.Contents[0].Role != "user" {
+		t.Fatalf("Expected role 'user', got '%s'", gemReq.Contents[0].Role)
+	}
+}
+
+func TestConvertToolSchemas(t *testing.T) {
+	// Create a simple tool with a JSON schema
+	schema := `{
+		"type": "object",
+		"properties": {
+			"name": {
+				"type": "string",
+				"description": "The name of the person"
+			},
+			"age": {
+				"type": "integer",
+				"description": "The age of the person"
+			}
+		},
+		"required": ["name"]
+	}`
+
+	tools := []*llm.Tool{
+		{
+			Name:        "get_person",
+			Description: "Get information about a person",
+			InputSchema: json.RawMessage(schema),
+		},
+	}
+
+	// Convert the tools
+	decls, err := convertToolSchemas(tools)
+	if err != nil {
+		t.Fatalf("Failed to convert tool schemas: %v", err)
+	}
+
+	// Verify the result
+	if len(decls) != 1 {
+		t.Fatalf("Expected 1 declaration, got %d", len(decls))
+	}
+	if decls[0].Name != "get_person" {
+		t.Fatalf("Expected name 'get_person', got '%s'", decls[0].Name)
+	}
+	if decls[0].Description != "Get information about a person" {
+		t.Fatalf("Expected description 'Get information about a person', got '%s'", decls[0].Description)
+	}
+
+	// Verify the schema properties
+	if decls[0].Parameters.Type != 6 { // DataTypeOBJECT
+		t.Fatalf("Expected type OBJECT (6), got %d", decls[0].Parameters.Type)
+	}
+	if len(decls[0].Parameters.Properties) != 2 {
+		t.Fatalf("Expected 2 properties, got %d", len(decls[0].Parameters.Properties))
+	}
+	if decls[0].Parameters.Properties["name"].Type != 1 { // DataTypeSTRING
+		t.Fatalf("Expected name type STRING (1), got %d", decls[0].Parameters.Properties["name"].Type)
+	}
+	if decls[0].Parameters.Properties["age"].Type != 3 { // DataTypeINTEGER
+		t.Fatalf("Expected age type INTEGER (3), got %d", decls[0].Parameters.Properties["age"].Type)
+	}
+	if len(decls[0].Parameters.Required) != 1 || decls[0].Parameters.Required[0] != "name" {
+		t.Fatalf("Expected required field 'name', got %v", decls[0].Parameters.Required)
+	}
+}
+
+func TestService_Do_MockResponse(t *testing.T) {
+	// This is a mock test that doesn't make actual API calls
+	// Create a mock HTTP client that returns a predefined response
+
+	// Create a Service with a mock client
+	service := &Service{
+		Model:  DefaultModel,
+		APIKey: "test-api-key",
+		// We would use a mock HTTP client here in a real test
+	}
+
+	// Create a sample request
+	ir := &llm.Request{
+		Messages: []llm.Message{
+			{
+				Role: llm.MessageRoleUser,
+				Content: []llm.Content{
+					{
+						Type: llm.ContentTypeText,
+						Text: "Hello",
+					},
+				},
+			},
+		},
+	}
+
+	// In a real test, we would execute service.Do with a mock client
+	// and verify the response structure
+
+	// For now, we'll just test that buildGeminiRequest works correctly
+	_, err := service.buildGeminiRequest(ir)
+	if err != nil {
+		t.Fatalf("Failed to build request: %v", err)
+	}
+}
+
+func TestConvertResponseWithToolCall(t *testing.T) {
+	// Create a mock Gemini response with a function call
+	gemRes := &gemini.Response{
+		Candidates: []gemini.Candidate{
+			{
+				Content: gemini.Content{
+					Parts: []gemini.Part{
+						{
+							FunctionCall: &gemini.FunctionCall{
+								Name: "bash",
+								Args: map[string]any{
+									"command": "cat README.md",
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	// Convert the response
+	content := convertGeminiResponseToContent(gemRes)
+
+	// Verify that content has a tool use
+	if len(content) != 1 {
+		t.Fatalf("Expected 1 content item, got %d", len(content))
+	}
+
+	if content[0].Type != llm.ContentTypeToolUse {
+		t.Fatalf("Expected content type ToolUse, got %s", content[0].Type)
+	}
+
+	if content[0].ToolName != "bash" {
+		t.Fatalf("Expected tool name 'bash', got '%s'", content[0].ToolName)
+	}
+
+	// Verify the tool input
+	var args map[string]any
+	if err := json.Unmarshal(content[0].ToolInput, &args); err != nil {
+		t.Fatalf("Failed to unmarshal tool input: %v", err)
+	}
+
+	cmd, ok := args["command"]
+	if !ok {
+		t.Fatalf("Expected 'command' argument, not found")
+	}
+
+	if cmd != "cat README.md" {
+		t.Fatalf("Expected command 'cat README.md', got '%s'", cmd)
+	}
+}
+
+func TestGeminiHeaderCapture(t *testing.T) {
+	// Create a mock HTTP client that returns a response with headers
+	mockClient := &http.Client{
+		Transport: &mockRoundTripper{
+			response: &http.Response{
+				StatusCode: http.StatusOK,
+				Header: http.Header{
+					"Content-Type":            []string{"application/json"},
+					"Skaband-Cost-Microcents": []string{"123456"},
+				},
+				Body: io.NopCloser(bytes.NewBufferString(`{
+					"candidates": [{
+						"content": {
+							"parts": [{
+								"text": "Hello!"
+							}]
+						}
+					}]
+				}`)),
+			},
+		},
+	}
+
+	// Create a Gemini model with the mock client
+	model := gemini.Model{
+		Model:    "models/gemini-test",
+		APIKey:   "test-key",
+		HTTPC:    mockClient,
+		Endpoint: "https://test.googleapis.com",
+	}
+
+	// Make a request
+	req := &gemini.Request{
+		Contents: []gemini.Content{
+			{
+				Parts: []gemini.Part{{Text: "Hello"}},
+				Role:  "user",
+			},
+		},
+	}
+
+	ctx := context.Background()
+	res, err := model.GenerateContent(ctx, req)
+	if err != nil {
+		t.Fatalf("Failed to generate content: %v", err)
+	}
+
+	// Verify that headers were captured
+	headers := res.Header()
+	if headers == nil {
+		t.Fatalf("Expected headers to be captured, got nil")
+	}
+
+	// Check for the cost header
+	costHeader := headers.Get("Skaband-Cost-Microcents")
+	if costHeader != "123456" {
+		t.Fatalf("Expected cost header '123456', got '%s'", costHeader)
+	}
+
+	// Verify that llm.CostUSDFromResponse works with these headers
+	costUSD := llm.CostUSDFromResponse(headers)
+	expectedCost := 0.00123456 // 123456 microcents / 100,000,000
+	if costUSD != expectedCost {
+		t.Fatalf("Expected cost USD %.8f, got %.8f", expectedCost, costUSD)
+	}
+}
+
+// mockRoundTripper is a mock HTTP transport for testing
+type mockRoundTripper struct {
+	response *http.Response
+}
+
+func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+	return m.response, nil
+}
+
+func TestHeaderCostIntegration(t *testing.T) {
+	// Create a mock HTTP client that returns a response with cost headers
+	mockClient := &http.Client{
+		Transport: &mockRoundTripper{
+			response: &http.Response{
+				StatusCode: http.StatusOK,
+				Header: http.Header{
+					"Content-Type":            []string{"application/json"},
+					"Skaband-Cost-Microcents": []string{"50000"}, // 0.5 USD
+				},
+				Body: io.NopCloser(bytes.NewBufferString(`{
+					"candidates": [{
+						"content": {
+							"parts": [{
+								"text": "Test response"
+							}]
+						}
+					}]
+				}`)),
+			},
+		},
+	}
+
+	// Create a Gem service with the mock client
+	service := &Service{
+		Model:  "gemini-test",
+		APIKey: "test-key",
+		HTTPC:  mockClient,
+		URL:    "https://test.googleapis.com",
+	}
+
+	// Create a request
+	ir := &llm.Request{
+		Messages: []llm.Message{
+			{
+				Role: llm.MessageRoleUser,
+				Content: []llm.Content{
+					{
+						Type: llm.ContentTypeText,
+						Text: "Hello",
+					},
+				},
+			},
+		},
+	}
+
+	// Make the request
+	ctx := context.Background()
+	res, err := service.Do(ctx, ir)
+	if err != nil {
+		t.Fatalf("Failed to make request: %v", err)
+	}
+
+	// Verify that the cost was captured from headers
+	expectedCost := 0.0005 // 50000 microcents / 100,000,000
+	if res.Usage.CostUSD != expectedCost {
+		t.Fatalf("Expected cost USD %.8f, got %.8f", expectedCost, res.Usage.CostUSD)
+	}
+
+	// Verify token counts are still estimated
+	if res.Usage.InputTokens == 0 {
+		t.Fatalf("Expected input tokens to be estimated, got 0")
+	}
+	if res.Usage.OutputTokens == 0 {
+		t.Fatalf("Expected output tokens to be estimated, got 0")
+	}
+}

llm/gem/gemini/gemini.go πŸ”—

@@ -0,0 +1,187 @@
+package gemini
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+)
+
+// https://ai.google.dev/api/generate-content#request-body
+type Request struct {
+	Contents          []Content         `json:"contents"`
+	Tools             []Tool            `json:"tools,omitempty"`
+	SystemInstruction *Content          `json:"systemInstruction,omitempty"`
+	GenerationConfig  *GenerationConfig `json:"generationConfig,omitempty"`
+	CachedContent     string            `json:"cachedContent,omitempty"` // format: "cachedContents/{name}"
+	// ToolConfig has been left out because it does not appear to be useful.
+}
+
+// https://ai.google.dev/api/generate-content#response-body
+type Response struct {
+	Candidates []Candidate `json:"candidates"`
+	headers    http.Header // captured HTTP response headers
+}
+
+// Header returns the HTTP response headers.
+func (r *Response) Header() http.Header {
+	return r.headers
+}
+
+type Candidate struct {
+	Content Content `json:"content"`
+}
+
+type Content struct {
+	Parts []Part `json:"parts"`
+	Role  string `json:"role,omitempty"`
+}
+
+// Part is a part of the content.
+// This is a union data structure, only one-of the fields can be set.
+type Part struct {
+	Text                string               `json:"text,omitempty"`
+	FunctionCall        *FunctionCall        `json:"functionCall,omitempty"`
+	FunctionResponse    *FunctionResponse    `json:"functionResponse,omitempty"`
+	ExecutableCode      *ExecutableCode      `json:"executableCode,omitempty"`
+	CodeExecutionResult *CodeExecutionResult `json:"codeExecutionResult,omitempty"`
+	// TODO inlineData
+	// TODO fileData
+}
+
+type FunctionCall struct {
+	Name string         `json:"name"`
+	Args map[string]any `json:"args"`
+}
+
+type FunctionResponse struct {
+	Name     string         `json:"name"`
+	Response map[string]any `json:"response"`
+}
+
+type ExecutableCode struct {
+	Language Language `json:"language"`
+	Code     string   `json:"code"`
+}
+
+type Language int
+
+const (
+	LanguageUnspecified Language = 0
+	LanguagePython      Language = 1 // python >= 3.10 with numpy and simpy
+)
+
+type CodeExecutionResult struct {
+	Outcome Outcome `json:"outcome"`
+	Output  string  `json:"output"`
+}
+
+type Outcome int
+
+const (
+	OutcomeUnspecified      Outcome = 0
+	OutcomeOK               Outcome = 1
+	OutcomeFailed           Outcome = 2
+	OutcomeDeadlineExceeded Outcome = 3
+)
+
+// https://ai.google.dev/api/generate-content#v1beta.GenerationConfig
+type GenerationConfig struct {
+	ResponseMimeType string  `json:"responseMimeType,omitempty"` // text/plain, application/json, or text/x.enum
+	ResponseSchema   *Schema `json:"responseSchema,omitempty"`   // for JSON
+}
+
+// https://ai.google.dev/api/caching#Tool
+type Tool struct {
+	FunctionDeclarations []FunctionDeclaration `json:"functionDeclarations"`
+	CodeExecution        *struct{}             `json:"codeExecution,omitempty"` // if present, enables the model to execute code
+	// TODO googleSearchRetrieval https://ai.google.dev/api/caching#GoogleSearchRetrieval
+}
+
+// https://ai.google.dev/api/caching#FunctionDeclaration
+type FunctionDeclaration struct {
+	Name        string `json:"name"`
+	Description string `json:"description"`
+	Parameters  Schema `json:"parameters"`
+}
+
+// https://ai.google.dev/api/caching#Schema
+type Schema struct {
+	Type        DataType          `json:"type"`
+	Format      string            `json:"string,omitempty"` // for NUMBER type: float, double for INTEGER type: int32, int64 for STRING type: enum
+	Description string            `json:"description,omitempty"`
+	Nullable    *bool             `json:"nullable,omitempty"`
+	Enum        []string          `json:"enum,omitempty"`
+	MaxItems    string            `json:"maxItems,omitempty"`   // for ARRAY
+	MinItems    string            `json:"minItems,omitempty"`   // for ARRAY
+	Properties  map[string]Schema `json:"properties,omitempty"` // for OBJECT
+	Required    []string          `json:"required,omitempty"`   // for OBJECT
+	Items       *Schema           `json:"items,omitempty"`      // for ARRAY
+}
+
+type DataType int
+
+const (
+	DataTypeUNSPECIFIED = DataType(0) // Not specified, should not be used.
+	DataTypeSTRING      = DataType(1)
+	DataTypeNUMBER      = DataType(2)
+	DataTypeINTEGER     = DataType(3)
+	DataTypeBOOLEAN     = DataType(4)
+	DataTypeARRAY       = DataType(5)
+	DataTypeOBJECT      = DataType(6)
+)
+
+const defaultEndpoint = "https://generativelanguage.googleapis.com/v1beta"
+
+type Model struct {
+	Model    string // e.g. "models/gemini-1.5-flash"
+	APIKey   string
+	HTTPC    *http.Client // if nil, http.DefaultClient is used
+	Endpoint string       // if empty, DefaultEndpoint is used
+}
+
+func (m Model) GenerateContent(ctx context.Context, req *Request) (*Response, error) {
+	reqBytes, err := json.Marshal(req)
+	if err != nil {
+		return nil, fmt.Errorf("marshaling request: %w", err)
+	}
+	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/%s:generateContent?key=%s", m.endpoint(), m.Model, m.APIKey), bytes.NewReader(reqBytes))
+	if err != nil {
+		return nil, fmt.Errorf("creating HTTP request: %w", err)
+	}
+	httpReq.Header.Add("Content-Type", "application/json")
+	httpResp, err := m.httpc().Do(httpReq)
+	if err != nil {
+		return nil, fmt.Errorf("GenerateContent: do: %w", err)
+	}
+	defer httpResp.Body.Close()
+	body, err := io.ReadAll(httpResp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("GenerateContent: reading response body: %w", err)
+	}
+	if httpResp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("GenerateContent: HTTP status: %d, %s", httpResp.StatusCode, string(body))
+	}
+	var res Response
+	if err := json.Unmarshal(body, &res); err != nil {
+		return nil, fmt.Errorf("GenerateContent: unmarshaling response: %w, %s", err, string(body))
+	}
+	res.headers = httpResp.Header
+	return &res, nil
+}
+
+func (m Model) endpoint() string {
+	if m.Endpoint != "" {
+		return m.Endpoint
+	}
+	return defaultEndpoint
+}
+
+func (m Model) httpc() *http.Client {
+	if m.HTTPC != nil {
+		return m.HTTPC
+	}
+	return http.DefaultClient
+}

llm/gem/gemini/gemini_test.go πŸ”—

@@ -0,0 +1,33 @@
+package gemini
+
+import (
+	"context"
+	"os"
+	"testing"
+)
+
+func TestGenerateContent(t *testing.T) {
+	// TODO replace with local replay endpoint
+	m := Model{
+		Model:  "models/gemini-1.5-flash",
+		APIKey: os.Getenv("GEMINI_API_KEY"),
+	}
+	if testing.Short() {
+		t.Skip("skipping test in short mode")
+	}
+	if m.APIKey == "" {
+		t.Skip("skipping test without API key")
+	}
+
+	res, err := m.GenerateContent(context.Background(), &Request{
+		Contents: []Content{{
+			Parts: []Part{{
+				Text: "What is the capital of France?",
+			}},
+		}},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Logf("res: %+v", res)
+}

llm/image_content_test.go πŸ”—

@@ -0,0 +1,62 @@
+package llm
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+func TestImageContent(t *testing.T) {
+	// Create a Content structure with an image
+	imageContent := Content{
+		Type:      ContentTypeText, // In the future, we might add a specific ContentTypeImage
+		MediaType: "image/jpeg",
+		Data:      "/9j/4AAQSkZJRg...", // Shortened base64 encoded image
+	}
+
+	// Verify the structure is correct
+	if imageContent.MediaType != "image/jpeg" {
+		t.Errorf("Expected MediaType to be 'image/jpeg', got '%s'", imageContent.MediaType)
+	}
+
+	if imageContent.Data != "/9j/4AAQSkZJRg..." {
+		t.Errorf("Expected Data to contain base64 image data")
+	}
+
+	// Create a tool result that contains text and image content
+	toolResult := Content{
+		Type:      ContentTypeToolResult,
+		ToolUseID: "toolu_01A09q90qw90lq917835lq9",
+		ToolResult: []Content{
+			{
+				Type: ContentTypeText,
+				Text: "15 degrees",
+			},
+			imageContent,
+		},
+	}
+
+	// Check that the tool result contains two content items
+	if len(toolResult.ToolResult) != 2 {
+		t.Errorf("Expected tool result to contain 2 content items, got %d", len(toolResult.ToolResult))
+	}
+
+	// Verify JSON marshaling works as expected
+	bytes, err := json.Marshal(toolResult)
+	if err != nil {
+		t.Errorf("Failed to marshal content to JSON: %v", err)
+	}
+
+	// Unmarshal and verify structure is preserved
+	var unmarshaled Content
+	if err := json.Unmarshal(bytes, &unmarshaled); err != nil {
+		t.Errorf("Failed to unmarshal JSON: %v", err)
+	}
+
+	if len(unmarshaled.ToolResult) != 2 {
+		t.Errorf("Expected unmarshaled tool result to contain 2 content items, got %d", len(unmarshaled.ToolResult))
+	}
+
+	if unmarshaled.ToolResult[1].MediaType != "image/jpeg" {
+		t.Errorf("Expected unmarshaled image MediaType to be 'image/jpeg', got '%s'", unmarshaled.ToolResult[1].MediaType)
+	}
+}

llm/llm.go πŸ”—

@@ -0,0 +1,367 @@
+// Package llm provides a unified interface for interacting with LLMs.
+package llm
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type Service interface {
+	// Do sends a request to an LLM.
+	Do(context.Context, *Request) (*Response, error)
+	// TokenContextWindow returns the maximum token context window size for this service
+	TokenContextWindow() int
+}
+
+type SimplifiedPatcher interface {
+	// UseSimplifiedPatch reports whether the service should use the simplified patch input schema.
+	UseSimplifiedPatch() bool
+}
+
+func UseSimplifiedPatch(svc Service) bool {
+	if sp, ok := svc.(SimplifiedPatcher); ok {
+		return sp.UseSimplifiedPatch()
+	}
+	return false
+}
+
+// MustSchema validates that schema is a valid JSON schema and returns it as a json.RawMessage.
+// It panics if the schema is invalid.
+// The schema must have at least type="object" and a properties key.
+func MustSchema(schema string) json.RawMessage {
+	schema = strings.TrimSpace(schema)
+	bytes := []byte(schema)
+	var obj map[string]any
+	if err := json.Unmarshal(bytes, &obj); err != nil {
+		panic("failed to parse JSON schema: " + schema + ": " + err.Error())
+	}
+	if typ, ok := obj["type"]; !ok || typ != "object" {
+		panic("JSON schema must have type='object': " + schema)
+	}
+	if _, ok := obj["properties"]; !ok {
+		panic("JSON schema must have 'properties' key: " + schema)
+	}
+	return json.RawMessage(bytes)
+}
+
+func EmptySchema() json.RawMessage {
+	return MustSchema(`{"type": "object", "properties": {}}`)
+}
+
+type Request struct {
+	Messages   []Message
+	ToolChoice *ToolChoice
+	Tools      []*Tool
+	System     []SystemContent
+}
+
+// Message represents a message in the conversation.
+type Message struct {
+	Role      MessageRole `json:"Role"`
+	Content   []Content   `json:"Content"`
+	ToolUse   *ToolUse    `json:"ToolUse,omitempty"` // use to control whether/which tool to use
+	EndOfTurn bool        `json:"EndOfTurn"`         // true if this message completes the agent's turn (no tool calls to make)
+}
+
+// ToolUse represents a tool use in the message content.
+type ToolUse struct {
+	ID   string
+	Name string
+}
+
+type ToolChoice struct {
+	Type ToolChoiceType
+	Name string
+}
+
+type SystemContent struct {
+	Text  string
+	Type  string
+	Cache bool
+}
+
+// Tool represents a tool available to an LLM.
+type Tool struct {
+	Name string
+	// Type is used by the text editor tool; see
+	// https://docs.anthropic.com/en/docs/build-with-claude/tool-use/text-editor-tool
+	Type        string
+	Description string
+	InputSchema json.RawMessage
+	// EndsTurn indicates that this tool should cause the model to end its turn when used
+	EndsTurn bool
+	// Cache indicates whether to use prompt caching for this tool
+	Cache bool
+
+	// The Run function is automatically called when the tool is used.
+	// Run functions may be called concurrently with each other and themselves.
+	// The input to Run function is the input to the tool, as provided by Claude, in compliance with the input schema.
+	// The outputs from Run will be sent back to Claude.
+	// If you do not want to respond to the tool call request from Claude, return ErrDoNotRespond.
+	// ctx contains extra (rarely used) tool call information; retrieve it with ToolCallInfoFromContext.
+	Run func(ctx context.Context, input json.RawMessage) ToolOut `json:"-"`
+}
+
+// ToolOut represents the output of a tool run.
+type ToolOut struct {
+	// LLMContent is the output of the tool to be sent back to the LLM.
+	// May be nil on error.
+	LLMContent []Content
+	// Display is content to be displayed to the user.
+	// The type of content is set by the tool and coordinated with the UIs.
+	// It should be JSON-serializable.
+	Display any
+	// Error is the error (if any) that occurred during the tool run.
+	// The text contents of the error will be sent back to the LLM.
+	// If non-nil, LLMContent will be ignored.
+	Error error
+}
+
+type Content struct {
+	ID   string
+	Type ContentType
+	Text string
+
+	// Media type for image content
+	MediaType string
+
+	// for thinking
+	Thinking  string
+	Data      string
+	Signature string
+
+	// for tool_use
+	ToolName  string
+	ToolInput json.RawMessage
+
+	// for tool_result
+	ToolUseID  string
+	ToolError  bool
+	ToolResult []Content
+
+	// timing information for tool_result; added externally; not sent to the LLM
+	ToolUseStartTime *time.Time
+	ToolUseEndTime   *time.Time
+
+	// Display is content to be displayed to the user, copied from ToolOut
+	Display any
+
+	Cache bool
+}
+
+func StringContent(s string) Content {
+	return Content{Type: ContentTypeText, Text: s}
+}
+
+// ContentsAttr returns contents as a slog.Attr.
+// It is meant for logging.
+func ContentsAttr(contents []Content) slog.Attr {
+	var contentAttrs []any // slog.Attr
+	for _, content := range contents {
+		var attrs []any // slog.Attr
+		switch content.Type {
+		case ContentTypeText:
+			attrs = append(attrs, slog.String("text", content.Text))
+		case ContentTypeToolUse:
+			attrs = append(attrs, slog.String("tool_name", content.ToolName))
+			attrs = append(attrs, slog.String("tool_input", string(content.ToolInput)))
+		case ContentTypeToolResult:
+			attrs = append(attrs, slog.Any("tool_result", content.ToolResult))
+			attrs = append(attrs, slog.Bool("tool_error", content.ToolError))
+		case ContentTypeThinking:
+			attrs = append(attrs, slog.String("thinking", content.Text))
+		default:
+			attrs = append(attrs, slog.String("unknown_content_type", content.Type.String()))
+			attrs = append(attrs, slog.Any("text", content)) // just log it all raw, better to have too much than not enough
+		}
+		contentAttrs = append(contentAttrs, slog.Group(content.ID, attrs...))
+	}
+	return slog.Group("contents", contentAttrs...)
+}
+
+type (
+	MessageRole    int
+	ContentType    int
+	ToolChoiceType int
+	StopReason     int
+)
+
+//go:generate go tool golang.org/x/tools/cmd/stringer -type=MessageRole,ContentType,ToolChoiceType,StopReason -output=llm_string.go
+
+const (
+	MessageRoleUser MessageRole = iota
+	MessageRoleAssistant
+
+	ContentTypeText ContentType = iota
+	ContentTypeThinking
+	ContentTypeRedactedThinking
+	ContentTypeToolUse
+	ContentTypeToolResult
+
+	ToolChoiceTypeAuto ToolChoiceType = iota // default
+	ToolChoiceTypeAny                        // any tool, but must use one
+	ToolChoiceTypeNone                       // no tools allowed
+	ToolChoiceTypeTool                       // must use the tool specified in the Name field
+
+	StopReasonStopSequence StopReason = iota
+	StopReasonMaxTokens
+	StopReasonEndTurn
+	StopReasonToolUse
+	StopReasonRefusal
+)
+
+type Response struct {
+	ID           string
+	Type         string
+	Role         MessageRole
+	Model        string
+	Content      []Content
+	StopReason   StopReason
+	StopSequence *string
+	Usage        Usage
+	StartTime    *time.Time
+	EndTime      *time.Time
+}
+
+func (m *Response) ToMessage() Message {
+	return Message{
+		Role:      m.Role,
+		Content:   m.Content,
+		EndOfTurn: m.StopReason != StopReasonToolUse, // End of turn unless there are tools to call
+	}
+}
+
+func CostUSDFromResponse(headers http.Header) float64 {
+	h := headers.Get("Skaband-Cost-Microcents")
+	if h == "" {
+		return 0
+	}
+	uc, err := strconv.ParseUint(h, 10, 64)
+	if err != nil {
+		slog.Warn("failed to parse cost header", "header", h)
+		return 0
+	}
+	return float64(uc) / 100_000_000
+}
+
+// Usage represents the billing and rate-limit usage.
+// Most LLM structs do not have JSON tags, to avoid accidental direct use in specific providers.
+// However, the front-end uses this struct, and it relies on its JSON serialization.
+// Do NOT use this struct directly when implementing an llm.Service.
+type Usage struct {
+	InputTokens              uint64     `json:"input_tokens"`
+	CacheCreationInputTokens uint64     `json:"cache_creation_input_tokens"`
+	CacheReadInputTokens     uint64     `json:"cache_read_input_tokens"`
+	OutputTokens             uint64     `json:"output_tokens"`
+	CostUSD                  float64    `json:"cost_usd"`
+	Model                    string     `json:"model,omitempty"`
+	StartTime                *time.Time `json:"start_time,omitempty"`
+	EndTime                  *time.Time `json:"end_time,omitempty"`
+}
+
+func (u *Usage) Add(other Usage) {
+	u.InputTokens += other.InputTokens
+	u.CacheCreationInputTokens += other.CacheCreationInputTokens
+	u.CacheReadInputTokens += other.CacheReadInputTokens
+	u.OutputTokens += other.OutputTokens
+	u.CostUSD += other.CostUSD
+}
+
+func (u *Usage) String() string {
+	return fmt.Sprintf("in: %d, out: %d", u.InputTokens, u.OutputTokens)
+}
+
+// TotalInputTokens returns the total number of input tokens including cached tokens.
+// This represents the full context that was sent to the model:
+// - InputTokens: tokens processed (not from cache)
+// - CacheCreationInputTokens: tokens written to cache (also part of input)
+// - CacheReadInputTokens: tokens read from cache (also part of input)
+func (u *Usage) TotalInputTokens() uint64 {
+	return u.InputTokens + u.CacheCreationInputTokens + u.CacheReadInputTokens
+}
+
+// ContextWindowUsed returns the total context window usage after this response.
+// This is the size of the conversation that would be sent to the model for the next turn:
+// total input tokens + output tokens (which become part of the conversation).
+func (u *Usage) ContextWindowUsed() uint64 {
+	return u.TotalInputTokens() + u.OutputTokens
+}
+
+func (u *Usage) IsZero() bool {
+	return *u == Usage{}
+}
+
+func (u *Usage) Attr() slog.Attr {
+	return slog.Group("usage",
+		slog.Uint64("input_tokens", u.InputTokens),
+		slog.Uint64("output_tokens", u.OutputTokens),
+		slog.Uint64("cache_creation_input_tokens", u.CacheCreationInputTokens),
+		slog.Uint64("cache_read_input_tokens", u.CacheReadInputTokens),
+		slog.Float64("cost_usd", u.CostUSD),
+	)
+}
+
+// UserStringMessage creates a user message with a single text content item.
+func UserStringMessage(text string) Message {
+	return Message{
+		Role:    MessageRoleUser,
+		Content: []Content{StringContent(text)},
+	}
+}
+
+// TextContent creates a simple text content for tool results.
+// This is a helper function to create the most common type of tool result content.
+func TextContent(text string) []Content {
+	return []Content{{
+		Type: ContentTypeText,
+		Text: text,
+	}}
+}
+
+func ErrorToolOut(err error) ToolOut {
+	if err == nil {
+		panic("ErrorToolOut called with nil error")
+	}
+	return ToolOut{
+		Error: err,
+	}
+}
+
+func ErrorfToolOut(format string, args ...any) ToolOut {
+	return ErrorToolOut(fmt.Errorf(format, args...))
+}
+
+// DumpToFile writes LLM communication content to a timestamped file in ~/.cache/sketch/.
+// For requests, it includes the URL followed by the content. For responses, it only includes the content.
+// The typ parameter is used as a prefix in the filename ("request", "response").
+func DumpToFile(typ, url string, content []byte) error {
+	homeDir, err := os.UserHomeDir()
+	if err != nil {
+		return err
+	}
+	cacheDir := filepath.Join(homeDir, ".cache", "sketch")
+	err = os.MkdirAll(cacheDir, 0o700)
+	if err != nil {
+		return err
+	}
+	now := time.Now()
+	filename := fmt.Sprintf("%s_%d.txt", typ, now.UnixMilli())
+	filePath := filepath.Join(cacheDir, filename)
+
+	// For requests, start with the URL; for responses, just write the content
+	data := []byte(url)
+	if url != "" {
+		data = append(data, "\n\n"...)
+	}
+	data = append(data, content...)
+
+	return os.WriteFile(filePath, data, 0o600)
+}

llm/llm_string.go πŸ”—

@@ -0,0 +1,90 @@
+// Code generated by "stringer -type=MessageRole,ContentType,ToolChoiceType,StopReason -output=llm_string.go"; DO NOT EDIT.
+
+package llm
+
+import "strconv"
+
+func _() {
+	// An "invalid array index" compiler error signifies that the constant values have changed.
+	// Re-run the stringer command to generate them again.
+	var x [1]struct{}
+	_ = x[MessageRoleUser-0]
+	_ = x[MessageRoleAssistant-1]
+}
+
+const _MessageRole_name = "MessageRoleUserMessageRoleAssistant"
+
+var _MessageRole_index = [...]uint8{0, 15, 35}
+
+func (i MessageRole) String() string {
+	idx := int(i) - 0
+	if i < 0 || idx >= len(_MessageRole_index)-1 {
+		return "MessageRole(" + strconv.FormatInt(int64(i), 10) + ")"
+	}
+	return _MessageRole_name[_MessageRole_index[idx]:_MessageRole_index[idx+1]]
+}
+func _() {
+	// An "invalid array index" compiler error signifies that the constant values have changed.
+	// Re-run the stringer command to generate them again.
+	var x [1]struct{}
+	_ = x[ContentTypeText-2]
+	_ = x[ContentTypeThinking-3]
+	_ = x[ContentTypeRedactedThinking-4]
+	_ = x[ContentTypeToolUse-5]
+	_ = x[ContentTypeToolResult-6]
+}
+
+const _ContentType_name = "ContentTypeTextContentTypeThinkingContentTypeRedactedThinkingContentTypeToolUseContentTypeToolResult"
+
+var _ContentType_index = [...]uint8{0, 15, 34, 61, 79, 100}
+
+func (i ContentType) String() string {
+	idx := int(i) - 2
+	if i < 2 || idx >= len(_ContentType_index)-1 {
+		return "ContentType(" + strconv.FormatInt(int64(i), 10) + ")"
+	}
+	return _ContentType_name[_ContentType_index[idx]:_ContentType_index[idx+1]]
+}
+func _() {
+	// An "invalid array index" compiler error signifies that the constant values have changed.
+	// Re-run the stringer command to generate them again.
+	var x [1]struct{}
+	_ = x[ToolChoiceTypeAuto-7]
+	_ = x[ToolChoiceTypeAny-8]
+	_ = x[ToolChoiceTypeNone-9]
+	_ = x[ToolChoiceTypeTool-10]
+}
+
+const _ToolChoiceType_name = "ToolChoiceTypeAutoToolChoiceTypeAnyToolChoiceTypeNoneToolChoiceTypeTool"
+
+var _ToolChoiceType_index = [...]uint8{0, 18, 35, 53, 71}
+
+func (i ToolChoiceType) String() string {
+	idx := int(i) - 7
+	if i < 7 || idx >= len(_ToolChoiceType_index)-1 {
+		return "ToolChoiceType(" + strconv.FormatInt(int64(i), 10) + ")"
+	}
+	return _ToolChoiceType_name[_ToolChoiceType_index[idx]:_ToolChoiceType_index[idx+1]]
+}
+func _() {
+	// An "invalid array index" compiler error signifies that the constant values have changed.
+	// Re-run the stringer command to generate them again.
+	var x [1]struct{}
+	_ = x[StopReasonStopSequence-11]
+	_ = x[StopReasonMaxTokens-12]
+	_ = x[StopReasonEndTurn-13]
+	_ = x[StopReasonToolUse-14]
+	_ = x[StopReasonRefusal-15]
+}
+
+const _StopReason_name = "StopReasonStopSequenceStopReasonMaxTokensStopReasonEndTurnStopReasonToolUseStopReasonRefusal"
+
+var _StopReason_index = [...]uint8{0, 22, 41, 58, 75, 92}
+
+func (i StopReason) String() string {
+	idx := int(i) - 11
+	if i < 11 || idx >= len(_StopReason_index)-1 {
+		return "StopReason(" + strconv.FormatInt(int64(i), 10) + ")"
+	}
+	return _StopReason_name[_StopReason_index[idx]:_StopReason_index[idx+1]]
+}

llm/oai/oai.go πŸ”—

@@ -0,0 +1,918 @@
+package oai
+
+import (
+	"cmp"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log/slog"
+	"math/rand/v2"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/sashabaranov/go-openai"
+	"shelley.exe.dev/llm"
+)
+
+const (
+	DefaultMaxTokens = 8192
+
+	OpenAIURL    = "https://api.openai.com/v1"
+	FireworksURL = "https://api.fireworks.ai/inference/v1"
+	CerebrasURL  = "https://api.cerebras.ai/v1"
+	LlamaCPPURL  = "http://host.docker.internal:1234/v1"
+	TogetherURL  = "https://api.together.xyz/v1"
+	GeminiURL    = "https://generativelanguage.googleapis.com/v1beta/openai/"
+	MistralURL   = "https://api.mistral.ai/v1"
+	MoonshotURL  = "https://api.moonshot.ai/v1"
+
+	// Environment variable names for API keys
+	OpenAIAPIKeyEnv    = "OPENAI_API_KEY"
+	FireworksAPIKeyEnv = "FIREWORKS_API_KEY"
+	CerebrasAPIKeyEnv  = "CEREBRAS_API_KEY"
+	TogetherAPIKeyEnv  = "TOGETHER_API_KEY"
+	GeminiAPIKeyEnv    = "GEMINI_API_KEY"
+	MistralAPIKeyEnv   = "MISTRAL_API_KEY"
+	MoonshotAPIKeyEnv  = "MOONSHOT_API_KEY"
+)
+
+type Model struct {
+	UserName           string // provided by the user to identify this model (e.g. "gpt4.1")
+	ModelName          string // provided to the service provide to specify which model to use (e.g. "gpt-4.1-2025-04-14")
+	URL                string
+	APIKeyEnv          string // environment variable name for the API key
+	IsReasoningModel   bool   // whether this model is a reasoning model (e.g. O3, O4-mini)
+	UseSimplifiedPatch bool   // whether to use the simplified patch input schema; defaults to false
+}
+
+var (
+	DefaultModel = GPT41
+
+	GPT41 = Model{
+		UserName:  "gpt4.1",
+		ModelName: "gpt-4.1-2025-04-14",
+		URL:       OpenAIURL,
+		APIKeyEnv: OpenAIAPIKeyEnv,
+	}
+
+	GPT4o = Model{
+		UserName:  "gpt4o",
+		ModelName: "gpt-4o-2024-08-06",
+		URL:       OpenAIURL,
+		APIKeyEnv: OpenAIAPIKeyEnv,
+	}
+
+	GPT4oMini = Model{
+		UserName:  "gpt4o-mini",
+		ModelName: "gpt-4o-mini-2024-07-18",
+		URL:       OpenAIURL,
+		APIKeyEnv: OpenAIAPIKeyEnv,
+	}
+
+	GPT41Mini = Model{
+		UserName:  "gpt4.1-mini",
+		ModelName: "gpt-4.1-mini-2025-04-14",
+		URL:       OpenAIURL,
+		APIKeyEnv: OpenAIAPIKeyEnv,
+	}
+
+	GPT41Nano = Model{
+		UserName:  "gpt4.1-nano",
+		ModelName: "gpt-4.1-nano-2025-04-14",
+		URL:       OpenAIURL,
+		APIKeyEnv: OpenAIAPIKeyEnv,
+	}
+
+	O3 = Model{
+		UserName:         "o3",
+		ModelName:        "o3-2025-04-16",
+		URL:              OpenAIURL,
+		APIKeyEnv:        OpenAIAPIKeyEnv,
+		IsReasoningModel: true,
+	}
+
+	O4Mini = Model{
+		UserName:         "o4-mini",
+		ModelName:        "o4-mini-2025-04-16",
+		URL:              OpenAIURL,
+		APIKeyEnv:        OpenAIAPIKeyEnv,
+		IsReasoningModel: true,
+	}
+
+	Gemini25Flash = Model{
+		UserName:  "gemini-flash-2.5",
+		ModelName: "gemini-2.5-flash-preview-04-17",
+		URL:       GeminiURL,
+		APIKeyEnv: GeminiAPIKeyEnv,
+	}
+
+	Gemini25Pro = Model{
+		UserName:  "gemini-pro-2.5",
+		ModelName: "gemini-2.5-pro-preview-03-25",
+		URL:       GeminiURL,
+		// GRRRR. Really??
+		// Input is: $1.25, prompts <= 200k tokens, $2.50, prompts > 200k tokens
+		// Output is: $10.00, prompts <= 200k tokens, $15.00, prompts > 200k
+		// Caching is: $0.31, prompts <= 200k tokens, $0.625, prompts > 200k, $4.50 / 1,000,000 tokens per hour
+		// Whatever that means. Are we caching? I have no idea.
+		// How do you always manage to be the annoying one, Google?
+		// I'm not complicating things just for you.
+		APIKeyEnv: GeminiAPIKeyEnv,
+	}
+
+	TogetherDeepseekV3 = Model{
+		UserName:  "together-deepseek-v3",
+		ModelName: "deepseek-ai/DeepSeek-V3",
+		URL:       TogetherURL,
+		APIKeyEnv: TogetherAPIKeyEnv,
+	}
+
+	TogetherDeepseekR1 = Model{
+		UserName:  "together-deepseek-r1",
+		ModelName: "deepseek-ai/DeepSeek-R1",
+		URL:       TogetherURL,
+		APIKeyEnv: TogetherAPIKeyEnv,
+	}
+
+	TogetherLlama4Maverick = Model{
+		UserName:  "together-llama4-maverick",
+		ModelName: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
+		URL:       TogetherURL,
+		APIKeyEnv: TogetherAPIKeyEnv,
+	}
+
+	FireworksLlama4Maverick = Model{
+		UserName:  "fireworks-llama4-maverick",
+		ModelName: "accounts/fireworks/models/llama4-maverick-instruct-basic",
+		URL:       FireworksURL,
+		APIKeyEnv: FireworksAPIKeyEnv,
+	}
+
+	TogetherLlama3_3_70B = Model{
+		UserName:  "together-llama3-70b",
+		ModelName: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
+		URL:       TogetherURL,
+		APIKeyEnv: TogetherAPIKeyEnv,
+	}
+
+	TogetherMistralSmall = Model{
+		UserName:  "together-mistral-small",
+		ModelName: "mistralai/Mistral-Small-24B-Instruct-2501",
+		URL:       TogetherURL,
+		APIKeyEnv: TogetherAPIKeyEnv,
+	}
+
+	TogetherQwen3 = Model{
+		UserName:  "together-qwen3",
+		ModelName: "Qwen/Qwen3-235B-A22B-fp8-tput",
+		URL:       TogetherURL,
+		APIKeyEnv: TogetherAPIKeyEnv,
+	}
+
+	TogetherGemma2 = Model{
+		UserName:  "together-gemma2",
+		ModelName: "google/gemma-2-27b-it",
+		URL:       TogetherURL,
+		APIKeyEnv: TogetherAPIKeyEnv,
+	}
+
+	LlamaCPP = Model{
+		UserName:  "llama.cpp",
+		ModelName: "llama.cpp local model",
+		URL:       LlamaCPPURL,
+		APIKeyEnv: "NONE",
+	}
+
+	FireworksDeepseekV3 = Model{
+		UserName:  "fireworks-deepseek-v3",
+		ModelName: "accounts/fireworks/models/deepseek-v3-0324",
+		URL:       FireworksURL,
+		APIKeyEnv: FireworksAPIKeyEnv,
+	}
+
+	MoonshotKimiK2 = Model{
+		UserName:  "moonshot-kimi-k2",
+		ModelName: "moonshot-v1-auto",
+		URL:       MoonshotURL,
+		APIKeyEnv: MoonshotAPIKeyEnv,
+	}
+
+	MistralMedium = Model{
+		UserName:  "mistral-medium-3",
+		ModelName: "mistral-medium-latest",
+		URL:       MistralURL,
+		APIKeyEnv: MistralAPIKeyEnv,
+	}
+
+	DevstralSmall = Model{
+		UserName:  "devstral-small",
+		ModelName: "devstral-small-latest",
+		URL:       MistralURL,
+		APIKeyEnv: MistralAPIKeyEnv,
+	}
+
+	Qwen3CoderFireworks = Model{
+		UserName:           "qwen3-coder-fireworks",
+		ModelName:          "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
+		URL:                FireworksURL,
+		APIKeyEnv:          FireworksAPIKeyEnv,
+		UseSimplifiedPatch: true,
+	}
+
+	Qwen3CoderCerebras = Model{
+		UserName:  "qwen3-coder-cerebras",
+		ModelName: "qwen-3-coder-480b",
+		URL:       CerebrasURL,
+		APIKeyEnv: CerebrasAPIKeyEnv,
+	}
+
+	Qwen3Coder30Fireworks = Model{
+		UserName:           "qwen3-coder-30-fireworks",
+		ModelName:          "accounts/fireworks/models/qwen3-30b-a3b",
+		URL:                FireworksURL,
+		APIKeyEnv:          FireworksAPIKeyEnv,
+		UseSimplifiedPatch: true,
+	}
+
+	ZaiGLM45CoderFireworks = Model{
+		UserName:  "zai-glm45-fireworks",
+		ModelName: "accounts/fireworks/models/glm-4p5",
+		URL:       FireworksURL,
+		APIKeyEnv: FireworksAPIKeyEnv,
+	}
+
+	GLM4P6Fireworks = Model{
+		UserName:  "glm-4p6-fireworks",
+		ModelName: "accounts/fireworks/models/glm-4p6",
+		URL:       FireworksURL,
+		APIKeyEnv: FireworksAPIKeyEnv,
+	}
+
+	GPTOSS20B = Model{
+		UserName:  "gpt-oss-20b",
+		ModelName: "accounts/fireworks/models/gpt-oss-20b",
+		URL:       FireworksURL,
+		APIKeyEnv: FireworksAPIKeyEnv,
+	}
+
+	GPTOSS120B = Model{
+		UserName:  "gpt-oss-120b",
+		ModelName: "accounts/fireworks/models/gpt-oss-120b",
+		URL:       FireworksURL,
+		APIKeyEnv: FireworksAPIKeyEnv,
+	}
+
+	GPT5 = Model{
+		UserName:  "gpt-5-thinking",
+		ModelName: "gpt-5.1",
+		URL:       OpenAIURL,
+		APIKeyEnv: OpenAIAPIKeyEnv,
+	}
+
+	GPT5Mini = Model{
+		UserName:  "gpt-5-thinking-mini",
+		ModelName: "gpt-5.1-mini",
+		URL:       OpenAIURL,
+		APIKeyEnv: OpenAIAPIKeyEnv,
+	}
+
+	GPT5Nano = Model{
+		UserName:  "gpt-5-thinking-nano",
+		ModelName: "gpt-5.1-nano",
+		URL:       OpenAIURL,
+		APIKeyEnv: OpenAIAPIKeyEnv,
+	}
+
+	GPT5Codex = Model{
+		UserName:  "gpt-5.1-codex",
+		ModelName: "gpt-5.1-codex",
+		URL:       OpenAIURL,
+		APIKeyEnv: OpenAIAPIKeyEnv,
+	}
+
+	// Skaband-specific model names.
+	// Provider details (URL and APIKeyEnv) are handled by skaband
+	Qwen = Model{
+		UserName:           "qwen",
+		ModelName:          "qwen", // skaband will map this to the actual provider model
+		UseSimplifiedPatch: true,
+	}
+	GLM = Model{
+		UserName:  "glm",
+		ModelName: "glm", // skaband will map this to the actual provider model
+	}
+)
+
+// Service provides chat completions.
+// Fields should not be altered concurrently with calling any method on Service.
+type Service struct {
+	HTTPC     *http.Client // defaults to http.DefaultClient if nil
+	APIKey    string       // optional, if not set will try to load from env var
+	Model     Model        // defaults to DefaultModel if zero value
+	ModelURL  string       // optional, overrides Model.URL
+	MaxTokens int          // defaults to DefaultMaxTokens if zero
+	Org       string       // optional - organization ID
+	DumpLLM   bool         // whether to dump request/response text to files for debugging; defaults to false
+}
+
+var _ llm.Service = (*Service)(nil)
+
+// ModelsRegistry is a registry of all known models with their user-friendly names.
+var ModelsRegistry = []Model{
+	GPT41,
+	GPT41Mini,
+	GPT41Nano,
+	GPT4o,
+	GPT4oMini,
+	GPT5,
+	GPT5Mini,
+	GPT5Nano,
+	GPT5Codex,
+	O3,
+	O4Mini,
+	Gemini25Flash,
+	Gemini25Pro,
+	TogetherDeepseekV3,
+	TogetherDeepseekR1,
+	TogetherLlama4Maverick,
+	TogetherLlama3_3_70B,
+	TogetherMistralSmall,
+	TogetherQwen3,
+	TogetherGemma2,
+	LlamaCPP,
+	FireworksDeepseekV3,
+	MoonshotKimiK2,
+	FireworksLlama4Maverick,
+	MistralMedium,
+	DevstralSmall,
+	Qwen3CoderFireworks,
+	Qwen3Coder30Fireworks,
+	Qwen3CoderCerebras,
+	ZaiGLM45CoderFireworks,
+	GLM4P6Fireworks,
+	GPTOSS120B,
+	GPTOSS20B,
+	// Skaband-supported models
+	Qwen,
+	GLM,
+}
+
+// ListModels returns a list of all available models with their user-friendly names.
+func ListModels() []string {
+	var names []string
+	for _, model := range ModelsRegistry {
+		if model.UserName != "" {
+			names = append(names, model.UserName)
+		}
+	}
+	return names
+}
+
+// ModelByUserName returns a model by its user-friendly name.
+// Returns nil if no model with the given name is found.
+func ModelByUserName(name string) Model {
+	for _, model := range ModelsRegistry {
+		if model.UserName == name {
+			return model
+		}
+	}
+	return Model{}
+}
+
+func (m Model) IsZero() bool {
+	return m == Model{}
+}
+
+var (
+	fromLLMRole = map[llm.MessageRole]string{
+		llm.MessageRoleAssistant: "assistant",
+		llm.MessageRoleUser:      "user",
+	}
+	fromLLMToolChoiceType = map[llm.ToolChoiceType]string{
+		llm.ToolChoiceTypeAuto: "auto",
+		llm.ToolChoiceTypeAny:  "any",
+		llm.ToolChoiceTypeNone: "none",
+		llm.ToolChoiceTypeTool: "function", // OpenAI uses "function" instead of "tool"
+	}
+	toLLMRole = map[string]llm.MessageRole{
+		"assistant": llm.MessageRoleAssistant,
+		"user":      llm.MessageRoleUser,
+	}
+	toLLMStopReason = map[string]llm.StopReason{
+		"stop":           llm.StopReasonStopSequence,
+		"length":         llm.StopReasonMaxTokens,
+		"tool_calls":     llm.StopReasonToolUse,
+		"function_call":  llm.StopReasonToolUse,      // Map both to ToolUse
+		"content_filter": llm.StopReasonStopSequence, // No direct equivalent
+	}
+)
+
+// fromLLMContent converts llm.Content to the format expected by OpenAI.
+func fromLLMContent(c llm.Content) (string, []openai.ToolCall) {
+	switch c.Type {
+	case llm.ContentTypeText:
+		return c.Text, nil
+	case llm.ContentTypeToolUse:
+		// For OpenAI, tool use is sent as a null content with tool_calls in the message
+		return "", []openai.ToolCall{
+			{
+				Type: openai.ToolTypeFunction,
+				ID:   c.ID, // Use the content ID if provided
+				Function: openai.FunctionCall{
+					Name:      c.ToolName,
+					Arguments: string(c.ToolInput),
+				},
+			},
+		}
+	case llm.ContentTypeToolResult:
+		// Tool results in OpenAI are sent as a separate message with tool_call_id
+		// OpenAI doesn't support multiple content items or images in tool results
+		// Combine all text content into a single string
+		var resultText string
+		if len(c.ToolResult) > 0 {
+			// Collect all text from content objects
+			texts := make([]string, 0, len(c.ToolResult))
+			for _, result := range c.ToolResult {
+				if result.Text != "" {
+					texts = append(texts, result.Text)
+				}
+			}
+			resultText = strings.Join(texts, "\n")
+		}
+		return resultText, nil
+	default:
+		// For thinking or other types, convert to text
+		return c.Text, nil
+	}
+}
+
+// fromLLMMessage converts llm.Message to OpenAI ChatCompletionMessage format
+func fromLLMMessage(msg llm.Message) []openai.ChatCompletionMessage {
+	// For OpenAI, we need to handle tool results differently than regular messages
+	// Each tool result becomes its own message with role="tool"
+
+	var messages []openai.ChatCompletionMessage
+
+	// Check if this is a regular message or contains tool results
+	var regularContent []llm.Content
+	var toolResults []llm.Content
+
+	for _, c := range msg.Content {
+		if c.Type == llm.ContentTypeToolResult {
+			toolResults = append(toolResults, c)
+		} else {
+			regularContent = append(regularContent, c)
+		}
+	}
+
+	// Process tool results as separate messages, but first
+	for _, tr := range toolResults {
+		// Convert toolresult array to a string for OpenAI
+		// Collect all text from content objects
+		var texts []string
+		for _, result := range tr.ToolResult {
+			if strings.TrimSpace(result.Text) != "" {
+				texts = append(texts, result.Text)
+			}
+		}
+		toolResultContent := strings.Join(texts, "\n")
+
+		// OpenAI doesn't have an explicit error field for tool results, so add it directly to the content.
+		if tr.ToolError {
+			if toolResultContent != "" {
+				toolResultContent = "error: " + toolResultContent
+			} else {
+				toolResultContent = "error: tool execution failed"
+			}
+		}
+
+		m := openai.ChatCompletionMessage{
+			Role:       "tool",
+			Content:    cmp.Or(toolResultContent, " "), // Use empty space if empty to avoid omitempty issues
+			ToolCallID: tr.ToolUseID,
+		}
+		messages = append(messages, m)
+	}
+	// Process regular content second
+	if len(regularContent) > 0 {
+		m := openai.ChatCompletionMessage{
+			Role: fromLLMRole[msg.Role],
+		}
+
+		// For assistant messages that contain tool calls
+		var toolCalls []openai.ToolCall
+		var textContent string
+
+		for _, c := range regularContent {
+			content, tools := fromLLMContent(c)
+			if len(tools) > 0 {
+				toolCalls = append(toolCalls, tools...)
+			} else if content != "" {
+				if textContent != "" {
+					textContent += "\n"
+				}
+				textContent += content
+			}
+		}
+
+		m.Content = textContent
+		m.ToolCalls = toolCalls
+
+		messages = append(messages, m)
+	}
+
+	return messages
+}
+
+// requiresMaxCompletionTokens returns true if the model requires max_completion_tokens instead of max_tokens.
+func (m Model) requiresMaxCompletionTokens() bool {
+	// Reasoning models always use max_completion_tokens
+	if m.IsReasoningModel {
+		return true
+	}
+
+	// GPT-5 series models also require max_completion_tokens
+	switch m.ModelName {
+	case "gpt-5.1", "gpt-5.1-mini", "gpt-5.1-nano":
+		return true
+	default:
+		return false
+	}
+}
+
+// fromLLMToolChoice converts llm.ToolChoice to the format expected by OpenAI.
+func fromLLMToolChoice(tc *llm.ToolChoice) any {
+	if tc == nil {
+		return nil
+	}
+
+	if tc.Type == llm.ToolChoiceTypeTool && tc.Name != "" {
+		return openai.ToolChoice{
+			Type: openai.ToolTypeFunction,
+			Function: openai.ToolFunction{
+				Name: tc.Name,
+			},
+		}
+	}
+
+	// For non-specific tool choice, just use the string
+	return fromLLMToolChoiceType[tc.Type]
+}
+
+// fromLLMTool converts llm.Tool to the format expected by OpenAI.
+func fromLLMTool(t *llm.Tool) openai.Tool {
+	return openai.Tool{
+		Type: openai.ToolTypeFunction,
+		Function: &openai.FunctionDefinition{
+			Name:        t.Name,
+			Description: t.Description,
+			Parameters:  t.InputSchema,
+		},
+	}
+}
+
+// fromLLMSystem converts llm.SystemContent to an OpenAI system message.
+func fromLLMSystem(systemContent []llm.SystemContent) []openai.ChatCompletionMessage {
+	if len(systemContent) == 0 {
+		return nil
+	}
+
+	// Combine all system content into a single system message
+	var systemText string
+	for i, content := range systemContent {
+		if i > 0 && systemText != "" && content.Text != "" {
+			systemText += "\n"
+		}
+		systemText += content.Text
+	}
+
+	if systemText == "" {
+		return nil
+	}
+
+	return []openai.ChatCompletionMessage{
+		{
+			Role:    "system",
+			Content: systemText,
+		},
+	}
+}
+
+// toRawLLMContent converts a raw content string from OpenAI to llm.Content.
+func toRawLLMContent(content string) llm.Content {
+	return llm.Content{
+		Type: llm.ContentTypeText,
+		Text: content,
+	}
+}
+
+// toToolCallLLMContent converts a tool call from OpenAI to llm.Content.
+func toToolCallLLMContent(toolCall openai.ToolCall) llm.Content {
+	// Generate a content ID if needed
+	id := toolCall.ID
+	if id == "" {
+		// Create a deterministic ID based on the function name if no ID is provided
+		id = "tc_" + toolCall.Function.Name
+	}
+
+	return llm.Content{
+		ID:        id,
+		Type:      llm.ContentTypeToolUse,
+		ToolName:  toolCall.Function.Name,
+		ToolInput: json.RawMessage(toolCall.Function.Arguments),
+	}
+}
+
+// toToolResultLLMContent converts a tool result message from OpenAI to llm.Content.
+func toToolResultLLMContent(msg openai.ChatCompletionMessage) llm.Content {
+	return llm.Content{
+		Type:      llm.ContentTypeToolResult,
+		ToolUseID: msg.ToolCallID,
+		ToolResult: []llm.Content{{
+			Type: llm.ContentTypeText,
+			Text: msg.Content,
+		}},
+		ToolError: false, // OpenAI doesn't specify errors explicitly; error information is parsed from content
+	}
+}
+
+// toLLMContents converts message content from OpenAI to []llm.Content.
+func toLLMContents(msg openai.ChatCompletionMessage) []llm.Content {
+	var contents []llm.Content
+
+	// If this is a tool response, handle it separately
+	if msg.Role == "tool" && msg.ToolCallID != "" {
+		return []llm.Content{toToolResultLLMContent(msg)}
+	}
+
+	// If there's text content, add it
+	if msg.Content != "" {
+		contents = append(contents, toRawLLMContent(msg.Content))
+	}
+
+	// If there are tool calls, add them
+	for _, tc := range msg.ToolCalls {
+		contents = append(contents, toToolCallLLMContent(tc))
+	}
+
+	// If empty, add an empty text content
+	if len(contents) == 0 {
+		contents = append(contents, llm.Content{
+			Type: llm.ContentTypeText,
+			Text: "",
+		})
+	}
+
+	return contents
+}
+
+// toLLMUsage converts usage information from OpenAI to llm.Usage.
+func (s *Service) toLLMUsage(au openai.Usage, headers http.Header) llm.Usage {
+	// fmt.Printf("raw usage: %+v / %v / %v\n", au, au.PromptTokensDetails, au.CompletionTokensDetails)
+	in := uint64(au.PromptTokens)
+	var inc uint64
+	if au.PromptTokensDetails != nil {
+		inc = uint64(au.PromptTokensDetails.CachedTokens)
+	}
+	out := uint64(au.CompletionTokens)
+	u := llm.Usage{
+		InputTokens:              in,
+		CacheReadInputTokens:     inc,
+		CacheCreationInputTokens: in,
+		OutputTokens:             out,
+	}
+	u.CostUSD = llm.CostUSDFromResponse(headers)
+	return u
+}
+
+// toLLMResponse converts the OpenAI response to llm.Response.
+func (s *Service) toLLMResponse(r *openai.ChatCompletionResponse) *llm.Response {
+	// fmt.Printf("Raw response\n")
+	// enc := json.NewEncoder(os.Stdout)
+	// enc.SetIndent("", "  ")
+	// enc.Encode(r)
+	// fmt.Printf("\n")
+
+	if len(r.Choices) == 0 {
+		return &llm.Response{
+			ID:    r.ID,
+			Model: r.Model,
+			Role:  llm.MessageRoleAssistant,
+			Usage: s.toLLMUsage(r.Usage, r.Header()),
+		}
+	}
+
+	// Process the primary choice
+	choice := r.Choices[0]
+
+	return &llm.Response{
+		ID:         r.ID,
+		Model:      r.Model,
+		Role:       toRoleFromString(choice.Message.Role),
+		Content:    toLLMContents(choice.Message),
+		StopReason: toStopReason(string(choice.FinishReason)),
+		Usage:      s.toLLMUsage(r.Usage, r.Header()),
+	}
+}
+
+// toRoleFromString converts a role string to llm.MessageRole.
+func toRoleFromString(role string) llm.MessageRole {
+	if role == "tool" || role == "system" || role == "function" {
+		return llm.MessageRoleAssistant // Map special roles to assistant for consistency
+	}
+	if mr, ok := toLLMRole[role]; ok {
+		return mr
+	}
+	return llm.MessageRoleUser // Default to user if unknown
+}
+
+// toStopReason converts a finish reason string to llm.StopReason.
+func toStopReason(reason string) llm.StopReason {
+	if sr, ok := toLLMStopReason[reason]; ok {
+		return sr
+	}
+	return llm.StopReasonStopSequence // Default
+}
+
+// TokenContextWindow returns the maximum token context window size for this service
+func (s *Service) TokenContextWindow() int {
+	// TODO: move TokenContextWindow information to Model struct
+
+	model := cmp.Or(s.Model, DefaultModel)
+
+	// OpenAI models generally have 128k context windows
+	// Some newer models have larger windows, but 128k is a safe default
+	switch model.ModelName {
+	case "gpt-4.1-2025-04-14", "gpt-4.1-mini-2025-04-14", "gpt-4.1-nano-2025-04-14":
+		return 200000 // 200k for newer GPT-4.1 models
+	case "gpt-4o-2024-08-06", "gpt-4o-mini-2024-07-18":
+		return 128000 // 128k for GPT-4o models
+	case "o3-2025-04-16", "o3-mini-2025-04-16":
+		return 200000 // 200k for O3 models
+	case "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct":
+		return 256000 // 256k native context for Qwen3-Coder
+	case "glm", "zai-glm45-fireworks":
+		return 128000
+	case "qwen", "qwen3-coder-cerebras", "qwen3-coder-fireworks":
+		return 256000 // 256k native context for Qwen3-Coder
+	case "gpt-oss-20b", "gpt-oss-120b":
+		return 128000
+	case "gpt-5.1", "gpt-5.1-mini", "gpt-5.1-nano":
+		return 256000
+	default:
+		// Default for unknown models
+		return 128000
+	}
+}
+
+// Do sends a request to OpenAI using the go-openai package.
+func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error) {
+	// Configure the OpenAI client
+	httpc := cmp.Or(s.HTTPC, http.DefaultClient)
+	model := cmp.Or(s.Model, DefaultModel)
+
+	// TODO: do this one during Service setup? maybe with a constructor instead?
+	config := openai.DefaultConfig(s.APIKey)
+	baseURL := cmp.Or(s.ModelURL, model.URL)
+	if baseURL != "" {
+		config.BaseURL = baseURL
+	}
+	if s.Org != "" {
+		config.OrgID = s.Org
+	}
+	config.HTTPClient = httpc
+
+	client := openai.NewClientWithConfig(config)
+
+	// Start with system messages if provided
+	var allMessages []openai.ChatCompletionMessage
+	if len(ir.System) > 0 {
+		sysMessages := fromLLMSystem(ir.System)
+		allMessages = append(allMessages, sysMessages...)
+	}
+
+	// Add regular and tool messages
+	for _, msg := range ir.Messages {
+		msgs := fromLLMMessage(msg)
+		allMessages = append(allMessages, msgs...)
+	}
+
+	// Convert tools
+	var tools []openai.Tool
+	for _, t := range ir.Tools {
+		tools = append(tools, fromLLMTool(t))
+	}
+
+	// Create the OpenAI request
+	req := openai.ChatCompletionRequest{
+		Model:      model.ModelName,
+		Messages:   allMessages,
+		Tools:      tools,
+		ToolChoice: fromLLMToolChoice(ir.ToolChoice), // TODO: make fromLLMToolChoice return an error when a perfect translation is not possible
+	}
+	if model.requiresMaxCompletionTokens() {
+		req.MaxCompletionTokens = cmp.Or(s.MaxTokens, DefaultMaxTokens)
+	} else {
+		req.MaxTokens = cmp.Or(s.MaxTokens, DefaultMaxTokens)
+	}
+	// Construct the full URL for logging and debugging
+	fullURL := baseURL + "/chat/completions"
+
+	// Dump request if enabled
+	if s.DumpLLM {
+		if reqJSON, err := json.MarshalIndent(req, "", "  "); err == nil {
+			if err := llm.DumpToFile("request", fullURL, reqJSON); err != nil {
+				slog.WarnContext(ctx, "failed to dump openai request to file", "error", err)
+			}
+		}
+	}
+
+	// Retry mechanism
+	backoff := []time.Duration{1 * time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second, 15 * time.Second}
+
+	// retry loop
+	var errs error // accumulated errors across all attempts
+	for attempts := 0; ; attempts++ {
+		if attempts > 10 {
+			return nil, fmt.Errorf("openai request failed after %d attempts (url=%s, model=%s): %w", attempts, fullURL, model.ModelName, errs)
+		}
+		if attempts > 0 {
+			sleep := backoff[min(attempts, len(backoff)-1)] + time.Duration(rand.Int64N(int64(time.Second)))
+			slog.WarnContext(ctx, "openai request sleep before retry", "sleep", sleep, "attempts", attempts)
+			time.Sleep(sleep)
+		}
+
+		resp, err := client.CreateChatCompletion(ctx, req)
+
+		// Handle successful response
+		if err == nil {
+			// Dump response if enabled
+			if s.DumpLLM {
+				if respJSON, jsonErr := json.MarshalIndent(resp, "", "  "); jsonErr == nil {
+					if dumpErr := llm.DumpToFile("response", "", respJSON); dumpErr != nil {
+						slog.WarnContext(ctx, "failed to dump openai response to file", "error", dumpErr)
+					}
+				}
+			}
+			return s.toLLMResponse(&resp), nil
+		}
+
+		// Handle errors
+		// Check for TLS "bad record MAC" errors and retry once
+		if strings.Contains(err.Error(), "tls: bad record MAC") && attempts == 0 {
+			slog.WarnContext(ctx, "tls bad record MAC error, retrying once", "error", err.Error())
+			errs = errors.Join(errs, fmt.Errorf("TLS error (attempt %d): %w", attempts+1, err))
+			continue
+		}
+
+		var apiErr *openai.APIError
+		if ok := errors.As(err, &apiErr); !ok {
+			// Not an OpenAI API error, return immediately with accumulated errors
+			return nil, errors.Join(errs, fmt.Errorf("url=%s model=%s: %w", fullURL, model.ModelName, err))
+		}
+
+		switch {
+		case apiErr.HTTPStatusCode >= 500:
+			// Server error, try again with backoff
+			slog.WarnContext(ctx, "openai_request_failed", "error", apiErr.Error(), "status_code", apiErr.HTTPStatusCode, "url", fullURL, "model", model.ModelName)
+			errs = errors.Join(errs, fmt.Errorf("status %d (url=%s, model=%s): %s", apiErr.HTTPStatusCode, fullURL, model.ModelName, apiErr.Error()))
+			continue
+
+		case apiErr.HTTPStatusCode == 429:
+			// Rate limited, accumulate error and retry
+			slog.WarnContext(ctx, "openai_request_rate_limited", "error", apiErr.Error(), "url", fullURL, "model", model.ModelName)
+			errs = errors.Join(errs, fmt.Errorf("status %d (rate limited, url=%s, model=%s): %s", apiErr.HTTPStatusCode, fullURL, model.ModelName, apiErr.Error()))
+			continue
+
+		case apiErr.HTTPStatusCode >= 400 && apiErr.HTTPStatusCode < 500:
+			// Client error, probably unrecoverable
+			slog.WarnContext(ctx, "openai_request_failed", "error", apiErr.Error(), "status_code", apiErr.HTTPStatusCode, "url", fullURL, "model", model.ModelName)
+			return nil, errors.Join(errs, fmt.Errorf("status %d (url=%s, model=%s): %s", apiErr.HTTPStatusCode, fullURL, model.ModelName, apiErr.Error()))
+
+		default:
+			// Other error, accumulate and retry
+			slog.WarnContext(ctx, "openai_request_failed", "error", apiErr.Error(), "status_code", apiErr.HTTPStatusCode, "url", fullURL, "model", model.ModelName)
+			errs = errors.Join(errs, fmt.Errorf("status %d (url=%s, model=%s): %s", apiErr.HTTPStatusCode, fullURL, model.ModelName, apiErr.Error()))
+			continue
+		}
+	}
+}
+
+func (s *Service) UseSimplifiedPatch() bool {
+	return s.Model.UseSimplifiedPatch
+}
+
+// ConfigDetails returns configuration information for logging
+func (s *Service) ConfigDetails() map[string]string {
+	model := cmp.Or(s.Model, DefaultModel)
+	baseURL := cmp.Or(s.ModelURL, model.URL, OpenAIURL)
+	return map[string]string{
+		"base_url":        baseURL,
+		"model_name":      model.ModelName,
+		"full_url":        baseURL + "/chat/completions",
+		"api_key_env":     model.APIKeyEnv,
+		"has_api_key_set": fmt.Sprintf("%v", s.APIKey != ""),
+	}
+}

llm/oai/oai_responses.go πŸ”—

@@ -0,0 +1,522 @@
+package oai
+
+import (
+	"bytes"
+	"cmp"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"math/rand/v2"
+	"net/http"
+	"strings"
+	"time"
+
+	"shelley.exe.dev/llm"
+)
+
+// ResponsesService provides chat completions using the OpenAI Responses API.
+// This API is required for models like gpt-5.1-codex.
+// Fields should not be altered concurrently with calling any method on ResponsesService.
+type ResponsesService struct {
+	HTTPC     *http.Client // defaults to http.DefaultClient if nil
+	APIKey    string       // optional, if not set will try to load from env var
+	Model     Model        // defaults to DefaultModel if zero value
+	ModelURL  string       // optional, overrides Model.URL
+	MaxTokens int          // defaults to DefaultMaxTokens if zero
+	Org       string       // optional - organization ID
+	DumpLLM   bool         // whether to dump request/response text to files for debugging; defaults to false
+}
+
+var _ llm.Service = (*ResponsesService)(nil)
+
+// Responses API request/response types
+
+type responsesRequest struct {
+	Model           string               `json:"model"`
+	Input           []responsesInputItem `json:"input"`
+	Tools           []responsesTool      `json:"tools,omitempty"`
+	ToolChoice      any                  `json:"tool_choice,omitempty"`
+	MaxOutputTokens int                  `json:"max_output_tokens,omitempty"`
+	Reasoning       *responsesReasoning  `json:"reasoning,omitempty"`
+}
+
+type responsesReasoning struct {
+	Effort string `json:"effort,omitempty"` // "low", "medium", "high"
+}
+
+type responsesInputItem struct {
+	Type      string             `json:"type"`                // "message", "function_call", "function_call_output"
+	Role      string             `json:"role,omitempty"`      // for messages: "user", "assistant"
+	Content   []responsesContent `json:"content,omitempty"`   // for messages
+	CallID    string             `json:"call_id,omitempty"`   // for function_call and function_call_output
+	Name      string             `json:"name,omitempty"`      // for function_call
+	Arguments string             `json:"arguments,omitempty"` // for function_call
+	Output    string             `json:"output,omitempty"`    // for function_call_output
+}
+
+type responsesContent struct {
+	Type string `json:"type"` // "input_text", "output_text"
+	Text string `json:"text"`
+}
+
+type responsesTool struct {
+	Type        string          `json:"type"` // "function"
+	Name        string          `json:"name"`
+	Description string          `json:"description,omitempty"`
+	Parameters  json.RawMessage `json:"parameters,omitempty"`
+}
+
+type responsesResponse struct {
+	ID        string                `json:"id"`
+	Object    string                `json:"object"` // "response"
+	CreatedAt int64                 `json:"created_at"`
+	Status    string                `json:"status"` // "completed", "incomplete", etc.
+	Model     string                `json:"model"`
+	Output    []responsesOutputItem `json:"output"`
+	Usage     responsesUsage        `json:"usage"`
+	Error     *responsesError       `json:"error"`
+}
+
+type responsesOutputItem struct {
+	ID        string             `json:"id"`
+	Type      string             `json:"type"`           // "message", "reasoning", "function_call"
+	Role      string             `json:"role,omitempty"` // for messages: "assistant"
+	Status    string             `json:"status,omitempty"`
+	Content   []responsesContent `json:"content,omitempty"`   // for messages
+	CallID    string             `json:"call_id,omitempty"`   // for function_call
+	Name      string             `json:"name,omitempty"`      // for function_call
+	Arguments string             `json:"arguments,omitempty"` // for function_call
+	Summary   []string           `json:"summary,omitempty"`   // for reasoning
+}
+
+type responsesUsage struct {
+	InputTokens         int                           `json:"input_tokens"`
+	InputTokensDetails  *responsesInputTokensDetails  `json:"input_tokens_details,omitempty"`
+	OutputTokens        int                           `json:"output_tokens"`
+	OutputTokensDetails *responsesOutputTokensDetails `json:"output_tokens_details,omitempty"`
+	TotalTokens         int                           `json:"total_tokens"`
+}
+
+type responsesInputTokensDetails struct {
+	CachedTokens int `json:"cached_tokens"`
+}
+
+type responsesOutputTokensDetails struct {
+	ReasoningTokens int `json:"reasoning_tokens"`
+}
+
+type responsesError struct {
+	Message string `json:"message"`
+	Type    string `json:"type"`
+	Param   string `json:"param"`
+	Code    string `json:"code"`
+}
+
+// fromLLMMessageResponses converts llm.Message to Responses API input items
+func fromLLMMessageResponses(msg llm.Message) []responsesInputItem {
+	var items []responsesInputItem
+
+	// Separate tool results from regular content
+	var regularContent []llm.Content
+	var toolResults []llm.Content
+
+	for _, c := range msg.Content {
+		if c.Type == llm.ContentTypeToolResult {
+			toolResults = append(toolResults, c)
+		} else {
+			regularContent = append(regularContent, c)
+		}
+	}
+
+	// Process tool results first - they need to come before the assistant message
+	for _, tr := range toolResults {
+		// Collect all text from content objects
+		var texts []string
+		for _, result := range tr.ToolResult {
+			if strings.TrimSpace(result.Text) != "" {
+				texts = append(texts, result.Text)
+			}
+		}
+		toolResultContent := strings.Join(texts, "\n")
+
+		// Add error prefix if needed
+		if tr.ToolError {
+			if toolResultContent != "" {
+				toolResultContent = "error: " + toolResultContent
+			} else {
+				toolResultContent = "error: tool execution failed"
+			}
+		}
+
+		items = append(items, responsesInputItem{
+			Type:   "function_call_output",
+			CallID: tr.ToolUseID,
+			Output: cmp.Or(toolResultContent, " "),
+		})
+	}
+
+	// Process regular content
+	if len(regularContent) > 0 {
+		var messageContent []responsesContent
+		var functionCalls []responsesInputItem
+
+		for _, c := range regularContent {
+			switch c.Type {
+			case llm.ContentTypeText:
+				if c.Text != "" {
+					contentType := "input_text"
+					if msg.Role == llm.MessageRoleAssistant {
+						contentType = "output_text"
+					}
+					messageContent = append(messageContent, responsesContent{
+						Type: contentType,
+						Text: c.Text,
+					})
+				}
+			case llm.ContentTypeToolUse:
+				// Tool use becomes a function_call in the input
+				functionCalls = append(functionCalls, responsesInputItem{
+					Type:      "function_call",
+					CallID:    c.ID,
+					Name:      c.ToolName,
+					Arguments: string(c.ToolInput),
+				})
+			}
+		}
+
+		// Add message if it has content
+		if len(messageContent) > 0 {
+			role := "user"
+			if msg.Role == llm.MessageRoleAssistant {
+				role = "assistant"
+			}
+			items = append(items, responsesInputItem{
+				Type:    "message",
+				Role:    role,
+				Content: messageContent,
+			})
+		}
+
+		// Add function calls
+		items = append(items, functionCalls...)
+	}
+
+	return items
+}
+
+// fromLLMToolResponses converts llm.Tool to Responses API tool format
+func fromLLMToolResponses(t *llm.Tool) responsesTool {
+	return responsesTool{
+		Type:        "function",
+		Name:        t.Name,
+		Description: t.Description,
+		Parameters:  t.InputSchema,
+	}
+}
+
+// fromLLMSystemResponses converts llm.SystemContent to Responses API input items
+func fromLLMSystemResponses(systemContent []llm.SystemContent) []responsesInputItem {
+	if len(systemContent) == 0 {
+		return nil
+	}
+
+	// Combine all system content into a single system message
+	var systemText string
+	for i, content := range systemContent {
+		if i > 0 && systemText != "" && content.Text != "" {
+			systemText += "\n"
+		}
+		systemText += content.Text
+	}
+
+	if systemText == "" {
+		return nil
+	}
+
+	return []responsesInputItem{
+		{
+			Type: "message",
+			Role: "user",
+			Content: []responsesContent{
+				{
+					Type: "input_text",
+					Text: systemText,
+				},
+			},
+		},
+	}
+}
+
+// toLLMResponseFromResponses converts Responses API response to llm.Response
+func (s *ResponsesService) toLLMResponseFromResponses(resp *responsesResponse, headers http.Header) *llm.Response {
+	if len(resp.Output) == 0 {
+		return &llm.Response{
+			ID:    resp.ID,
+			Model: resp.Model,
+			Role:  llm.MessageRoleAssistant,
+			Usage: s.toLLMUsageFromResponses(resp.Usage, headers),
+		}
+	}
+
+	// Process the output items
+	var contents []llm.Content
+	var stopReason llm.StopReason = llm.StopReasonStopSequence
+
+	for _, item := range resp.Output {
+		switch item.Type {
+		case "message":
+			// Convert message content
+			for _, c := range item.Content {
+				if c.Text != "" {
+					contents = append(contents, llm.Content{
+						Type: llm.ContentTypeText,
+						Text: c.Text,
+					})
+				}
+			}
+		case "reasoning":
+			// Convert reasoning to thinking content
+			if len(item.Summary) > 0 {
+				summaryText := strings.Join(item.Summary, "\n")
+				contents = append(contents, llm.Content{
+					Type: llm.ContentTypeThinking,
+					Text: summaryText,
+				})
+			}
+		case "function_call":
+			// Convert function call to tool use
+			contents = append(contents, llm.Content{
+				ID:        item.CallID,
+				Type:      llm.ContentTypeToolUse,
+				ToolName:  item.Name,
+				ToolInput: json.RawMessage(item.Arguments),
+			})
+			stopReason = llm.StopReasonToolUse
+		}
+	}
+
+	// If no content, add empty text content
+	if len(contents) == 0 {
+		contents = append(contents, llm.Content{
+			Type: llm.ContentTypeText,
+			Text: "",
+		})
+	}
+
+	return &llm.Response{
+		ID:         resp.ID,
+		Model:      resp.Model,
+		Role:       llm.MessageRoleAssistant,
+		Content:    contents,
+		StopReason: stopReason,
+		Usage:      s.toLLMUsageFromResponses(resp.Usage, headers),
+	}
+}
+
+// toLLMUsageFromResponses converts Responses API usage to llm.Usage
+func (s *ResponsesService) toLLMUsageFromResponses(usage responsesUsage, headers http.Header) llm.Usage {
+	in := uint64(usage.InputTokens)
+	var inc uint64
+	if usage.InputTokensDetails != nil {
+		inc = uint64(usage.InputTokensDetails.CachedTokens)
+	}
+	out := uint64(usage.OutputTokens)
+	u := llm.Usage{
+		InputTokens:              in,
+		CacheReadInputTokens:     inc,
+		CacheCreationInputTokens: in,
+		OutputTokens:             out,
+	}
+	u.CostUSD = llm.CostUSDFromResponse(headers)
+	return u
+}
+
+// TokenContextWindow returns the maximum token context window size for this service
+func (s *ResponsesService) TokenContextWindow() int {
+	model := cmp.Or(s.Model, DefaultModel)
+
+	// Use the same context window logic as the regular service
+	switch model.ModelName {
+	case "gpt-5.1-codex":
+		return 256000 // 256k for gpt-5.1-codex
+	case "gpt-4.1-2025-04-14", "gpt-4.1-mini-2025-04-14", "gpt-4.1-nano-2025-04-14":
+		return 200000
+	case "gpt-4o-2024-08-06", "gpt-4o-mini-2024-07-18":
+		return 128000
+	default:
+		return 128000
+	}
+}
+
+// Do sends a request to OpenAI using the Responses API.
+func (s *ResponsesService) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error) {
+	httpc := cmp.Or(s.HTTPC, http.DefaultClient)
+	model := cmp.Or(s.Model, DefaultModel)
+
+	// Start with system messages if provided
+	var allInput []responsesInputItem
+	if len(ir.System) > 0 {
+		sysItems := fromLLMSystemResponses(ir.System)
+		allInput = append(allInput, sysItems...)
+	}
+
+	// Add regular messages
+	for _, msg := range ir.Messages {
+		items := fromLLMMessageResponses(msg)
+		allInput = append(allInput, items...)
+	}
+
+	// Convert tools
+	var tools []responsesTool
+	for _, t := range ir.Tools {
+		tools = append(tools, fromLLMToolResponses(t))
+	}
+
+	// Create the request
+	req := responsesRequest{
+		Model:           model.ModelName,
+		Input:           allInput,
+		Tools:           tools,
+		MaxOutputTokens: cmp.Or(s.MaxTokens, DefaultMaxTokens),
+	}
+
+	// Add tool choice if specified
+	if ir.ToolChoice != nil {
+		req.ToolChoice = fromLLMToolChoice(ir.ToolChoice)
+	}
+
+	// Construct the full URL
+	baseURL := cmp.Or(s.ModelURL, model.URL, OpenAIURL)
+	fullURL := baseURL + "/responses"
+
+	// Marshal the request
+	reqJSON, err := json.Marshal(req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal request: %w", err)
+	}
+
+	// Dump request if enabled
+	if s.DumpLLM {
+		if reqJSONPretty, err := json.MarshalIndent(req, "", "  "); err == nil {
+			if err := llm.DumpToFile("request", fullURL, reqJSONPretty); err != nil {
+				slog.WarnContext(ctx, "failed to dump responses request to file", "error", err)
+			}
+		}
+	}
+
+	// Retry mechanism
+	backoff := []time.Duration{1 * time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second, 15 * time.Second}
+
+	// retry loop
+	var errs error // accumulated errors across all attempts
+	for attempts := 0; ; attempts++ {
+		if attempts > 10 {
+			return nil, fmt.Errorf("responses request failed after %d attempts (url=%s, model=%s): %w", attempts, fullURL, model.ModelName, errs)
+		}
+		if attempts > 0 {
+			sleep := backoff[min(attempts, len(backoff)-1)] + time.Duration(rand.Int64N(int64(time.Second)))
+			slog.WarnContext(ctx, "responses request sleep before retry", "sleep", sleep, "attempts", attempts)
+			time.Sleep(sleep)
+		}
+
+		// Create HTTP request
+		httpReq, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewReader(reqJSON))
+		if err != nil {
+			return nil, fmt.Errorf("failed to create request: %w", err)
+		}
+
+		httpReq.Header.Set("Content-Type", "application/json")
+		httpReq.Header.Set("Authorization", "Bearer "+s.APIKey)
+		if s.Org != "" {
+			httpReq.Header.Set("OpenAI-Organization", s.Org)
+		}
+
+		// Send request
+		httpResp, err := httpc.Do(httpReq)
+		if err != nil {
+			errs = errors.Join(errs, fmt.Errorf("attempt %d: %w", attempts+1, err))
+			continue
+		}
+		defer httpResp.Body.Close()
+
+		// Read response body
+		body, err := io.ReadAll(httpResp.Body)
+		if err != nil {
+			return nil, fmt.Errorf("failed to read response body: %w", err)
+		}
+
+		// Handle non-200 responses
+		if httpResp.StatusCode != http.StatusOK {
+			var apiErr responsesError
+			if jsonErr := json.Unmarshal(body, &struct {
+				Error *responsesError `json:"error"`
+			}{Error: &apiErr}); jsonErr == nil && apiErr.Message != "" {
+				// We have a structured error
+				switch {
+				case httpResp.StatusCode >= 500:
+					// Server error, retry
+					slog.WarnContext(ctx, "responses_request_failed", "error", apiErr.Message, "status_code", httpResp.StatusCode, "url", fullURL, "model", model.ModelName)
+					errs = errors.Join(errs, fmt.Errorf("status %d (url=%s, model=%s): %s", httpResp.StatusCode, fullURL, model.ModelName, apiErr.Message))
+					continue
+
+				case httpResp.StatusCode == 429:
+					// Rate limited, retry
+					slog.WarnContext(ctx, "responses_request_rate_limited", "error", apiErr.Message, "url", fullURL, "model", model.ModelName)
+					errs = errors.Join(errs, fmt.Errorf("status %d (rate limited, url=%s, model=%s): %s", httpResp.StatusCode, fullURL, model.ModelName, apiErr.Message))
+					continue
+
+				case httpResp.StatusCode >= 400 && httpResp.StatusCode < 500:
+					// Client error, probably unrecoverable
+					slog.WarnContext(ctx, "responses_request_failed", "error", apiErr.Message, "status_code", httpResp.StatusCode, "url", fullURL, "model", model.ModelName)
+					return nil, errors.Join(errs, fmt.Errorf("status %d (url=%s, model=%s): %s", httpResp.StatusCode, fullURL, model.ModelName, apiErr.Message))
+				}
+			}
+
+			// No structured error, use the raw body
+			slog.WarnContext(ctx, "responses_request_failed", "status_code", httpResp.StatusCode, "url", fullURL, "model", model.ModelName, "body", string(body))
+			return nil, fmt.Errorf("status %d (url=%s, model=%s): %s", httpResp.StatusCode, fullURL, model.ModelName, string(body))
+		}
+
+		// Parse successful response
+		var resp responsesResponse
+		if err := json.Unmarshal(body, &resp); err != nil {
+			return nil, fmt.Errorf("failed to unmarshal response: %w", err)
+		}
+
+		// Check for errors in the response
+		if resp.Error != nil {
+			return nil, fmt.Errorf("response contains error: %s", resp.Error.Message)
+		}
+
+		// Dump response if enabled
+		if s.DumpLLM {
+			if respJSON, err := json.MarshalIndent(resp, "", "  "); err == nil {
+				if err := llm.DumpToFile("response", "", respJSON); err != nil {
+					slog.WarnContext(ctx, "failed to dump responses response to file", "error", err)
+				}
+			}
+		}
+
+		return s.toLLMResponseFromResponses(&resp, httpResp.Header), nil
+	}
+}
+
+func (s *ResponsesService) UseSimplifiedPatch() bool {
+	return s.Model.UseSimplifiedPatch
+}
+
+// ConfigDetails returns configuration information for logging
+func (s *ResponsesService) ConfigDetails() map[string]string {
+	model := cmp.Or(s.Model, DefaultModel)
+	baseURL := cmp.Or(s.ModelURL, model.URL, OpenAIURL)
+	return map[string]string{
+		"base_url":        baseURL,
+		"model_name":      model.ModelName,
+		"full_url":        baseURL + "/responses",
+		"api_key_env":     model.APIKeyEnv,
+		"has_api_key_set": fmt.Sprintf("%v", s.APIKey != ""),
+	}
+}

llm/oai/oai_responses_test.go πŸ”—

@@ -0,0 +1,415 @@
+package oai
+
+import (
+	"context"
+	"encoding/json"
+	"os"
+	"testing"
+
+	"shelley.exe.dev/llm"
+)
+
+func TestResponsesServiceBasic(t *testing.T) {
+	// This is a basic compile-time test to ensure ResponsesService implements llm.Service
+	var _ llm.Service = (*ResponsesService)(nil)
+}
+
+func TestFromLLMMessageResponses(t *testing.T) {
+	tests := []struct {
+		name     string
+		msg      llm.Message
+		expected int // expected number of output items
+	}{
+		{
+			name: "simple user message",
+			msg: llm.Message{
+				Role: llm.MessageRoleUser,
+				Content: []llm.Content{
+					{Type: llm.ContentTypeText, Text: "Hello"},
+				},
+			},
+			expected: 1,
+		},
+		{
+			name: "assistant message with text",
+			msg: llm.Message{
+				Role: llm.MessageRoleAssistant,
+				Content: []llm.Content{
+					{Type: llm.ContentTypeText, Text: "Hi there"},
+				},
+			},
+			expected: 1,
+		},
+		{
+			name: "message with tool use",
+			msg: llm.Message{
+				Role: llm.MessageRoleAssistant,
+				Content: []llm.Content{
+					{
+						Type:      llm.ContentTypeToolUse,
+						ID:        "call_123",
+						ToolName:  "get_weather",
+						ToolInput: json.RawMessage(`{"location":"SF"}`),
+					},
+				},
+			},
+			expected: 1,
+		},
+		{
+			name: "message with tool result",
+			msg: llm.Message{
+				Role: llm.MessageRoleUser,
+				Content: []llm.Content{
+					{
+						Type:      llm.ContentTypeToolResult,
+						ToolUseID: "call_123",
+						ToolResult: []llm.Content{
+							{Type: llm.ContentTypeText, Text: "72 degrees"},
+						},
+					},
+				},
+			},
+			expected: 1,
+		},
+		{
+			name: "message with text and tool use",
+			msg: llm.Message{
+				Role: llm.MessageRoleAssistant,
+				Content: []llm.Content{
+					{Type: llm.ContentTypeText, Text: "Let me check"},
+					{
+						Type:      llm.ContentTypeToolUse,
+						ID:        "call_123",
+						ToolName:  "get_weather",
+						ToolInput: json.RawMessage(`{"location":"SF"}`),
+					},
+				},
+			},
+			expected: 2, // one message item, one function_call item
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			items := fromLLMMessageResponses(tt.msg)
+			if len(items) != tt.expected {
+				t.Errorf("expected %d items, got %d", tt.expected, len(items))
+			}
+
+			// Verify structure based on content type
+			for _, item := range items {
+				switch item.Type {
+				case "message":
+					if item.Role == "" {
+						t.Error("message item missing role")
+					}
+					if len(item.Content) == 0 {
+						t.Error("message item has no content")
+					}
+				case "function_call":
+					if item.CallID == "" {
+						t.Error("function_call item missing call_id")
+					}
+					if item.Name == "" {
+						t.Error("function_call item missing name")
+					}
+				case "function_call_output":
+					if item.CallID == "" {
+						t.Error("function_call_output item missing call_id")
+					}
+				}
+			}
+		})
+	}
+}
+
+func TestFromLLMToolResponses(t *testing.T) {
+	tool := &llm.Tool{
+		Name:        "test_tool",
+		Description: "A test tool",
+		InputSchema: llm.MustSchema(`{
+			"type": "object",
+			"properties": {
+				"param": {"type": "string"}
+			}
+		}`),
+	}
+
+	rtool := fromLLMToolResponses(tool)
+
+	if rtool.Type != "function" {
+		t.Errorf("expected type 'function', got %s", rtool.Type)
+	}
+	if rtool.Name != "test_tool" {
+		t.Errorf("expected name 'test_tool', got %s", rtool.Name)
+	}
+	if rtool.Description != "A test tool" {
+		t.Errorf("expected description 'A test tool', got %s", rtool.Description)
+	}
+	if len(rtool.Parameters) == 0 {
+		t.Error("expected parameters to be set")
+	}
+}
+
+func TestFromLLMSystemResponses(t *testing.T) {
+	tests := []struct {
+		name     string
+		system   []llm.SystemContent
+		expected int
+	}{
+		{
+			name:     "empty system",
+			system:   []llm.SystemContent{},
+			expected: 0,
+		},
+		{
+			name: "single system message",
+			system: []llm.SystemContent{
+				{Text: "You are a helpful assistant"},
+			},
+			expected: 1,
+		},
+		{
+			name: "multiple system messages",
+			system: []llm.SystemContent{
+				{Text: "You are a helpful assistant"},
+				{Text: "Be concise"},
+			},
+			expected: 1, // should be combined into one message
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			items := fromLLMSystemResponses(tt.system)
+			if len(items) != tt.expected {
+				t.Errorf("expected %d items, got %d", len(items), tt.expected)
+			}
+		})
+	}
+}
+
+func TestToLLMResponseFromResponses(t *testing.T) {
+	svc := &ResponsesService{}
+
+	tests := []struct {
+		name           string
+		resp           *responsesResponse
+		expectedReason llm.StopReason
+		contentCount   int
+	}{
+		{
+			name: "simple text response",
+			resp: &responsesResponse{
+				ID:    "resp_123",
+				Model: "gpt-5.1-codex",
+				Output: []responsesOutputItem{
+					{
+						Type: "message",
+						Role: "assistant",
+						Content: []responsesContent{
+							{Type: "output_text", Text: "Hello!"},
+						},
+					},
+				},
+			},
+			expectedReason: llm.StopReasonStopSequence,
+			contentCount:   1,
+		},
+		{
+			name: "response with function call",
+			resp: &responsesResponse{
+				ID:    "resp_123",
+				Model: "gpt-5.1-codex",
+				Output: []responsesOutputItem{
+					{
+						Type:      "function_call",
+						CallID:    "call_123",
+						Name:      "get_weather",
+						Arguments: `{"location":"SF"}`,
+					},
+				},
+			},
+			expectedReason: llm.StopReasonToolUse,
+			contentCount:   1,
+		},
+		{
+			name: "response with reasoning and message",
+			resp: &responsesResponse{
+				ID:    "resp_123",
+				Model: "gpt-5.1-codex",
+				Output: []responsesOutputItem{
+					{
+						Type:    "reasoning",
+						Summary: []string{"Let me think", "about this"},
+					},
+					{
+						Type: "message",
+						Role: "assistant",
+						Content: []responsesContent{
+							{Type: "output_text", Text: "Here's the answer"},
+						},
+					},
+				},
+			},
+			expectedReason: llm.StopReasonStopSequence,
+			contentCount:   2, // reasoning + text
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			llmResp := svc.toLLMResponseFromResponses(tt.resp, nil)
+
+			if llmResp.ID != tt.resp.ID {
+				t.Errorf("expected ID %s, got %s", tt.resp.ID, llmResp.ID)
+			}
+			if llmResp.Model != tt.resp.Model {
+				t.Errorf("expected model %s, got %s", tt.resp.Model, llmResp.Model)
+			}
+			if llmResp.StopReason != tt.expectedReason {
+				t.Errorf("expected stop reason %v, got %v", tt.expectedReason, llmResp.StopReason)
+			}
+			if len(llmResp.Content) != tt.contentCount {
+				t.Errorf("expected %d content items, got %d", tt.contentCount, len(llmResp.Content))
+			}
+		})
+	}
+}
+
+func TestResponsesServiceTokenContextWindow(t *testing.T) {
+	tests := []struct {
+		model    Model
+		expected int
+	}{
+		{model: GPT5Codex, expected: 256000},
+		{model: GPT41, expected: 200000},
+		{model: GPT4o, expected: 128000},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.model.UserName, func(t *testing.T) {
+			svc := &ResponsesService{Model: tt.model}
+			got := svc.TokenContextWindow()
+			if got != tt.expected {
+				t.Errorf("expected %d, got %d", tt.expected, got)
+			}
+		})
+	}
+}
+
+func TestResponsesServiceConfigDetails(t *testing.T) {
+	svc := &ResponsesService{
+		Model:  GPT5Codex,
+		APIKey: "test-key",
+	}
+
+	details := svc.ConfigDetails()
+
+	if details["model_name"] != "gpt-5.1-codex" {
+		t.Errorf("expected model_name 'gpt-5.1-codex', got %s", details["model_name"])
+	}
+	if details["full_url"] != "https://api.openai.com/v1/responses" {
+		t.Errorf("unexpected full_url: %s", details["full_url"])
+	}
+	if details["has_api_key_set"] != "true" {
+		t.Error("expected has_api_key_set to be true")
+	}
+}
+
+// TestResponsesServiceIntegration is a live test that requires OPENAI_API_KEY
+// Run with: go test -v -run TestResponsesServiceIntegration
+func TestResponsesServiceIntegration(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping integration test in short mode")
+	}
+
+	apiKey := os.Getenv(OpenAIAPIKeyEnv)
+	if apiKey == "" {
+		t.Skip("OPENAI_API_KEY not set, skipping integration test")
+	}
+
+	svc := &ResponsesService{
+		APIKey: apiKey,
+		Model:  GPT5Codex,
+	}
+
+	ctx := context.Background()
+
+	t.Run("simple request", func(t *testing.T) {
+		req := &llm.Request{
+			Messages: []llm.Message{
+				{
+					Role: llm.MessageRoleUser,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "Say 'hello' and nothing else"},
+					},
+				},
+			},
+		}
+
+		resp, err := svc.Do(ctx, req)
+		if err != nil {
+			t.Fatalf("request failed: %v", err)
+		}
+
+		if resp.ID == "" {
+			t.Error("expected response ID to be set")
+		}
+		if resp.Model != "gpt-5.1-codex" {
+			t.Errorf("expected model gpt-5.1-codex, got %s", resp.Model)
+		}
+		if len(resp.Content) == 0 {
+			t.Error("expected response to have content")
+		}
+	})
+
+	t.Run("request with tools", func(t *testing.T) {
+		req := &llm.Request{
+			Messages: []llm.Message{
+				{
+					Role: llm.MessageRoleUser,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "What's the weather in Paris?"},
+					},
+				},
+			},
+			Tools: []*llm.Tool{
+				{
+					Name:        "get_weather",
+					Description: "Get weather for a location",
+					InputSchema: llm.MustSchema(`{
+						"type": "object",
+						"properties": {
+							"location": {"type": "string"}
+						},
+						"required": ["location"]
+					}`),
+				},
+			},
+		}
+
+		resp, err := svc.Do(ctx, req)
+		if err != nil {
+			t.Fatalf("request failed: %v", err)
+		}
+
+		if resp.StopReason != llm.StopReasonToolUse {
+			t.Errorf("expected tool use stop reason, got %v", resp.StopReason)
+		}
+
+		// Find the tool use content
+		var foundToolUse bool
+		for _, c := range resp.Content {
+			if c.Type == llm.ContentTypeToolUse {
+				foundToolUse = true
+				if c.ToolName != "get_weather" {
+					t.Errorf("expected tool name get_weather, got %s", c.ToolName)
+				}
+			}
+		}
+		if !foundToolUse {
+			t.Error("expected to find tool use in response")
+		}
+	})
+}

llm/oai/oai_test.go πŸ”—

@@ -0,0 +1,103 @@
+package oai
+
+import "testing"
+
+func TestRequiresMaxCompletionTokens(t *testing.T) {
+	tests := []struct {
+		name     string
+		model    Model
+		expected bool
+	}{
+		{
+			name:     "GPT-5 requires max_completion_tokens",
+			model:    GPT5,
+			expected: true,
+		},
+		{
+			name:     "GPT-5 Mini requires max_completion_tokens",
+			model:    GPT5Mini,
+			expected: true,
+		},
+		{
+			name:     "O3 reasoning model requires max_completion_tokens",
+			model:    O3,
+			expected: true,
+		},
+		{
+			name:     "O4-mini reasoning model requires max_completion_tokens",
+			model:    O4Mini,
+			expected: true,
+		},
+		{
+			name:     "GPT-4.1 uses max_tokens",
+			model:    GPT41,
+			expected: false,
+		},
+		{
+			name:     "GPT-4o uses max_tokens",
+			model:    GPT4o,
+			expected: false,
+		},
+		{
+			name:     "GPT-4o Mini uses max_tokens",
+			model:    GPT4oMini,
+			expected: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := tt.model.requiresMaxCompletionTokens()
+			if result != tt.expected {
+				t.Errorf("requiresMaxCompletionTokens() = %v, expected %v", result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestRequestParameterGeneration(t *testing.T) {
+	// Test that we can generate the correct request structure without making API calls
+	tests := []struct {
+		name                      string
+		model                     Model
+		expectMaxTokens           bool
+		expectMaxCompletionTokens bool
+	}{
+		{
+			name:                      "GPT-5 uses max_completion_tokens",
+			model:                     GPT5,
+			expectMaxTokens:           false,
+			expectMaxCompletionTokens: true,
+		},
+		{
+			name:                      "GPT-5 Mini uses max_completion_tokens",
+			model:                     GPT5Mini,
+			expectMaxTokens:           false,
+			expectMaxCompletionTokens: true,
+		},
+		{
+			name:                      "GPT-4.1 uses max_tokens",
+			model:                     GPT41,
+			expectMaxTokens:           true,
+			expectMaxCompletionTokens: false,
+		},
+		{
+			name:                      "O3 uses max_completion_tokens",
+			model:                     O3,
+			expectMaxTokens:           false,
+			expectMaxCompletionTokens: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			usesMaxCompletionTokens := tt.model.requiresMaxCompletionTokens()
+			if tt.expectMaxCompletionTokens && !usesMaxCompletionTokens {
+				t.Errorf("Expected model %s to use max_completion_tokens, but it doesn't", tt.model.UserName)
+			}
+			if tt.expectMaxTokens && usesMaxCompletionTokens {
+				t.Errorf("Expected model %s to use max_tokens, but it uses max_completion_tokens", tt.model.UserName)
+			}
+		})
+	}
+}

llm/tool_content_test.go πŸ”—

@@ -0,0 +1,37 @@
+package llm
+
+import (
+	"testing"
+)
+
+func TestToolResultArray(t *testing.T) {
+	// Test a tool result with multiple content items
+	textContent := Content{
+		Type: ContentTypeText,
+		Text: "15 degrees",
+	}
+
+	imageContent := Content{
+		Type:      ContentTypeText, // In the future, this could be ContentTypeImage
+		Text:      "",
+		MediaType: "image/jpeg",
+		Data:      "/9j/4AAQSkZJRg...", // Base64 encoded image sample
+	}
+
+	toolResult := Content{
+		ToolResult: []Content{textContent, imageContent},
+	}
+
+	// Check the structure
+	if len(toolResult.ToolResult) != 2 {
+		t.Errorf("Expected 2 content items in ToolResult, got %d", len(toolResult.ToolResult))
+	}
+
+	if toolResult.ToolResult[0].Text != "15 degrees" {
+		t.Errorf("Expected first item text to be '15 degrees', got '%s'", toolResult.ToolResult[0].Text)
+	}
+
+	if toolResult.ToolResult[1].MediaType != "image/jpeg" {
+		t.Errorf("Expected second item media type to be 'image/jpeg', got '%s'", toolResult.ToolResult[1].MediaType)
+	}
+}

llm/usage_test.go πŸ”—

@@ -0,0 +1,100 @@
+package llm
+
+import "testing"
+
+func TestUsageTotalInputTokens(t *testing.T) {
+	tests := []struct {
+		name  string
+		usage Usage
+		want  uint64
+	}{
+		{
+			name: "all token types",
+			usage: Usage{
+				InputTokens:              100,
+				CacheCreationInputTokens: 50,
+				CacheReadInputTokens:     200,
+				OutputTokens:             30,
+			},
+			want: 350, // 100 + 50 + 200
+		},
+		{
+			name: "only input tokens",
+			usage: Usage{
+				InputTokens:  150,
+				OutputTokens: 50,
+			},
+			want: 150,
+		},
+		{
+			name: "heavy caching",
+			usage: Usage{
+				InputTokens:              10,
+				CacheCreationInputTokens: 0,
+				CacheReadInputTokens:     5000,
+				OutputTokens:             100,
+			},
+			want: 5010, // 10 + 0 + 5000
+		},
+		{
+			name:  "zero",
+			usage: Usage{},
+			want:  0,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := tt.usage.TotalInputTokens()
+			if got != tt.want {
+				t.Errorf("TotalInputTokens() = %d, want %d", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestUsageContextWindowUsed(t *testing.T) {
+	tests := []struct {
+		name  string
+		usage Usage
+		want  uint64
+	}{
+		{
+			name: "all token types",
+			usage: Usage{
+				InputTokens:              100,
+				CacheCreationInputTokens: 50,
+				CacheReadInputTokens:     200,
+				OutputTokens:             30,
+			},
+			want: 380, // 100 + 50 + 200 + 30
+		},
+		{
+			name: "only input and output",
+			usage: Usage{
+				InputTokens:  150,
+				OutputTokens: 50,
+			},
+			want: 200,
+		},
+		{
+			name: "heavy caching with output",
+			usage: Usage{
+				InputTokens:              10,
+				CacheCreationInputTokens: 0,
+				CacheReadInputTokens:     5000,
+				OutputTokens:             100,
+			},
+			want: 5110, // 10 + 0 + 5000 + 100
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := tt.usage.ContextWindowUsed()
+			if got != tt.want {
+				t.Errorf("ContextWindowUsed() = %d, want %d", got, tt.want)
+			}
+		})
+	}
+}

loop/README.md πŸ”—

@@ -0,0 +1,71 @@
+# Loop Package
+
+The `loop` package provides the core agentic conversation loop for Shelley,
+handling LLM interactions, tool execution, and message recording.
+
+## Features
+
+- **LLM Integration**: Works with any LLM service implementing the `llm.Service` interface
+- **Predictable Testing**: Includes a `PredictableService` for deterministic testing
+- **Tool Execution**: Automatically executes tools called by the LLM
+- **Message Recording**: Records all conversation messages via a configurable function
+- **Usage Tracking**: Tracks token usage and costs across all LLM calls
+- **Context Cancellation**: Gracefully handles context cancellation
+- **Thread Safety**: All methods are safe for concurrent use
+
+## Basic Usage
+
+```go
+// Create tools (using claudetool package or custom tools)
+tools := []*llm.Tool{bashTool, patchTool, thinkTool}
+
+// Define message recording function (typically saves to the database)
+recordMessage := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+    return messageService.Create(ctx, db.CreateMessageParams{
+        ConversationID: conversationID,
+        Type:            getMessageType(message.Role),
+        LLMData:         message,
+        UsageData:       usage,
+    })
+}
+
+// Create loop with explicit LLM configuration
+agentLoop := loop.NewLoop(loop.Config{
+    LLM:           &ant.Service{APIKey: apiKey},
+    History:       history, // existing conversation history
+    Tools:         tools,
+    RecordMessage: recordMessage,
+    Logger:        logger,
+    System:        systemPrompt, // []llm.SystemContent
+})
+
+// Queue user messages for the current turn
+agentLoop.QueueUserMessage(llm.UserStringMessage("Hello, please help me with something"))
+
+// Run the conversation turn
+ctx := context.Background()
+if err := agentLoop.ProcessOneTurn(ctx); err != nil {
+    log.Fatalf("conversation failed: %v", err)
+}
+```
+
+## Testing with PredictableService
+
+The `PredictableService` records requests and returns deterministic responses that are convenient for tests:
+
+```go
+service := loop.NewPredictableService()
+
+testLoop := loop.NewLoop(loop.Config{
+    LLM:           service,
+    RecordMessage: func(context.Context, llm.Message, llm.Usage) error { return nil },
+})
+
+testLoop.QueueUserMessage(llm.UserStringMessage("hello"))
+if err := testLoop.ProcessOneTurn(context.Background()); err != nil {
+    t.Fatalf("loop failed: %v", err)
+}
+
+last := service.GetLastRequest()
+require.NotNil(t, last)
+```

loop/claude_integration_test.go πŸ”—

@@ -0,0 +1,69 @@
+package loop
+
+import (
+	"context"
+	"os"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/llm/ant"
+)
+
+// TestLoopWithClaude tests the loop with actual Claude API if key is available
+func TestLoopWithClaude(t *testing.T) {
+	apiKey := os.Getenv("ANTHROPIC_API_KEY")
+	if apiKey == "" {
+		t.Skip("Skipping Claude integration test - ANTHROPIC_API_KEY not set")
+	}
+
+	// Create a simple conversation with Claude service
+	loop := NewLoop(Config{
+		LLM: &ant.Service{
+			APIKey: apiKey,
+			Model:  ant.Claude45Haiku, // Use cheaper model for testing
+		},
+		History: []llm.Message{},
+		Tools:   []*llm.Tool{},
+		RecordMessage: func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+			// In a real app, this would save to database
+			t.Logf("Recorded %s message: %s", message.Role, message.Content[0].Text)
+			return nil
+		},
+	})
+
+	// Queue a simple user message
+	loop.QueueUserMessage(llm.UserStringMessage("Hello! Please respond with just 'Hi there!' and nothing else."))
+
+	// Run with a reasonable timeout
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	err := loop.Go(ctx)
+	if err != context.DeadlineExceeded {
+		t.Errorf("expected context deadline exceeded, got %v", err)
+	}
+
+	// Check that usage was tracked
+	usage := loop.GetUsage()
+	if usage.IsZero() {
+		t.Error("expected non-zero usage from Claude API")
+	}
+
+	t.Logf("Claude API usage: %s", usage.String())
+
+	// Check conversation history
+	history := loop.GetHistory()
+	if len(history) < 2 {
+		t.Errorf("expected at least 2 messages in history, got %d", len(history))
+	}
+
+	// First should be user message, second should be assistant
+	if history[0].Role != llm.MessageRoleUser {
+		t.Errorf("first message should be user, got %v", history[0].Role)
+	}
+
+	if len(history) > 1 && history[1].Role != llm.MessageRoleAssistant {
+		t.Errorf("second message should be assistant, got %v", history[1].Role)
+	}
+}

loop/example_test.go πŸ”—

@@ -0,0 +1,80 @@
+package loop_test
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/loop"
+)
+
+func ExampleLoop() {
+	// Create a simple tool
+	testTool := &llm.Tool{
+		Name:        "greet",
+		Description: "Greets the user with a friendly message",
+		InputSchema: llm.MustSchema(`{"type": "object", "properties": {"name": {"type": "string"}}}`),
+		Run: func(ctx context.Context, input json.RawMessage) llm.ToolOut {
+			var req struct {
+				Name string `json:"name"`
+			}
+			if err := json.Unmarshal(input, &req); err != nil {
+				return llm.ErrorToolOut(err)
+			}
+			return llm.ToolOut{
+				LLMContent: llm.TextContent(fmt.Sprintf("Hello, %s! Nice to meet you.", req.Name)),
+			}
+		},
+	}
+
+	// Message recording function (in real usage, this would save to database)
+	recordMessage := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+		roleStr := "user"
+		if message.Role == llm.MessageRoleAssistant {
+			roleStr = "assistant"
+		}
+		fmt.Printf("Recorded %s message with %d content items\n", roleStr, len(message.Content))
+		return nil
+	}
+
+	// Create a loop with initial history
+	initialHistory := []llm.Message{
+		{
+			Role: llm.MessageRoleUser,
+			Content: []llm.Content{
+				{Type: llm.ContentTypeText, Text: "Hello, I'm Alice"},
+			},
+		},
+	}
+
+	// Set up a predictable service for this example
+	service := loop.NewPredictableService()
+	myLoop := loop.NewLoop(loop.Config{
+		LLM:           service,
+		History:       initialHistory,
+		Tools:         []*llm.Tool{testTool},
+		RecordMessage: recordMessage,
+	})
+
+	// Queue a user message that triggers a simple response
+	myLoop.QueueUserMessage(llm.Message{
+		Role:    llm.MessageRoleUser,
+		Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}},
+	})
+
+	// Run the loop for a short time
+	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+	defer cancel()
+
+	myLoop.Go(ctx)
+
+	// Check usage
+	usage := myLoop.GetUsage()
+	fmt.Printf("Total usage: %s\n", usage.String())
+
+	// Output:
+	// Recorded assistant message with 1 content items
+	// Total usage: in: 31, out: 3
+}

loop/integration_test.go πŸ”—

@@ -0,0 +1,132 @@
+package loop
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/llm"
+)
+
+func TestLoopWithClaudeTools(t *testing.T) {
+	var recordedMessages []llm.Message
+
+	recordFunc := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+		recordedMessages = append(recordedMessages, message)
+		return nil
+	}
+
+	// Use some actual claudetools
+	tools := []*llm.Tool{
+		// TODO: Add actual tools when needed
+	}
+
+	service := NewPredictableService()
+
+	// Create loop with the configured service
+	loop := NewLoop(Config{
+		LLM:           service,
+		History:       []llm.Message{},
+		Tools:         tools,
+		RecordMessage: recordFunc,
+	})
+
+	// Queue a user message that will trigger a specific predictable response
+	userMessage := llm.Message{
+		Role:    llm.MessageRoleUser,
+		Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}},
+	}
+	loop.QueueUserMessage(userMessage)
+
+	// Run the loop with a short timeout
+	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+	defer cancel()
+
+	err := loop.Go(ctx)
+	if err != context.DeadlineExceeded {
+		t.Errorf("expected context deadline exceeded, got %v", err)
+	}
+
+	// Verify that messages were recorded
+	// Note: User messages are recorded by ConversationManager, not by Loop,
+	// so we only expect assistant messages to be recorded here
+	if len(recordedMessages) < 1 {
+		t.Errorf("expected at least 1 recorded message (assistant), got %d", len(recordedMessages))
+	}
+
+	// Check that usage was accumulated
+	usage := loop.GetUsage()
+	if usage.IsZero() {
+		t.Error("expected non-zero usage")
+	}
+
+	// Verify conversation history includes user and assistant messages
+	history := loop.GetHistory()
+	if len(history) < 2 {
+		t.Errorf("expected at least 2 history messages, got %d", len(history))
+	}
+
+	// Check for expected response
+	found := false
+	for _, msg := range history {
+		if msg.Role == llm.MessageRoleAssistant {
+			for _, content := range msg.Content {
+				if content.Type == llm.ContentTypeText && content.Text == "Well, hi there!" {
+					found = true
+					break
+				}
+			}
+		}
+	}
+	if !found {
+		t.Error("expected to find 'Well, hi there!' response")
+	}
+}
+
+func TestLoopContextCancellation(t *testing.T) {
+	service := NewPredictableService()
+	loop := NewLoop(Config{
+		LLM:     service,
+		History: []llm.Message{},
+		Tools:   []*llm.Tool{},
+		RecordMessage: func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+			return nil
+		},
+	})
+
+	// Cancel context immediately
+	ctx, cancel := context.WithCancel(context.Background())
+	cancel()
+
+	err := loop.Go(ctx)
+	if err != context.Canceled {
+		t.Errorf("expected context canceled, got %v", err)
+	}
+}
+
+func TestLoopSystemMessages(t *testing.T) {
+	// Set system messages
+	system := []llm.SystemContent{
+		{Text: "You are a helpful assistant.", Type: "text"},
+	}
+
+	loop := NewLoop(Config{
+		LLM:     NewPredictableService(),
+		History: []llm.Message{},
+		Tools:   []*llm.Tool{},
+		System:  system,
+		RecordMessage: func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+			return nil
+		},
+	})
+
+	// The system messages are stored and would be passed to LLM
+	loop.mu.Lock()
+	if len(loop.system) != 1 {
+		t.Errorf("expected 1 system message, got %d", len(loop.system))
+	}
+	if loop.system[0].Text != "You are a helpful assistant." {
+		t.Errorf("unexpected system message text: %s", loop.system[0].Text)
+	}
+	loop.mu.Unlock()
+}

loop/loop.go πŸ”—

@@ -0,0 +1,509 @@
+package loop
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"sync"
+	"time"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/llm"
+)
+
+// MessageRecordFunc is called to record new messages to persistent storage
+type MessageRecordFunc func(ctx context.Context, message llm.Message, usage llm.Usage) error
+
+// Config contains all configuration needed to create a Loop
+type Config struct {
+	LLM           llm.Service
+	History       []llm.Message
+	Tools         []*llm.Tool
+	RecordMessage MessageRecordFunc
+	Logger        *slog.Logger
+	System        []llm.SystemContent
+	WorkingDir    string // working directory for tools
+}
+
+// Loop manages a conversation turn with an LLM including tool execution and message recording.
+// Notably, when the turn ends, the "Loop" is over. TODO: maybe rename to Turn?
+type Loop struct {
+	llm           llm.Service
+	tools         []*llm.Tool
+	recordMessage MessageRecordFunc
+	history       []llm.Message
+	messageQueue  []llm.Message
+	totalUsage    llm.Usage
+	mu            sync.Mutex
+	logger        *slog.Logger
+	system        []llm.SystemContent
+	workingDir    string
+}
+
+// NewLoop creates a new Loop instance with the provided configuration
+func NewLoop(config Config) *Loop {
+	logger := config.Logger
+	if logger == nil {
+		logger = slog.Default()
+	}
+
+	return &Loop{
+		llm:           config.LLM,
+		history:       config.History,
+		tools:         config.Tools,
+		recordMessage: config.RecordMessage,
+		messageQueue:  make([]llm.Message, 0),
+		logger:        logger,
+		system:        config.System,
+		workingDir:    config.WorkingDir,
+	}
+}
+
+// QueueUserMessage adds a user message to the queue to be processed
+func (l *Loop) QueueUserMessage(message llm.Message) {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	l.messageQueue = append(l.messageQueue, message)
+	l.logger.Debug("queued user message", "content_count", len(message.Content))
+}
+
+// GetUsage returns the total usage accumulated by this loop
+func (l *Loop) GetUsage() llm.Usage {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	return l.totalUsage
+}
+
+// GetHistory returns a copy of the current conversation history
+func (l *Loop) GetHistory() []llm.Message {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	// Deep copy the messages to prevent modifications
+	historyCopy := make([]llm.Message, len(l.history))
+	for i, msg := range l.history {
+		// Copy the message
+		historyCopy[i] = llm.Message{
+			Role:    msg.Role,
+			ToolUse: msg.ToolUse, // This is a pointer, but we won't modify it in tests
+			Content: make([]llm.Content, len(msg.Content)),
+		}
+		// Copy content slice
+		copy(historyCopy[i].Content, msg.Content)
+	}
+	return historyCopy
+}
+
+// Go runs the conversation loop until the context is canceled
+func (l *Loop) Go(ctx context.Context) error {
+	if l.llm == nil {
+		return fmt.Errorf("no LLM service configured")
+	}
+
+	l.logger.Info("starting conversation loop", "tools", len(l.tools))
+
+	for {
+		select {
+		case <-ctx.Done():
+			l.logger.Info("conversation loop canceled")
+			return ctx.Err()
+		default:
+		}
+
+		// Process any queued messages
+		l.mu.Lock()
+		hasQueuedMessages := len(l.messageQueue) > 0
+		if hasQueuedMessages {
+			// Add queued messages to history (they are already recorded to DB by ConversationManager)
+			for _, msg := range l.messageQueue {
+				l.history = append(l.history, msg)
+			}
+			l.messageQueue = l.messageQueue[:0] // Clear queue
+		}
+		l.mu.Unlock()
+
+		if hasQueuedMessages {
+			// Send request to LLM
+			l.logger.Debug("processing queued messages", "count", 1)
+			if err := l.processLLMRequest(ctx); err != nil {
+				l.logger.Error("failed to process LLM request", "error", err)
+				time.Sleep(time.Second) // Wait before retrying
+				continue
+			}
+			l.logger.Debug("finished processing queued messages")
+		} else {
+			// No queued messages, wait a bit
+			select {
+			case <-ctx.Done():
+				return ctx.Err()
+			case <-time.After(100 * time.Millisecond):
+				// Continue loop
+			}
+		}
+	}
+}
+
+// ProcessOneTurn processes queued messages through one complete turn (user message + assistant response)
+// It stops after the assistant responds, regardless of whether tools were called
+func (l *Loop) ProcessOneTurn(ctx context.Context) error {
+	if l.llm == nil {
+		return fmt.Errorf("no LLM service configured")
+	}
+
+	// Process any queued messages first
+	l.mu.Lock()
+	if len(l.messageQueue) > 0 {
+		// Add queued messages to history (they are already recorded to DB by ConversationManager)
+		for _, msg := range l.messageQueue {
+			l.history = append(l.history, msg)
+		}
+		l.messageQueue = nil
+	}
+	l.mu.Unlock()
+
+	// Process one LLM request and response
+	return l.processLLMRequest(ctx)
+}
+
+// processLLMRequest sends a request to the LLM and handles the response
+func (l *Loop) processLLMRequest(ctx context.Context) error {
+	l.mu.Lock()
+	messages := append([]llm.Message(nil), l.history...)
+	tools := l.tools
+	system := l.system
+	llmService := l.llm
+	l.mu.Unlock()
+
+	// Enable prompt caching: set cache flag on last tool and last user message content
+	// See https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
+	if len(tools) > 0 {
+		// Make a copy of tools to avoid modifying the shared slice
+		tools = append([]*llm.Tool(nil), tools...)
+		// Copy the last tool and enable caching
+		lastTool := *tools[len(tools)-1]
+		lastTool.Cache = true
+		tools[len(tools)-1] = &lastTool
+	}
+
+	// Set cache flag on the last content block of the last user message
+	if len(messages) > 0 {
+		for i := len(messages) - 1; i >= 0; i-- {
+			if messages[i].Role == llm.MessageRoleUser && len(messages[i].Content) > 0 {
+				// Deep copy the message to avoid modifying the shared history
+				msg := messages[i]
+				msg.Content = append([]llm.Content(nil), msg.Content...)
+				msg.Content[len(msg.Content)-1].Cache = true
+				messages[i] = msg
+				break
+			}
+		}
+	}
+
+	req := &llm.Request{
+		Messages: messages,
+		Tools:    tools,
+		System:   system,
+	}
+
+	// Insert missing tool results if the previous message had tool_use blocks
+	// without corresponding tool_result blocks. This can happen when a request
+	// is cancelled or fails after the LLM responds but before tools execute.
+	l.insertMissingToolResults(req)
+
+	systemLen := 0
+	for _, sys := range system {
+		systemLen += len(sys.Text)
+	}
+	l.logger.Debug("sending LLM request", "message_count", len(messages), "tool_count", len(tools), "system_items", len(system), "system_length", systemLen)
+
+	// Add a timeout for the LLM request to prevent indefinite hangs
+	llmCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
+	defer cancel()
+
+	resp, err := llmService.Do(llmCtx, req)
+	if err != nil {
+		// Record the error as a message so it can be displayed in the UI
+		errorMessage := llm.Message{
+			Role: llm.MessageRoleAssistant,
+			Content: []llm.Content{
+				{
+					Type: llm.ContentTypeText,
+					Text: fmt.Sprintf("LLM request failed: %v", err),
+				},
+			},
+		}
+		if recordErr := l.recordMessage(ctx, errorMessage, llm.Usage{}); recordErr != nil {
+			l.logger.Error("failed to record error message", "error", recordErr)
+		}
+		return fmt.Errorf("LLM request failed: %w", err)
+	}
+
+	l.logger.Debug("received LLM response", "content_count", len(resp.Content), "stop_reason", resp.StopReason.String(), "usage", resp.Usage.String())
+
+	// Update total usage
+	l.mu.Lock()
+	l.totalUsage.Add(resp.Usage)
+	l.mu.Unlock()
+
+	// Convert response to message and add to history
+	assistantMessage := resp.ToMessage()
+	l.mu.Lock()
+	l.history = append(l.history, assistantMessage)
+	l.mu.Unlock()
+
+	// Record assistant message with model and timing metadata
+	usageWithMeta := resp.Usage
+	usageWithMeta.Model = resp.Model
+	usageWithMeta.StartTime = resp.StartTime
+	usageWithMeta.EndTime = resp.EndTime
+	if err := l.recordMessage(ctx, assistantMessage, usageWithMeta); err != nil {
+		l.logger.Error("failed to record assistant message", "error", err)
+	}
+
+	// Handle tool calls if any
+	if resp.StopReason == llm.StopReasonToolUse {
+		l.logger.Debug("handling tool calls", "content_count", len(resp.Content))
+		return l.handleToolCalls(ctx, resp.Content)
+	}
+
+	return nil
+}
+
+// handleToolCalls processes tool calls from the LLM response
+func (l *Loop) handleToolCalls(ctx context.Context, content []llm.Content) error {
+	var toolResults []llm.Content
+
+	for _, c := range content {
+		if c.Type != llm.ContentTypeToolUse {
+			continue
+		}
+
+		l.logger.Debug("executing tool", "name", c.ToolName, "id", c.ID)
+
+		// Find the tool
+		var tool *llm.Tool
+		for _, t := range l.tools {
+			if t.Name == c.ToolName {
+				tool = t
+				break
+			}
+		}
+
+		if tool == nil {
+			l.logger.Error("tool not found", "name", c.ToolName)
+			toolResults = append(toolResults, llm.Content{
+				Type:      llm.ContentTypeToolResult,
+				ToolUseID: c.ID,
+				ToolError: true,
+				ToolResult: []llm.Content{
+					{Type: llm.ContentTypeText, Text: fmt.Sprintf("Tool '%s' not found", c.ToolName)},
+				},
+			})
+			continue
+		}
+
+		// Execute the tool with working directory set in context
+		toolCtx := ctx
+		if l.workingDir != "" {
+			toolCtx = claudetool.WithWorkingDir(ctx, l.workingDir)
+		}
+		startTime := time.Now()
+		result := tool.Run(toolCtx, c.ToolInput)
+		endTime := time.Now()
+
+		var toolResultContent []llm.Content
+		if result.Error != nil {
+			l.logger.Error("tool execution failed", "name", c.ToolName, "error", result.Error)
+			toolResultContent = []llm.Content{
+				{Type: llm.ContentTypeText, Text: result.Error.Error()},
+			}
+		} else {
+			toolResultContent = result.LLMContent
+			l.logger.Debug("tool executed successfully", "name", c.ToolName, "duration", endTime.Sub(startTime))
+		}
+
+		toolResults = append(toolResults, llm.Content{
+			Type:             llm.ContentTypeToolResult,
+			ToolUseID:        c.ID,
+			ToolError:        result.Error != nil,
+			ToolResult:       toolResultContent,
+			ToolUseStartTime: &startTime,
+			ToolUseEndTime:   &endTime,
+			Display:          result.Display,
+		})
+	}
+
+	if len(toolResults) > 0 {
+		// Add tool results to history as a user message
+		toolMessage := llm.Message{
+			Role:    llm.MessageRoleUser,
+			Content: toolResults,
+		}
+
+		l.mu.Lock()
+		l.history = append(l.history, toolMessage)
+		l.mu.Unlock()
+
+		// Record tool result message
+		if err := l.recordMessage(ctx, toolMessage, llm.Usage{}); err != nil {
+			l.logger.Error("failed to record tool result message", "error", err)
+		}
+
+		// Process another LLM request with the tool results
+		return l.processLLMRequest(ctx)
+	}
+
+	return nil
+}
+
+// insertMissingToolResults fixes tool_result issues in the conversation history:
+//  1. Adds error results for tool_uses that were requested but not included in the next message.
+//     This can happen when a request is cancelled or fails after the LLM responds with tool_use
+//     blocks but before the tools execute.
+//  2. Removes orphan tool_results that reference tool_use IDs not present in the immediately
+//     preceding assistant message. This can happen when a tool execution completes after
+//     CancelConversation has already written cancellation messages.
+//
+// This prevents API errors like:
+//   - "tool_use ids were found without tool_result blocks"
+//   - "unexpected tool_use_id found in tool_result blocks ... Each tool_result block must have
+//     a corresponding tool_use block in the previous message"
+//
+// Mutates the request's Messages slice.
+func (l *Loop) insertMissingToolResults(req *llm.Request) {
+	if len(req.Messages) < 1 {
+		return
+	}
+
+	// Scan through all messages looking for assistant messages with tool_use
+	// that are not immediately followed by a user message with corresponding tool_results.
+	// We may need to insert synthetic user messages with tool_results or filter orphans.
+	var newMessages []llm.Message
+	totalInserted := 0
+	totalRemoved := 0
+
+	// Track the tool_use IDs from the most recent assistant message
+	var prevAssistantToolUseIDs map[string]bool
+
+	for i := 0; i < len(req.Messages); i++ {
+		msg := req.Messages[i]
+
+		if msg.Role == llm.MessageRoleAssistant {
+			// Track all tool_use IDs in this assistant message
+			prevAssistantToolUseIDs = make(map[string]bool)
+			for _, c := range msg.Content {
+				if c.Type == llm.ContentTypeToolUse {
+					prevAssistantToolUseIDs[c.ID] = true
+				}
+			}
+			newMessages = append(newMessages, msg)
+
+			// Check if next message needs synthetic tool_results
+			var toolUseContents []llm.Content
+			for _, c := range msg.Content {
+				if c.Type == llm.ContentTypeToolUse {
+					toolUseContents = append(toolUseContents, c)
+				}
+			}
+
+			if len(toolUseContents) == 0 {
+				continue
+			}
+
+			// Check if next message is a user message with corresponding tool_results
+			var nextMsg *llm.Message
+			if i+1 < len(req.Messages) {
+				nextMsg = &req.Messages[i+1]
+			}
+
+			if nextMsg == nil || nextMsg.Role != llm.MessageRoleUser {
+				// Next message is not a user message (or there is no next message).
+				// Insert a synthetic user message with tool_results for all tool_uses.
+				var toolResultContent []llm.Content
+				for _, tu := range toolUseContents {
+					toolResultContent = append(toolResultContent, llm.Content{
+						Type:      llm.ContentTypeToolResult,
+						ToolUseID: tu.ID,
+						ToolError: true,
+						ToolResult: []llm.Content{{
+							Type: llm.ContentTypeText,
+							Text: "not executed; retry possible",
+						}},
+					})
+				}
+				syntheticMsg := llm.Message{
+					Role:    llm.MessageRoleUser,
+					Content: toolResultContent,
+				}
+				newMessages = append(newMessages, syntheticMsg)
+				totalInserted += len(toolResultContent)
+			}
+		} else if msg.Role == llm.MessageRoleUser {
+			// Filter out orphan tool_results and add missing ones
+			var filteredContent []llm.Content
+			existingResultIDs := make(map[string]bool)
+
+			for _, c := range msg.Content {
+				if c.Type == llm.ContentTypeToolResult {
+					// Only keep tool_results that match a tool_use in the previous assistant message
+					if prevAssistantToolUseIDs != nil && prevAssistantToolUseIDs[c.ToolUseID] {
+						filteredContent = append(filteredContent, c)
+						existingResultIDs[c.ToolUseID] = true
+					} else {
+						// Orphan tool_result - skip it
+						totalRemoved++
+						l.logger.Debug("removing orphan tool_result", "tool_use_id", c.ToolUseID)
+					}
+				} else {
+					// Keep non-tool_result content
+					filteredContent = append(filteredContent, c)
+				}
+			}
+
+			// Check if we need to add missing tool_results for this user message
+			if prevAssistantToolUseIDs != nil {
+				var prefix []llm.Content
+				for toolUseID := range prevAssistantToolUseIDs {
+					if !existingResultIDs[toolUseID] {
+						prefix = append(prefix, llm.Content{
+							Type:      llm.ContentTypeToolResult,
+							ToolUseID: toolUseID,
+							ToolError: true,
+							ToolResult: []llm.Content{{
+								Type: llm.ContentTypeText,
+								Text: "not executed; retry possible",
+							}},
+						})
+						totalInserted++
+					}
+				}
+				if len(prefix) > 0 {
+					filteredContent = append(prefix, filteredContent...)
+				}
+			}
+
+			// Only add the message if it has content
+			if len(filteredContent) > 0 {
+				msg.Content = filteredContent
+				newMessages = append(newMessages, msg)
+			} else {
+				// Message is now empty after filtering - skip it entirely
+				l.logger.Debug("removing empty user message after filtering orphan tool_results")
+			}
+
+			// Reset for next iteration - user message "consumes" the previous tool_uses
+			prevAssistantToolUseIDs = nil
+		} else {
+			newMessages = append(newMessages, msg)
+		}
+	}
+
+	if totalInserted > 0 || totalRemoved > 0 {
+		req.Messages = newMessages
+		if totalInserted > 0 {
+			l.logger.Debug("inserted missing tool results", "count", totalInserted)
+		}
+		if totalRemoved > 0 {
+			l.logger.Debug("removed orphan tool results", "count", totalRemoved)
+		}
+	}
+}

loop/loop_test.go πŸ”—

@@ -0,0 +1,843 @@
+package loop
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/llm"
+)
+
+func TestNewLoop(t *testing.T) {
+	history := []llm.Message{
+		{Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Hello"}}},
+	}
+	tools := []*llm.Tool{}
+	recordFunc := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+		return nil
+	}
+
+	loop := NewLoop(Config{
+		LLM:           NewPredictableService(),
+		History:       history,
+		Tools:         tools,
+		RecordMessage: recordFunc,
+	})
+	if loop == nil {
+		t.Fatal("NewLoop returned nil")
+	}
+
+	if len(loop.history) != 1 {
+		t.Errorf("expected history length 1, got %d", len(loop.history))
+	}
+
+	if len(loop.messageQueue) != 0 {
+		t.Errorf("expected empty message queue, got %d", len(loop.messageQueue))
+	}
+}
+
+func TestQueueUserMessage(t *testing.T) {
+	loop := NewLoop(Config{
+		LLM:     NewPredictableService(),
+		History: []llm.Message{},
+		Tools:   []*llm.Tool{},
+	})
+
+	message := llm.Message{
+		Role:    llm.MessageRoleUser,
+		Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Test message"}},
+	}
+
+	loop.QueueUserMessage(message)
+
+	loop.mu.Lock()
+	queueLen := len(loop.messageQueue)
+	loop.mu.Unlock()
+
+	if queueLen != 1 {
+		t.Errorf("expected message queue length 1, got %d", queueLen)
+	}
+}
+
+func TestPredictableService(t *testing.T) {
+	service := NewPredictableService()
+
+	// Test simple hello response
+	ctx := context.Background()
+	req := &llm.Request{
+		Messages: []llm.Message{
+			{Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}}},
+		},
+	}
+
+	resp, err := service.Do(ctx, req)
+	if err != nil {
+		t.Fatalf("predictable service Do failed: %v", err)
+	}
+
+	if resp.Role != llm.MessageRoleAssistant {
+		t.Errorf("expected assistant role, got %v", resp.Role)
+	}
+
+	if len(resp.Content) == 0 {
+		t.Error("expected non-empty content")
+	}
+
+	if resp.Content[0].Type != llm.ContentTypeText {
+		t.Errorf("expected text content, got %v", resp.Content[0].Type)
+	}
+
+	if resp.Content[0].Text != "Well, hi there!" {
+		t.Errorf("unexpected response text: %s", resp.Content[0].Text)
+	}
+}
+
+func TestPredictableServiceEcho(t *testing.T) {
+	service := NewPredictableService()
+
+	ctx := context.Background()
+	req := &llm.Request{
+		Messages: []llm.Message{
+			{Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "echo: foo"}}},
+		},
+	}
+
+	resp, err := service.Do(ctx, req)
+	if err != nil {
+		t.Fatalf("echo test failed: %v", err)
+	}
+
+	if resp.Content[0].Text != "foo" {
+		t.Errorf("expected 'foo', got '%s'", resp.Content[0].Text)
+	}
+
+	// Test another echo
+	req.Messages[0].Content[0].Text = "echo: hello world"
+	resp, err = service.Do(ctx, req)
+	if err != nil {
+		t.Fatalf("echo hello world test failed: %v", err)
+	}
+
+	if resp.Content[0].Text != "hello world" {
+		t.Errorf("expected 'hello world', got '%s'", resp.Content[0].Text)
+	}
+}
+
+func TestPredictableServiceBashTool(t *testing.T) {
+	service := NewPredictableService()
+
+	ctx := context.Background()
+	req := &llm.Request{
+		Messages: []llm.Message{
+			{Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "bash: ls -la"}}},
+		},
+	}
+
+	resp, err := service.Do(ctx, req)
+	if err != nil {
+		t.Fatalf("bash tool test failed: %v", err)
+	}
+
+	if resp.StopReason != llm.StopReasonToolUse {
+		t.Errorf("expected tool use stop reason, got %v", resp.StopReason)
+	}
+
+	if len(resp.Content) != 2 {
+		t.Errorf("expected 2 content items (text + tool_use), got %d", len(resp.Content))
+	}
+
+	// Find the tool use content
+	var toolUseContent *llm.Content
+	for _, content := range resp.Content {
+		if content.Type == llm.ContentTypeToolUse {
+			toolUseContent = &content
+			break
+		}
+	}
+
+	if toolUseContent == nil {
+		t.Fatal("no tool use content found")
+	}
+
+	if toolUseContent.ToolName != "bash" {
+		t.Errorf("expected tool name 'bash', got '%s'", toolUseContent.ToolName)
+	}
+
+	// Check tool input contains the command
+	var toolInput map[string]interface{}
+	if err := json.Unmarshal(toolUseContent.ToolInput, &toolInput); err != nil {
+		t.Fatalf("failed to parse tool input: %v", err)
+	}
+
+	if toolInput["command"] != "ls -la" {
+		t.Errorf("expected command 'ls -la', got '%v'", toolInput["command"])
+	}
+}
+
+func TestPredictableServiceDefaultResponse(t *testing.T) {
+	service := NewPredictableService()
+
+	ctx := context.Background()
+	req := &llm.Request{
+		Messages: []llm.Message{
+			{Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "some unknown input"}}},
+		},
+	}
+
+	resp, err := service.Do(ctx, req)
+	if err != nil {
+		t.Fatalf("default response test failed: %v", err)
+	}
+
+	if resp.Content[0].Text != "edit predictable.go to add a response for that one..." {
+		t.Errorf("unexpected default response: %s", resp.Content[0].Text)
+	}
+}
+
+func TestPredictableServiceDelay(t *testing.T) {
+	service := NewPredictableService()
+
+	ctx := context.Background()
+	req := &llm.Request{
+		Messages: []llm.Message{
+			{Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "delay: 0.1"}}},
+		},
+	}
+
+	start := time.Now()
+	resp, err := service.Do(ctx, req)
+	elapsed := time.Since(start)
+
+	if err != nil {
+		t.Fatalf("delay test failed: %v", err)
+	}
+
+	if elapsed < 100*time.Millisecond {
+		t.Errorf("expected delay of at least 100ms, got %v", elapsed)
+	}
+
+	if resp.Content[0].Text != "Delayed for 0.1 seconds" {
+		t.Errorf("unexpected response text: %s", resp.Content[0].Text)
+	}
+}
+
+func TestLoopWithPredictableService(t *testing.T) {
+	var recordedMessages []llm.Message
+	var recordedUsages []llm.Usage
+
+	recordFunc := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+		recordedMessages = append(recordedMessages, message)
+		recordedUsages = append(recordedUsages, usage)
+		return nil
+	}
+
+	service := NewPredictableService()
+	loop := NewLoop(Config{
+		LLM:           service,
+		History:       []llm.Message{},
+		Tools:         []*llm.Tool{},
+		RecordMessage: recordFunc,
+	})
+
+	// Queue a user message that triggers a known response
+	userMessage := llm.Message{
+		Role:    llm.MessageRoleUser,
+		Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}},
+	}
+	loop.QueueUserMessage(userMessage)
+
+	// Run the loop with a short timeout
+	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+	defer cancel()
+
+	err := loop.Go(ctx)
+	if err != context.DeadlineExceeded {
+		t.Errorf("expected context deadline exceeded, got %v", err)
+	}
+
+	// Check that messages were recorded
+	if len(recordedMessages) < 1 {
+		t.Errorf("expected at least 1 recorded message, got %d", len(recordedMessages))
+	}
+
+	// Check usage tracking
+	usage := loop.GetUsage()
+	if usage.IsZero() {
+		t.Error("expected non-zero usage")
+	}
+}
+
+func TestLoopWithTools(t *testing.T) {
+	var toolCalls []string
+
+	testTool := &llm.Tool{
+		Name:        "bash",
+		Description: "A test bash tool",
+		InputSchema: llm.MustSchema(`{"type": "object", "properties": {"command": {"type": "string"}}}`),
+		Run: func(ctx context.Context, input json.RawMessage) llm.ToolOut {
+			toolCalls = append(toolCalls, string(input))
+			return llm.ToolOut{
+				LLMContent: []llm.Content{
+					{Type: llm.ContentTypeText, Text: "Command executed successfully"},
+				},
+			}
+		},
+	}
+
+	service := NewPredictableService()
+	loop := NewLoop(Config{
+		LLM:     service,
+		History: []llm.Message{},
+		Tools:   []*llm.Tool{testTool},
+		RecordMessage: func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+			return nil
+		},
+	})
+
+	// Queue a user message that triggers the bash tool
+	userMessage := llm.Message{
+		Role:    llm.MessageRoleUser,
+		Content: []llm.Content{{Type: llm.ContentTypeText, Text: "bash: echo hello"}},
+	}
+	loop.QueueUserMessage(userMessage)
+
+	// Run the loop with a short timeout
+	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+	defer cancel()
+
+	err := loop.Go(ctx)
+	if err != context.DeadlineExceeded {
+		t.Errorf("expected context deadline exceeded, got %v", err)
+	}
+
+	// Check that the tool was called
+	if len(toolCalls) != 1 {
+		t.Errorf("expected 1 tool call, got %d", len(toolCalls))
+	}
+
+	if toolCalls[0] != `{"command":"echo hello"}` {
+		t.Errorf("unexpected tool call input: %s", toolCalls[0])
+	}
+}
+
+func TestGetHistory(t *testing.T) {
+	initialHistory := []llm.Message{
+		{Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Hello"}}},
+	}
+
+	loop := NewLoop(Config{
+		LLM:     NewPredictableService(),
+		History: initialHistory,
+		Tools:   []*llm.Tool{},
+	})
+
+	history := loop.GetHistory()
+	if len(history) != 1 {
+		t.Errorf("expected history length 1, got %d", len(history))
+	}
+
+	// Modify returned slice to ensure it's a copy
+	history[0].Content[0].Text = "Modified"
+
+	// Original should be unchanged
+	original := loop.GetHistory()
+	if original[0].Content[0].Text != "Hello" {
+		t.Error("GetHistory should return a copy, not the original slice")
+	}
+}
+
+func TestLoopWithKeywordTool(t *testing.T) {
+	// Test that keyword tool doesn't crash with nil pointer dereference
+	service := NewPredictableService()
+
+	var messages []llm.Message
+	recordMessage := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+		messages = append(messages, message)
+		return nil
+	}
+
+	// Add a mock keyword tool that doesn't actually search
+	tools := []*llm.Tool{
+		{
+			Name:        "keyword_search",
+			Description: "Mock keyword search",
+			InputSchema: llm.MustSchema(`{"type": "object", "properties": {"query": {"type": "string"}, "search_terms": {"type": "array", "items": {"type": "string"}}}, "required": ["query", "search_terms"]}`),
+			Run: func(ctx context.Context, input json.RawMessage) llm.ToolOut {
+				// Simple mock implementation
+				return llm.ToolOut{LLMContent: []llm.Content{{Type: llm.ContentTypeText, Text: "mock keyword search result"}}}
+			},
+		},
+	}
+
+	loop := NewLoop(Config{
+		LLM:           service,
+		History:       []llm.Message{},
+		Tools:         tools,
+		RecordMessage: recordMessage,
+	})
+
+	// Send a user message that will trigger the default response
+	userMessage := llm.Message{
+		Role: llm.MessageRoleUser,
+		Content: []llm.Content{
+			{Type: llm.ContentTypeText, Text: "Please search for some files"},
+		},
+	}
+
+	loop.QueueUserMessage(userMessage)
+
+	// Process one turn
+	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+	defer cancel()
+
+	err := loop.ProcessOneTurn(ctx)
+	if err != nil {
+		t.Fatalf("ProcessOneTurn failed: %v", err)
+	}
+
+	// Verify we got expected messages
+	// Note: User messages are recorded by ConversationManager, not by Loop,
+	// so we only expect the assistant response to be recorded here
+	if len(messages) < 1 {
+		t.Fatalf("Expected at least 1 message (assistant), got %d", len(messages))
+	}
+
+	// Should have assistant response
+	if messages[0].Role != llm.MessageRoleAssistant {
+		t.Errorf("Expected first recorded message to be assistant, got %s", messages[0].Role)
+	}
+}
+
+func TestLoopWithActualKeywordTool(t *testing.T) {
+	// Test that actual keyword tool works with Loop
+	service := NewPredictableService()
+
+	var messages []llm.Message
+	recordMessage := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+		messages = append(messages, message)
+		return nil
+	}
+
+	// Use the actual keyword tool from claudetool package
+	// Note: We need to import it first
+	tools := []*llm.Tool{
+		// Add a simplified keyword tool to avoid file system dependencies in tests
+		{
+			Name:        "keyword_search",
+			Description: "Search for files by keyword",
+			InputSchema: llm.MustSchema(`{"type": "object", "properties": {"query": {"type": "string"}, "search_terms": {"type": "array", "items": {"type": "string"}}}, "required": ["query", "search_terms"]}`),
+			Run: func(ctx context.Context, input json.RawMessage) llm.ToolOut {
+				// Simple mock implementation - no context dependencies
+				return llm.ToolOut{LLMContent: []llm.Content{{Type: llm.ContentTypeText, Text: "mock keyword search result"}}}
+			},
+		},
+	}
+
+	loop := NewLoop(Config{
+		LLM:           service,
+		History:       []llm.Message{},
+		Tools:         tools,
+		RecordMessage: recordMessage,
+	})
+
+	// Send a user message that will trigger the default response
+	userMessage := llm.Message{
+		Role: llm.MessageRoleUser,
+		Content: []llm.Content{
+			{Type: llm.ContentTypeText, Text: "Please search for some files"},
+		},
+	}
+
+	loop.QueueUserMessage(userMessage)
+
+	// Process one turn
+	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+	defer cancel()
+
+	err := loop.ProcessOneTurn(ctx)
+	if err != nil {
+		t.Fatalf("ProcessOneTurn failed: %v", err)
+	}
+
+	// Verify we got expected messages
+	// Note: User messages are recorded by ConversationManager, not by Loop,
+	// so we only expect the assistant response to be recorded here
+	if len(messages) < 1 {
+		t.Fatalf("Expected at least 1 message (assistant), got %d", len(messages))
+	}
+
+	// Should have assistant response
+	if messages[0].Role != llm.MessageRoleAssistant {
+		t.Errorf("Expected first recorded message to be assistant, got %s", messages[0].Role)
+	}
+
+	t.Log("Keyword tool test passed - no nil pointer dereference occurred")
+}
+
+func TestKeywordToolWithLLMProvider(t *testing.T) {
+	// Create a temp directory with a test file to search
+	tempDir := t.TempDir()
+	testFile := filepath.Join(tempDir, "test.txt")
+	if err := os.WriteFile(testFile, []byte("this is a test file\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	// Create a predictable service for testing
+	predictableService := NewPredictableService()
+
+	// Create a simple LLM provider for testing
+	llmProvider := &testLLMProvider{
+		service: predictableService,
+		models:  []string{"predictable"},
+	}
+
+	// Create keyword tool with provider - use temp dir instead of /
+	keywordTool := claudetool.NewKeywordToolWithWorkingDir(llmProvider, claudetool.NewMutableWorkingDir(tempDir))
+	tool := keywordTool.Tool()
+
+	// Test input
+	input := `{"query": "test search", "search_terms": ["test"]}`
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	result := tool.Run(ctx, json.RawMessage(input))
+
+	// Should get a result without error (even though ripgrep will fail in test environment)
+	// The important thing is that it doesn't crash with nil pointer dereference
+	if result.Error != nil {
+		t.Logf("Expected error in test environment (no ripgrep): %v", result.Error)
+		// This is expected in test environment
+	} else {
+		t.Log("Keyword tool executed successfully")
+		if len(result.LLMContent) == 0 {
+			t.Error("Expected some content in result")
+		}
+	}
+}
+
+// testLLMProvider implements LLMServiceProvider for testing
+type testLLMProvider struct {
+	service llm.Service
+	models  []string
+}
+
+func (t *testLLMProvider) GetService(modelID string) (llm.Service, error) {
+	for _, model := range t.models {
+		if model == modelID {
+			return t.service, nil
+		}
+	}
+	return nil, fmt.Errorf("model %s not available", modelID)
+}
+
+func (t *testLLMProvider) GetAvailableModels() []string {
+	return t.models
+}
+
+func TestInsertMissingToolResults(t *testing.T) {
+	tests := []struct {
+		name     string
+		messages []llm.Message
+		wantLen  int
+		wantText string
+	}{
+		{
+			name: "no missing tool results",
+			messages: []llm.Message{
+				{
+					Role: llm.MessageRoleAssistant,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "Let me help you"},
+					},
+				},
+				{
+					Role: llm.MessageRoleUser,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "Thanks"},
+					},
+				},
+			},
+			wantLen:  1,
+			wantText: "", // No synthetic result expected
+		},
+		{
+			name: "missing tool result - should insert synthetic result",
+			messages: []llm.Message{
+				{
+					Role: llm.MessageRoleAssistant,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "I'll use a tool"},
+						{Type: llm.ContentTypeToolUse, ID: "tool_123", ToolName: "bash"},
+					},
+				},
+				{
+					Role: llm.MessageRoleUser,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "Error occurred"},
+					},
+				},
+			},
+			wantLen:  2, // Should have synthetic tool_result + error message
+			wantText: "not executed; retry possible",
+		},
+		{
+			name: "multiple missing tool results",
+			messages: []llm.Message{
+				{
+					Role: llm.MessageRoleAssistant,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "I'll use multiple tools"},
+						{Type: llm.ContentTypeToolUse, ID: "tool_1", ToolName: "bash"},
+						{Type: llm.ContentTypeToolUse, ID: "tool_2", ToolName: "read"},
+					},
+				},
+				{
+					Role: llm.MessageRoleUser,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "Error occurred"},
+					},
+				},
+			},
+			wantLen: 3, // Should have 2 synthetic tool_results + error message
+		},
+		{
+			name: "has tool results - should not insert",
+			messages: []llm.Message{
+				{
+					Role: llm.MessageRoleAssistant,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "I'll use a tool"},
+						{Type: llm.ContentTypeToolUse, ID: "tool_123", ToolName: "bash"},
+					},
+				},
+				{
+					Role: llm.MessageRoleUser,
+					Content: []llm.Content{
+						{
+							Type:       llm.ContentTypeToolResult,
+							ToolUseID:  "tool_123",
+							ToolResult: []llm.Content{{Type: llm.ContentTypeText, Text: "result"}},
+						},
+					},
+				},
+			},
+			wantLen: 1, // Should not insert anything
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			loop := NewLoop(Config{
+				LLM:     NewPredictableService(),
+				History: []llm.Message{},
+			})
+
+			req := &llm.Request{
+				Messages: tt.messages,
+			}
+
+			loop.insertMissingToolResults(req)
+
+			got := req.Messages[len(req.Messages)-1]
+			if len(got.Content) != tt.wantLen {
+				t.Errorf("expected %d content items, got %d", tt.wantLen, len(got.Content))
+			}
+
+			if tt.wantText != "" {
+				// Find the synthetic tool result
+				found := false
+				for _, c := range got.Content {
+					if c.Type == llm.ContentTypeToolResult && len(c.ToolResult) > 0 {
+						if c.ToolResult[0].Text == tt.wantText {
+							found = true
+							if !c.ToolError {
+								t.Error("synthetic tool result should have ToolError=true")
+							}
+							break
+						}
+					}
+				}
+				if !found {
+					t.Errorf("expected to find synthetic tool result with text %q", tt.wantText)
+				}
+			}
+		})
+	}
+}
+
+func TestInsertMissingToolResultsWithEdgeCases(t *testing.T) {
+	// Test for the bug: when an assistant error message is recorded after a tool_use
+	// but before tool execution, the tool_use is "hidden" from insertMissingToolResults
+	// because it only checks the last two messages.
+	t.Run("tool_use hidden by subsequent assistant message", func(t *testing.T) {
+		loop := NewLoop(Config{
+			LLM:     NewPredictableService(),
+			History: []llm.Message{},
+		})
+
+		// Scenario:
+		// 1. LLM responds with tool_use
+		// 2. Something fails, error message recorded (assistant message)
+		// 3. User sends new message
+		// The tool_use in message 0 is never followed by a tool_result
+		req := &llm.Request{
+			Messages: []llm.Message{
+				{
+					Role: llm.MessageRoleAssistant,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "I'll run a command"},
+						{Type: llm.ContentTypeToolUse, ID: "tool_hidden", ToolName: "bash"},
+					},
+				},
+				{
+					Role: llm.MessageRoleAssistant,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "LLM request failed: some error"},
+					},
+				},
+				{
+					Role: llm.MessageRoleUser,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "Please try again"},
+					},
+				},
+			},
+		}
+
+		loop.insertMissingToolResults(req)
+
+		// The function should have inserted a tool_result for tool_hidden
+		// It should be inserted as a user message after the assistant message with tool_use
+		// Since we can't insert in the middle, we need to ensure the history is valid
+
+		// Check that there's a tool_result for tool_hidden somewhere in the messages
+		found := false
+		for _, msg := range req.Messages {
+			for _, c := range msg.Content {
+				if c.Type == llm.ContentTypeToolResult && c.ToolUseID == "tool_hidden" {
+					found = true
+					if !c.ToolError {
+						t.Error("synthetic tool result should have ToolError=true")
+					}
+					break
+				}
+			}
+		}
+		if !found {
+			t.Error("expected to find synthetic tool result for tool_hidden - the bug is that tool_use is hidden by subsequent assistant message")
+		}
+	})
+
+	// Test for tool_use in earlier message (not the second-to-last)
+	t.Run("tool_use in earlier message without result", func(t *testing.T) {
+		loop := NewLoop(Config{
+			LLM:     NewPredictableService(),
+			History: []llm.Message{},
+		})
+
+		req := &llm.Request{
+			Messages: []llm.Message{
+				{
+					Role: llm.MessageRoleUser,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "Do something"},
+					},
+				},
+				{
+					Role: llm.MessageRoleAssistant,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "I'll use a tool"},
+						{Type: llm.ContentTypeToolUse, ID: "tool_earlier", ToolName: "bash"},
+					},
+				},
+				// Missing: user message with tool_result for tool_earlier
+				{
+					Role: llm.MessageRoleAssistant,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "Something went wrong"},
+					},
+				},
+				{
+					Role: llm.MessageRoleUser,
+					Content: []llm.Content{
+						{Type: llm.ContentTypeText, Text: "Try again"},
+					},
+				},
+			},
+		}
+
+		loop.insertMissingToolResults(req)
+
+		// Should have inserted a tool_result for tool_earlier
+		found := false
+		for _, msg := range req.Messages {
+			for _, c := range msg.Content {
+				if c.Type == llm.ContentTypeToolResult && c.ToolUseID == "tool_earlier" {
+					found = true
+					break
+				}
+			}
+		}
+		if !found {
+			t.Error("expected to find synthetic tool result for tool_earlier")
+		}
+	})
+
+	t.Run("empty message list", func(t *testing.T) {
+		loop := NewLoop(Config{
+			LLM:     NewPredictableService(),
+			History: []llm.Message{},
+		})
+
+		req := &llm.Request{
+			Messages: []llm.Message{},
+		}
+
+		loop.insertMissingToolResults(req)
+		// Should not panic
+	})
+
+	t.Run("single message", func(t *testing.T) {
+		loop := NewLoop(Config{
+			LLM:     NewPredictableService(),
+			History: []llm.Message{},
+		})
+
+		req := &llm.Request{
+			Messages: []llm.Message{
+				{Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}}},
+			},
+		}
+
+		loop.insertMissingToolResults(req)
+		// Should not panic, should not modify
+		if len(req.Messages[0].Content) != 1 {
+			t.Error("should not modify single message")
+		}
+	})
+
+	t.Run("wrong role order - user then assistant", func(t *testing.T) {
+		loop := NewLoop(Config{
+			LLM:     NewPredictableService(),
+			History: []llm.Message{},
+		})
+
+		req := &llm.Request{
+			Messages: []llm.Message{
+				{Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}}},
+				{Role: llm.MessageRoleAssistant, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hi"}}},
+			},
+		}
+
+		loop.insertMissingToolResults(req)
+		// Should not modify when roles are wrong order
+		if len(req.Messages[1].Content) != 1 {
+			t.Error("should not modify when roles are in wrong order")
+		}
+	})
+}

loop/predictable.go πŸ”—

@@ -0,0 +1,555 @@
+package loop
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"shelley.exe.dev/llm"
+)
+
+// PredictableService is an LLM service that returns predictable responses for testing.
+//
+// To add new test patterns, update the Do() method directly by adding cases to the switch
+// statement or new prefix checks. Do not extend or wrap this service - modify it in place.
+// Available patterns include:
+//   - "echo: <text>" - echoes the text back
+//   - "bash: <command>" - triggers bash tool with command
+//   - "think: <thoughts>" - triggers think tool
+//   - "delay: <seconds>" - delays response by specified seconds
+//   - See Do() method for complete list of supported patterns
+type PredictableService struct {
+	// TokenContextWindow size
+	tokenContextWindow int
+	mu                 sync.Mutex
+	// Recent requests for testing inspection
+	recentRequests []*llm.Request
+	responseDelay  time.Duration
+}
+
+// NewPredictableService creates a new predictable LLM service
+func NewPredictableService() *PredictableService {
+	svc := &PredictableService{
+		tokenContextWindow: 200000,
+	}
+
+	if delayEnv := os.Getenv("PREDICTABLE_DELAY_MS"); delayEnv != "" {
+		if ms, err := strconv.Atoi(delayEnv); err == nil && ms > 0 {
+			svc.responseDelay = time.Duration(ms) * time.Millisecond
+		}
+	}
+
+	return svc
+}
+
+// TokenContextWindow returns the maximum token context window size
+func (s *PredictableService) TokenContextWindow() int {
+	return s.tokenContextWindow
+}
+
+// Do processes a request and returns a predictable response based on the input text
+func (s *PredictableService) Do(ctx context.Context, req *llm.Request) (*llm.Response, error) {
+	// Store request for testing inspection
+	s.mu.Lock()
+	delay := s.responseDelay
+	s.recentRequests = append(s.recentRequests, req)
+	// Keep only last 10 requests
+	if len(s.recentRequests) > 10 {
+		s.recentRequests = s.recentRequests[len(s.recentRequests)-10:]
+	}
+	s.mu.Unlock()
+
+	if delay > 0 {
+		select {
+		case <-time.After(delay):
+		case <-ctx.Done():
+			return nil, ctx.Err()
+		}
+	}
+
+	// Calculate input token count based on the request content
+	inputTokens := s.countRequestTokens(req)
+
+	// Extract the text content from the last user message
+	var inputText string
+	if len(req.Messages) > 0 {
+		lastMessage := req.Messages[len(req.Messages)-1]
+		if lastMessage.Role == llm.MessageRoleUser {
+			for _, content := range lastMessage.Content {
+				if content.Type == llm.ContentTypeText {
+					inputText = strings.TrimSpace(content.Text)
+					break
+				}
+			}
+		}
+	}
+
+	// Handle input using case statements
+	switch inputText {
+	case "hello":
+		return s.makeResponse("Well, hi there!", inputTokens), nil
+
+	case "Hello":
+		return s.makeResponse("Hello! I'm Shelley, your AI assistant. How can I help you today?", inputTokens), nil
+
+	case "Create an example":
+		return s.makeThinkToolResponse("I'll create a simple example for you.", inputTokens), nil
+
+	case "screenshot":
+		// Trigger a screenshot of the current page
+		return s.makeScreenshotToolResponse("", inputTokens), nil
+
+	case "tool smorgasbord":
+		// Return a response with all tool types for testing
+		return s.makeToolSmorgasbordResponse(inputTokens), nil
+
+	case "echo: foo":
+		return s.makeResponse("foo", inputTokens), nil
+
+	case "patch fail":
+		// Trigger a patch that will fail (file doesn't exist)
+		return s.makePatchToolResponse("/nonexistent/file/that/does/not/exist.txt", inputTokens), nil
+
+	case "patch bad json":
+		// Trigger a patch with malformed JSON (simulates Anthropic sending invalid JSON)
+		return s.makeMalformedPatchToolResponse(inputTokens), nil
+
+	default:
+		// Handle pattern-based inputs
+		if strings.HasPrefix(inputText, "echo: ") {
+			text := strings.TrimPrefix(inputText, "echo: ")
+			return s.makeResponse(text, inputTokens), nil
+		}
+
+		if strings.HasPrefix(inputText, "bash: ") {
+			cmd := strings.TrimPrefix(inputText, "bash: ")
+			return s.makeBashToolResponse(cmd, inputTokens), nil
+		}
+
+		if strings.HasPrefix(inputText, "think: ") {
+			thoughts := strings.TrimPrefix(inputText, "think: ")
+			return s.makeThinkToolResponse(thoughts, inputTokens), nil
+		}
+
+		if strings.HasPrefix(inputText, "patch: ") {
+			filePath := strings.TrimPrefix(inputText, "patch: ")
+			return s.makePatchToolResponse(filePath, inputTokens), nil
+		}
+
+		if strings.HasPrefix(inputText, "error: ") {
+			errorMsg := strings.TrimPrefix(inputText, "error: ")
+			return nil, fmt.Errorf("predictable error: %s", errorMsg)
+		}
+
+		if strings.HasPrefix(inputText, "screenshot: ") {
+			selector := strings.TrimSpace(strings.TrimPrefix(inputText, "screenshot: "))
+			return s.makeScreenshotToolResponse(selector, inputTokens), nil
+		}
+
+		if strings.HasPrefix(inputText, "delay: ") {
+			delayStr := strings.TrimPrefix(inputText, "delay: ")
+			delaySeconds, err := strconv.ParseFloat(delayStr, 64)
+			if err == nil && delaySeconds > 0 {
+				delayDuration := time.Duration(delaySeconds * float64(time.Second))
+				select {
+				case <-time.After(delayDuration):
+				case <-ctx.Done():
+					return nil, ctx.Err()
+				}
+			}
+			return s.makeResponse(fmt.Sprintf("Delayed for %s seconds", delayStr), inputTokens), nil
+		}
+
+		// Default response for undefined inputs
+		return s.makeResponse("edit predictable.go to add a response for that one...", inputTokens), nil
+	}
+}
+
+// makeResponse creates a simple text response
+func (s *PredictableService) makeResponse(text string, inputTokens uint64) *llm.Response {
+	outputTokens := uint64(len(text) / 4) // ~4 chars per token
+	if outputTokens == 0 {
+		outputTokens = 1
+	}
+	return &llm.Response{
+		ID:    fmt.Sprintf("pred-%d", time.Now().UnixNano()),
+		Type:  "message",
+		Role:  llm.MessageRoleAssistant,
+		Model: "predictable-v1",
+		Content: []llm.Content{
+			{Type: llm.ContentTypeText, Text: text},
+		},
+		StopReason: llm.StopReasonStopSequence,
+		Usage: llm.Usage{
+			InputTokens:  inputTokens,
+			OutputTokens: outputTokens,
+			CostUSD:      0.001,
+		},
+	}
+}
+
+// makeBashToolResponse creates a response that calls the bash tool
+func (s *PredictableService) makeBashToolResponse(command string, inputTokens uint64) *llm.Response {
+	// Properly marshal the command to avoid JSON escaping issues
+	toolInputData := map[string]string{"command": command}
+	toolInputBytes, _ := json.Marshal(toolInputData)
+	toolInput := json.RawMessage(toolInputBytes)
+	responseText := fmt.Sprintf("I'll run the command: %s", command)
+	outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
+	if outputTokens == 0 {
+		outputTokens = 1
+	}
+	return &llm.Response{
+		ID:    fmt.Sprintf("pred-bash-%d", time.Now().UnixNano()),
+		Type:  "message",
+		Role:  llm.MessageRoleAssistant,
+		Model: "predictable-v1",
+		Content: []llm.Content{
+			{Type: llm.ContentTypeText, Text: responseText},
+			{
+				ID:        fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
+				Type:      llm.ContentTypeToolUse,
+				ToolName:  "bash",
+				ToolInput: toolInput,
+			},
+		},
+		StopReason: llm.StopReasonToolUse,
+		Usage: llm.Usage{
+			InputTokens:  inputTokens,
+			OutputTokens: outputTokens,
+			CostUSD:      0.002,
+		},
+	}
+}
+
+// makeThinkToolResponse creates a response that calls the think tool
+func (s *PredictableService) makeThinkToolResponse(thoughts string, inputTokens uint64) *llm.Response {
+	// Properly marshal the thoughts to avoid JSON escaping issues
+	toolInputData := map[string]string{"thoughts": thoughts}
+	toolInputBytes, _ := json.Marshal(toolInputData)
+	toolInput := json.RawMessage(toolInputBytes)
+	responseText := "Let me think about this."
+	outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
+	if outputTokens == 0 {
+		outputTokens = 1
+	}
+	return &llm.Response{
+		ID:    fmt.Sprintf("pred-think-%d", time.Now().UnixNano()),
+		Type:  "message",
+		Role:  llm.MessageRoleAssistant,
+		Model: "predictable-v1",
+		Content: []llm.Content{
+			{Type: llm.ContentTypeText, Text: responseText},
+			{
+				ID:        fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
+				Type:      llm.ContentTypeToolUse,
+				ToolName:  "think",
+				ToolInput: toolInput,
+			},
+		},
+		StopReason: llm.StopReasonToolUse,
+		Usage: llm.Usage{
+			InputTokens:  inputTokens,
+			OutputTokens: outputTokens,
+			CostUSD:      0.002,
+		},
+	}
+}
+
+// makePatchToolResponse creates a response that calls the patch tool
+func (s *PredictableService) makePatchToolResponse(filePath string, inputTokens uint64) *llm.Response {
+	// Properly marshal the patch data to avoid JSON escaping issues
+	toolInputData := map[string]interface{}{
+		"path": filePath,
+		"patches": []map[string]string{
+			{
+				"operation": "replace",
+				"oldText":   "example",
+				"newText":   "updated example",
+			},
+		},
+	}
+	toolInputBytes, _ := json.Marshal(toolInputData)
+	toolInput := json.RawMessage(toolInputBytes)
+	responseText := fmt.Sprintf("I'll patch the file: %s", filePath)
+	outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
+	if outputTokens == 0 {
+		outputTokens = 1
+	}
+	return &llm.Response{
+		ID:    fmt.Sprintf("pred-patch-%d", time.Now().UnixNano()),
+		Type:  "message",
+		Role:  llm.MessageRoleAssistant,
+		Model: "predictable-v1",
+		Content: []llm.Content{
+			{Type: llm.ContentTypeText, Text: responseText},
+			{
+				ID:        fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
+				Type:      llm.ContentTypeToolUse,
+				ToolName:  "patch",
+				ToolInput: toolInput,
+			},
+		},
+		StopReason: llm.StopReasonToolUse,
+		Usage: llm.Usage{
+			InputTokens:  inputTokens,
+			OutputTokens: outputTokens,
+			CostUSD:      0.003,
+		},
+	}
+}
+
+// makeMalformedPatchToolResponse creates a response with malformed JSON that will fail to parse
+// This simulates when Anthropic sends back invalid JSON in the tool input
+func (s *PredictableService) makeMalformedPatchToolResponse(inputTokens uint64) *llm.Response {
+	// This malformed JSON has a string where an object is expected (patch field)
+	// Mimics the error: "cannot unmarshal string into Go struct field PatchInputOneSingular.patch"
+	malformedJSON := `{"path":"/home/agent/example.css","patch":"<parameter name=\"operation\">replace","oldText":".example {\n  color: red;\n}","newText":".example {\n  color: blue;\n}"}`
+	toolInput := json.RawMessage(malformedJSON)
+	return &llm.Response{
+		ID:    fmt.Sprintf("pred-patch-malformed-%d", time.Now().UnixNano()),
+		Type:  "message",
+		Role:  llm.MessageRoleAssistant,
+		Model: "predictable-v1",
+		Content: []llm.Content{
+			{Type: llm.ContentTypeText, Text: "I'll patch the file with the changes."},
+			{
+				ID:        fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
+				Type:      llm.ContentTypeToolUse,
+				ToolName:  "patch",
+				ToolInput: toolInput,
+			},
+		},
+		StopReason: llm.StopReasonToolUse,
+		Usage: llm.Usage{
+			InputTokens:  inputTokens,
+			OutputTokens: 50,
+			CostUSD:      0.003,
+		},
+	}
+}
+
+// GetRecentRequests returns the recent requests made to this service
+func (s *PredictableService) GetRecentRequests() []*llm.Request {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if len(s.recentRequests) == 0 {
+		return nil
+	}
+
+	requests := make([]*llm.Request, len(s.recentRequests))
+	copy(requests, s.recentRequests)
+	return requests
+}
+
+// GetLastRequest returns the most recent request, or nil if none
+func (s *PredictableService) GetLastRequest() *llm.Request {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if len(s.recentRequests) == 0 {
+		return nil
+	}
+	return s.recentRequests[len(s.recentRequests)-1]
+}
+
+// ClearRequests clears the request history
+func (s *PredictableService) ClearRequests() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	s.recentRequests = nil
+}
+
+// countRequestTokens estimates token count based on character count.
+// Uses a simple ~4 chars per token approximation.
+func (s *PredictableService) countRequestTokens(req *llm.Request) uint64 {
+	var totalChars int
+
+	// Count system prompt characters
+	for _, sys := range req.System {
+		totalChars += len(sys.Text)
+	}
+
+	// Count message characters
+	for _, msg := range req.Messages {
+		for _, content := range msg.Content {
+			switch content.Type {
+			case llm.ContentTypeText:
+				totalChars += len(content.Text)
+			case llm.ContentTypeToolUse:
+				totalChars += len(content.ToolName)
+				totalChars += len(content.ToolInput)
+			case llm.ContentTypeToolResult:
+				for _, result := range content.ToolResult {
+					if result.Type == llm.ContentTypeText {
+						totalChars += len(result.Text)
+					}
+				}
+			}
+		}
+	}
+
+	// Count tool definitions
+	for _, tool := range req.Tools {
+		totalChars += len(tool.Name)
+		totalChars += len(tool.Description)
+		totalChars += len(tool.InputSchema)
+	}
+
+	// ~4 chars per token is a rough approximation
+	return uint64(totalChars / 4)
+}
+
+// makeScreenshotToolResponse creates a response that calls the screenshot tool
+func (s *PredictableService) makeScreenshotToolResponse(selector string, inputTokens uint64) *llm.Response {
+	toolInputData := map[string]any{}
+	if selector != "" {
+		toolInputData["selector"] = selector
+	}
+	toolInputBytes, _ := json.Marshal(toolInputData)
+	toolInput := json.RawMessage(toolInputBytes)
+	responseText := "Taking a screenshot..."
+	outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
+	if outputTokens == 0 {
+		outputTokens = 1
+	}
+	return &llm.Response{
+		ID:    fmt.Sprintf("pred-screenshot-%d", time.Now().UnixNano()),
+		Type:  "message",
+		Role:  llm.MessageRoleAssistant,
+		Model: "predictable-v1",
+		Content: []llm.Content{
+			{Type: llm.ContentTypeText, Text: responseText},
+			{
+				ID:        fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
+				Type:      llm.ContentTypeToolUse,
+				ToolName:  "browser_take_screenshot",
+				ToolInput: toolInput,
+			},
+		},
+		StopReason: llm.StopReasonToolUse,
+		Usage: llm.Usage{
+			InputTokens:  inputTokens,
+			OutputTokens: outputTokens,
+			CostUSD:      0.0,
+		},
+	}
+}
+
+// makeToolSmorgasbordResponse creates a response that uses all available tool types
+func (s *PredictableService) makeToolSmorgasbordResponse(inputTokens uint64) *llm.Response {
+	baseNano := time.Now().UnixNano()
+	content := []llm.Content{
+		{Type: llm.ContentTypeText, Text: "Here's a sample of all the tools:"},
+	}
+
+	// bash tool
+	bashInput, _ := json.Marshal(map[string]string{"command": "echo 'hello from bash'"})
+	content = append(content, llm.Content{
+		ID:        fmt.Sprintf("tool_bash_%d", baseNano%1000),
+		Type:      llm.ContentTypeToolUse,
+		ToolName:  "bash",
+		ToolInput: json.RawMessage(bashInput),
+	})
+
+	// think tool
+	thinkInput, _ := json.Marshal(map[string]string{"thoughts": "I'm thinking about the best approach for this task. Let me consider all the options available."})
+	content = append(content, llm.Content{
+		ID:        fmt.Sprintf("tool_think_%d", (baseNano+1)%1000),
+		Type:      llm.ContentTypeToolUse,
+		ToolName:  "think",
+		ToolInput: json.RawMessage(thinkInput),
+	})
+
+	// patch tool
+	patchInput, _ := json.Marshal(map[string]interface{}{
+		"path": "/tmp/example.txt",
+		"patches": []map[string]string{
+			{"operation": "replace", "oldText": "foo", "newText": "bar"},
+		},
+	})
+	content = append(content, llm.Content{
+		ID:        fmt.Sprintf("tool_patch_%d", (baseNano+2)%1000),
+		Type:      llm.ContentTypeToolUse,
+		ToolName:  "patch",
+		ToolInput: json.RawMessage(patchInput),
+	})
+
+	// screenshot tool
+	screenshotInput, _ := json.Marshal(map[string]string{})
+	content = append(content, llm.Content{
+		ID:        fmt.Sprintf("tool_screenshot_%d", (baseNano+3)%1000),
+		Type:      llm.ContentTypeToolUse,
+		ToolName:  "browser_take_screenshot",
+		ToolInput: json.RawMessage(screenshotInput),
+	})
+
+	// keyword_search tool
+	keywordInput, _ := json.Marshal(map[string]interface{}{
+		"query":        "find all references",
+		"search_terms": []string{"reference", "example"},
+	})
+	content = append(content, llm.Content{
+		ID:        fmt.Sprintf("tool_keyword_%d", (baseNano+4)%1000),
+		Type:      llm.ContentTypeToolUse,
+		ToolName:  "keyword_search",
+		ToolInput: json.RawMessage(keywordInput),
+	})
+
+	// browser_navigate tool
+	navigateInput, _ := json.Marshal(map[string]string{"url": "https://example.com"})
+	content = append(content, llm.Content{
+		ID:        fmt.Sprintf("tool_navigate_%d", (baseNano+5)%1000),
+		Type:      llm.ContentTypeToolUse,
+		ToolName:  "browser_navigate",
+		ToolInput: json.RawMessage(navigateInput),
+	})
+
+	// browser_eval tool
+	evalInput, _ := json.Marshal(map[string]string{"script": "document.title"})
+	content = append(content, llm.Content{
+		ID:        fmt.Sprintf("tool_eval_%d", (baseNano+6)%1000),
+		Type:      llm.ContentTypeToolUse,
+		ToolName:  "browser_eval",
+		ToolInput: json.RawMessage(evalInput),
+	})
+
+	// read_image tool
+	readImageInput, _ := json.Marshal(map[string]string{"path": "/tmp/image.png"})
+	content = append(content, llm.Content{
+		ID:        fmt.Sprintf("tool_readimg_%d", (baseNano+7)%1000),
+		Type:      llm.ContentTypeToolUse,
+		ToolName:  "read_image",
+		ToolInput: json.RawMessage(readImageInput),
+	})
+
+	// browser_recent_console_logs tool
+	consoleInput, _ := json.Marshal(map[string]string{})
+	content = append(content, llm.Content{
+		ID:        fmt.Sprintf("tool_console_%d", (baseNano+8)%1000),
+		Type:      llm.ContentTypeToolUse,
+		ToolName:  "browser_recent_console_logs",
+		ToolInput: json.RawMessage(consoleInput),
+	})
+
+	return &llm.Response{
+		ID:         fmt.Sprintf("pred-smorgasbord-%d", baseNano),
+		Type:       "message",
+		Role:       llm.MessageRoleAssistant,
+		Model:      "predictable-v1",
+		Content:    content,
+		StopReason: llm.StopReasonToolUse,
+		Usage: llm.Usage{
+			InputTokens:  inputTokens,
+			OutputTokens: 200,
+			CostUSD:      0.01,
+		},
+	}
+}

models/models.go πŸ”—

@@ -0,0 +1,477 @@
+package models
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"sync"
+	"time"
+
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/llm/ant"
+	"shelley.exe.dev/llm/oai"
+	"shelley.exe.dev/loop"
+)
+
+// Provider represents an LLM provider
+type Provider string
+
+const (
+	ProviderOpenAI    Provider = "OpenAI"
+	ProviderAnthropic Provider = "Anthropic"
+	ProviderFireworks Provider = "Fireworks"
+	ProviderGemini    Provider = "Gemini"
+	ProviderBuiltIn   Provider = "Built-in"
+)
+
+// Model represents a configured LLM model in Shelley
+type Model struct {
+	// ID is the user-facing identifier for this model
+	ID string
+
+	// Provider is the LLM provider (OpenAI, Anthropic, etc.)
+	Provider Provider
+
+	// Description is a human-readable description
+	Description string
+
+	// RequiredEnvVars are the environment variables required for this model
+	RequiredEnvVars []string
+
+	// Factory creates an llm.Service instance for this model
+	Factory func(config *Config) (llm.Service, error)
+}
+
+// Config holds the configuration needed to create LLM services
+type Config struct {
+	// API keys for each provider
+	AnthropicAPIKey string
+	OpenAIAPIKey    string
+	GeminiAPIKey    string
+	FireworksAPIKey string
+
+	// Gateway is the base URL of the LLM gateway (optional)
+	// If set, model-specific suffixes will be appended
+	Gateway string
+
+	Logger *slog.Logger
+}
+
+// getAnthropicURL returns the Anthropic API URL, with gateway suffix if gateway is set
+func (c *Config) getAnthropicURL() string {
+	if c.Gateway != "" {
+		return c.Gateway + "/_/gateway/anthropic/v1/messages"
+	}
+	return "" // use default from ant package
+}
+
+// getOpenAIURL returns the OpenAI API URL, with gateway suffix if gateway is set
+func (c *Config) getOpenAIURL() string {
+	if c.Gateway != "" {
+		return c.Gateway + "/_/gateway/openai/v1"
+	}
+	return "" // use default from oai package
+}
+
+// getGeminiURL returns the Gemini API URL, with gateway suffix if gateway is set
+func (c *Config) getGeminiURL() string {
+	if c.Gateway != "" {
+		return c.Gateway + "/_/gateway/gemini/v1/models/generate"
+	}
+	return "" // use default from gem package
+}
+
+// getFireworksURL returns the Fireworks API URL, with gateway suffix if gateway is set
+func (c *Config) getFireworksURL() string {
+	if c.Gateway != "" {
+		return c.Gateway + "/_/gateway/fireworks/inference/v1"
+	}
+	return "" // use default from oai package
+}
+
+// All returns all available models in Shelley
+func All() []Model {
+	return []Model{
+		{
+			ID:              "claude-opus-4.5",
+			Provider:        ProviderAnthropic,
+			Description:     "Claude Opus 4.5 (default)",
+			RequiredEnvVars: []string{"ANTHROPIC_API_KEY"},
+			Factory: func(config *Config) (llm.Service, error) {
+				if config.AnthropicAPIKey == "" {
+					return nil, fmt.Errorf("claude-opus-4.5 requires ANTHROPIC_API_KEY")
+				}
+				svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Opus}
+				if url := config.getAnthropicURL(); url != "" {
+					svc.URL = url
+				}
+				return svc, nil
+			},
+		},
+		{
+			ID:              "qwen3-coder-fireworks",
+			Provider:        ProviderFireworks,
+			Description:     "Qwen3 Coder 480B on Fireworks",
+			RequiredEnvVars: []string{"FIREWORKS_API_KEY"},
+			Factory: func(config *Config) (llm.Service, error) {
+				if config.FireworksAPIKey == "" {
+					return nil, fmt.Errorf("qwen3-coder-fireworks requires FIREWORKS_API_KEY")
+				}
+				svc := &oai.Service{Model: oai.Qwen3CoderFireworks, APIKey: config.FireworksAPIKey}
+				if url := config.getFireworksURL(); url != "" {
+					svc.ModelURL = url
+				}
+				return svc, nil
+			},
+		},
+		{
+			ID:              "glm-4p6-fireworks",
+			Provider:        ProviderFireworks,
+			Description:     "GLM-4P6 on Fireworks",
+			RequiredEnvVars: []string{"FIREWORKS_API_KEY"},
+			Factory: func(config *Config) (llm.Service, error) {
+				if config.FireworksAPIKey == "" {
+					return nil, fmt.Errorf("glm-4p6-fireworks requires FIREWORKS_API_KEY")
+				}
+				svc := &oai.Service{Model: oai.GLM4P6Fireworks, APIKey: config.FireworksAPIKey}
+				if url := config.getFireworksURL(); url != "" {
+					svc.ModelURL = url
+				}
+				return svc, nil
+			},
+		},
+		{
+			ID:              "gpt-5",
+			Provider:        ProviderOpenAI,
+			Description:     "GPT-5",
+			RequiredEnvVars: []string{"OPENAI_API_KEY"},
+			Factory: func(config *Config) (llm.Service, error) {
+				if config.OpenAIAPIKey == "" {
+					return nil, fmt.Errorf("gpt-5 requires OPENAI_API_KEY")
+				}
+				svc := &oai.Service{Model: oai.GPT5, APIKey: config.OpenAIAPIKey}
+				if url := config.getOpenAIURL(); url != "" {
+					svc.ModelURL = url
+				}
+				return svc, nil
+			},
+		},
+		{
+			ID:              "gpt-5-nano",
+			Provider:        ProviderOpenAI,
+			Description:     "GPT-5 Nano",
+			RequiredEnvVars: []string{"OPENAI_API_KEY"},
+			Factory: func(config *Config) (llm.Service, error) {
+				if config.OpenAIAPIKey == "" {
+					return nil, fmt.Errorf("gpt-5-nano requires OPENAI_API_KEY")
+				}
+				svc := &oai.Service{Model: oai.GPT5Nano, APIKey: config.OpenAIAPIKey}
+				if url := config.getOpenAIURL(); url != "" {
+					svc.ModelURL = url
+				}
+				return svc, nil
+			},
+		},
+		{
+			ID:              "gpt-5.1-codex",
+			Provider:        ProviderOpenAI,
+			Description:     "GPT-5.1 Codex (uses Responses API)",
+			RequiredEnvVars: []string{"OPENAI_API_KEY"},
+			Factory: func(config *Config) (llm.Service, error) {
+				if config.OpenAIAPIKey == "" {
+					return nil, fmt.Errorf("gpt-5.1-codex requires OPENAI_API_KEY")
+				}
+				svc := &oai.ResponsesService{Model: oai.GPT5Codex, APIKey: config.OpenAIAPIKey}
+				if url := config.getOpenAIURL(); url != "" {
+					svc.ModelURL = url
+				}
+				return svc, nil
+			},
+		},
+		{
+			ID:              "claude-sonnet-4.5",
+			Provider:        ProviderAnthropic,
+			Description:     "Claude Sonnet 4.5",
+			RequiredEnvVars: []string{"ANTHROPIC_API_KEY"},
+			Factory: func(config *Config) (llm.Service, error) {
+				if config.AnthropicAPIKey == "" {
+					return nil, fmt.Errorf("claude-sonnet-4.5 requires ANTHROPIC_API_KEY")
+				}
+				svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Sonnet}
+				if url := config.getAnthropicURL(); url != "" {
+					svc.URL = url
+				}
+				return svc, nil
+			},
+		},
+		{
+			ID:              "claude-haiku-4.5",
+			Provider:        ProviderAnthropic,
+			Description:     "Claude Haiku 4.5",
+			RequiredEnvVars: []string{"ANTHROPIC_API_KEY"},
+			Factory: func(config *Config) (llm.Service, error) {
+				if config.AnthropicAPIKey == "" {
+					return nil, fmt.Errorf("claude-haiku-4.5 requires ANTHROPIC_API_KEY")
+				}
+				svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Haiku}
+				if url := config.getAnthropicURL(); url != "" {
+					svc.URL = url
+				}
+				return svc, nil
+			},
+		},
+		{
+			ID:              "predictable",
+			Provider:        ProviderBuiltIn,
+			Description:     "Deterministic test model (no API key)",
+			RequiredEnvVars: []string{},
+			Factory: func(config *Config) (llm.Service, error) {
+				return loop.NewPredictableService(), nil
+			},
+		},
+	}
+}
+
+// ByID returns the model with the given ID, or nil if not found
+func ByID(id string) *Model {
+	for _, m := range All() {
+		if m.ID == id {
+			return &m
+		}
+	}
+	return nil
+}
+
+// IDs returns all model IDs (not including aliases)
+func IDs() []string {
+	models := All()
+	ids := make([]string, len(models))
+	for i, m := range models {
+		ids[i] = m.ID
+	}
+	return ids
+}
+
+// Default returns the default model
+func Default() Model {
+	return All()[0] // claude-opus-4.5
+}
+
+// Manager manages LLM services for all configured models
+type Manager struct {
+	services map[string]llm.Service
+	logger   *slog.Logger
+	history  *LLMRequestHistory
+}
+
+// LLMRequestRecord stores a request/response pair for debugging
+type LLMRequestRecord struct {
+	Timestamp      time.Time `json:"timestamp"`
+	ModelID        string    `json:"model_id"`
+	URL            string    `json:"url"`
+	HTTPRequest    []byte    `json:"http_request,omitempty"`
+	HTTPResponse   []byte    `json:"http_response,omitempty"`
+	HTTPStatusCode int       `json:"http_status_code,omitempty"`
+	Error          string    `json:"error,omitempty"`
+	Duration       float64   `json:"duration_seconds"`
+}
+
+// LLMRequestHistory maintains a circular buffer of recent LLM requests
+type LLMRequestHistory struct {
+	mu      sync.RWMutex
+	records []LLMRequestRecord
+	maxSize int
+}
+
+// NewLLMRequestHistory creates a new request history with the given max size
+func NewLLMRequestHistory(maxSize int) *LLMRequestHistory {
+	return &LLMRequestHistory{
+		records: make([]LLMRequestRecord, 0, maxSize),
+		maxSize: maxSize,
+	}
+}
+
+// Add adds a new record to the history
+func (h *LLMRequestHistory) Add(record LLMRequestRecord) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+
+	if len(h.records) >= h.maxSize {
+		// Remove oldest record
+		h.records = h.records[1:]
+	}
+	h.records = append(h.records, record)
+}
+
+// GetRecords returns a copy of all records
+func (h *LLMRequestHistory) GetRecords() []LLMRequestRecord {
+	h.mu.RLock()
+	defer h.mu.RUnlock()
+
+	result := make([]LLMRequestRecord, len(h.records))
+	copy(result, h.records)
+	return result
+}
+
+// ConfigInfo is an optional interface that services can implement to provide configuration details for logging
+type ConfigInfo interface {
+	// ConfigDetails returns human-readable configuration info (e.g., URL, model name)
+	ConfigDetails() map[string]string
+}
+
+// loggingService wraps an llm.Service to log request completion with usage information
+type loggingService struct {
+	service llm.Service
+	logger  *slog.Logger
+	modelID string
+	history *LLMRequestHistory
+}
+
+// Do wraps the underlying service's Do method with logging
+func (l *loggingService) Do(ctx context.Context, request *llm.Request) (*llm.Response, error) {
+	start := time.Now()
+
+	// Call the underlying service
+	response, err := l.service.Do(ctx, request)
+
+	duration := time.Since(start)
+	durationSeconds := duration.Seconds()
+
+	// History recording now happens in the provider (e.g., ant.Service)
+	// to capture raw HTTP requests/responses
+
+	// Log the completion with usage information
+	if err != nil {
+		logAttrs := []any{
+			"model", l.modelID,
+			"duration_seconds", durationSeconds,
+		}
+
+		// Add configuration details if available
+		if configProvider, ok := l.service.(ConfigInfo); ok {
+			for k, v := range configProvider.ConfigDetails() {
+				logAttrs = append(logAttrs, k, v)
+			}
+		}
+
+		logAttrs = append(logAttrs, "error", err)
+		l.logger.Error("LLM request failed", logAttrs...)
+	} else {
+		// Log successful completion with usage info
+		logAttrs := []any{
+			"model", l.modelID,
+			"duration_seconds", durationSeconds,
+		}
+
+		// Add usage information if available
+		if !response.Usage.IsZero() {
+			logAttrs = append(logAttrs,
+				"input_tokens", response.Usage.InputTokens,
+				"output_tokens", response.Usage.OutputTokens,
+				"cost_usd", response.Usage.CostUSD,
+			)
+			if response.Usage.CacheCreationInputTokens > 0 {
+				logAttrs = append(logAttrs, "cache_creation_input_tokens", response.Usage.CacheCreationInputTokens)
+			}
+			if response.Usage.CacheReadInputTokens > 0 {
+				logAttrs = append(logAttrs, "cache_read_input_tokens", response.Usage.CacheReadInputTokens)
+			}
+		}
+
+		l.logger.Info("LLM request completed", logAttrs...)
+	}
+
+	return response, err
+}
+
+// TokenContextWindow delegates to the underlying service
+func (l *loggingService) TokenContextWindow() int {
+	return l.service.TokenContextWindow()
+}
+
+// UseSimplifiedPatch delegates to the underlying service if it supports it
+func (l *loggingService) UseSimplifiedPatch() bool {
+	if sp, ok := l.service.(llm.SimplifiedPatcher); ok {
+		return sp.UseSimplifiedPatch()
+	}
+	return false
+}
+
+// NewManager creates a new Manager with all models configured
+func NewManager(cfg *Config, history *LLMRequestHistory) (*Manager, error) {
+	manager := &Manager{
+		services: make(map[string]llm.Service),
+		logger:   cfg.Logger,
+		history:  history,
+	}
+
+	for _, model := range All() {
+		svc, err := model.Factory(cfg)
+		if err != nil {
+			// Model not available (e.g., missing API key) - skip it
+			continue
+		}
+		manager.services[model.ID] = svc
+	}
+
+	return manager, nil
+}
+
+// GetService returns the LLM service for the given model ID, wrapped with logging
+func (m *Manager) GetService(modelID string) (llm.Service, error) {
+	if svc, ok := m.services[modelID]; ok {
+		// Set HTTP recorder on ant.Service if we have history
+		if antSvc, ok := svc.(*ant.Service); ok && m.history != nil {
+			antSvc.HTTPRecorder = func(url string, requestBody, responseBody []byte, statusCode int, err error, duration time.Duration) {
+				record := LLMRequestRecord{
+					Timestamp:      time.Now().Add(-duration),
+					ModelID:        modelID,
+					URL:            url,
+					HTTPRequest:    requestBody,
+					HTTPResponse:   responseBody,
+					HTTPStatusCode: statusCode,
+					Duration:       duration.Seconds(),
+				}
+				if err != nil {
+					record.Error = err.Error()
+				}
+				m.history.Add(record)
+			}
+		}
+		// Wrap with logging if we have a logger
+		if m.logger != nil {
+			return &loggingService{
+				service: svc,
+				logger:  m.logger,
+				modelID: modelID,
+				history: m.history,
+			}, nil
+		}
+		return svc, nil
+	}
+	return nil, fmt.Errorf("unsupported model: %s", modelID)
+}
+
+// GetHistory returns the LLM request history
+func (m *Manager) GetHistory() *LLMRequestHistory {
+	return m.history
+}
+
+// GetAvailableModels returns a list of available model IDs in the same order as All()
+func (m *Manager) GetAvailableModels() []string {
+	// Return IDs in the same order as All() for consistency
+	all := All()
+	var ids []string
+	for _, model := range all {
+		if _, ok := m.services[model.ID]; ok {
+			ids = append(ids, model.ID)
+		}
+	}
+	return ids
+}
+
+// HasModel reports whether the manager has a service for the given model ID
+func (m *Manager) HasModel(modelID string) bool {
+	_, ok := m.services[modelID]
+	return ok
+}

models/models_test.go πŸ”—

@@ -0,0 +1,172 @@
+package models
+
+import (
+	"testing"
+)
+
+func TestAll(t *testing.T) {
+	models := All()
+	if len(models) == 0 {
+		t.Fatal("expected at least one model")
+	}
+
+	// Verify all models have required fields
+	for _, m := range models {
+		if m.ID == "" {
+			t.Errorf("model missing ID")
+		}
+		if m.Provider == "" {
+			t.Errorf("model %s missing Provider", m.ID)
+		}
+		if m.Factory == nil {
+			t.Errorf("model %s missing Factory", m.ID)
+		}
+	}
+}
+
+func TestByID(t *testing.T) {
+	tests := []struct {
+		id      string
+		wantID  string
+		wantNil bool
+	}{
+		{id: "qwen3-coder-fireworks", wantID: "qwen3-coder-fireworks", wantNil: false},
+		{id: "gpt-5", wantID: "gpt-5", wantNil: false},
+		{id: "claude-sonnet-4.5", wantID: "claude-sonnet-4.5", wantNil: false},
+		{id: "claude-haiku-4.5", wantID: "claude-haiku-4.5", wantNil: false},
+		{id: "claude-opus-4.5", wantID: "claude-opus-4.5", wantNil: false},
+		{id: "nonexistent", wantNil: true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.id, func(t *testing.T) {
+			m := ByID(tt.id)
+			if tt.wantNil {
+				if m != nil {
+					t.Errorf("ByID(%q) = %v, want nil", tt.id, m)
+				}
+			} else {
+				if m == nil {
+					t.Fatalf("ByID(%q) = nil, want non-nil", tt.id)
+				}
+				if m.ID != tt.wantID {
+					t.Errorf("ByID(%q).ID = %q, want %q", tt.id, m.ID, tt.wantID)
+				}
+			}
+		})
+	}
+}
+
+func TestDefault(t *testing.T) {
+	d := Default()
+	if d.ID != "claude-opus-4.5" {
+		t.Errorf("Default().ID = %q, want %q", d.ID, "claude-opus-4.5")
+	}
+}
+
+func TestIDs(t *testing.T) {
+	ids := IDs()
+	if len(ids) == 0 {
+		t.Fatal("expected at least one model ID")
+	}
+
+	// Verify all IDs are unique
+	seen := make(map[string]bool)
+	for _, id := range ids {
+		if seen[id] {
+			t.Errorf("duplicate model ID: %s", id)
+		}
+		seen[id] = true
+	}
+}
+
+func TestFactory(t *testing.T) {
+	// Test that we can create services with empty config (should fail for most models)
+	cfg := &Config{}
+
+	// Predictable should work without any config
+	m := ByID("predictable")
+	if m == nil {
+		t.Fatal("predictable model not found")
+	}
+
+	svc, err := m.Factory(cfg)
+	if err != nil {
+		t.Fatalf("predictable Factory() failed: %v", err)
+	}
+	if svc == nil {
+		t.Fatal("predictable Factory() returned nil service")
+	}
+}
+
+func TestManagerGetAvailableModelsOrder(t *testing.T) {
+	// Test that GetAvailableModels returns models in consistent order
+	cfg := &Config{}
+
+	// Create manager - should only have predictable model since no API keys
+	manager, err := NewManager(cfg, nil)
+	if err != nil {
+		t.Fatalf("NewManager failed: %v", err)
+	}
+
+	// Get available models multiple times
+	firstCall := manager.GetAvailableModels()
+	secondCall := manager.GetAvailableModels()
+	thirdCall := manager.GetAvailableModels()
+
+	// Should return at least predictable model
+	if len(firstCall) == 0 {
+		t.Fatal("expected at least one model")
+	}
+
+	// All calls should return identical order
+	if len(firstCall) != len(secondCall) || len(firstCall) != len(thirdCall) {
+		t.Errorf("calls returned different lengths: %d, %d, %d", len(firstCall), len(secondCall), len(thirdCall))
+	}
+
+	for i := range firstCall {
+		if firstCall[i] != secondCall[i] {
+			t.Errorf("call 1 and 2 differ at index %d: %q vs %q", i, firstCall[i], secondCall[i])
+		}
+		if firstCall[i] != thirdCall[i] {
+			t.Errorf("call 1 and 3 differ at index %d: %q vs %q", i, firstCall[i], thirdCall[i])
+		}
+	}
+}
+
+func TestManagerGetAvailableModelsMatchesAllOrder(t *testing.T) {
+	// Test that available models are returned in the same order as All()
+	cfg := &Config{
+		AnthropicAPIKey: "test-key",
+		OpenAIAPIKey:    "test-key",
+		GeminiAPIKey:    "test-key",
+		FireworksAPIKey: "test-key",
+	}
+
+	manager, err := NewManager(cfg, nil)
+	if err != nil {
+		t.Fatalf("NewManager failed: %v", err)
+	}
+
+	available := manager.GetAvailableModels()
+	all := All()
+
+	// Build expected order from All()
+	var expected []string
+	for _, m := range all {
+		if manager.HasModel(m.ID) {
+			expected = append(expected, m.ID)
+		}
+	}
+
+	// Should match
+	if len(available) != len(expected) {
+		t.Fatalf("available models count %d != expected count %d", len(available), len(expected))
+	}
+
+	for i := range available {
+		if available[i] != expected[i] {
+			t.Errorf("model at index %d: got %q, want %q", i, available[i], expected[i])
+		}
+	}
+}

seccomp/arch_linux_amd64.go πŸ”—

@@ -0,0 +1,13 @@
+//go:build linux && amd64
+
+package seccomp
+
+import "golang.org/x/sys/unix"
+
+const (
+	auditArch          = unix.AUDIT_ARCH_X86_64
+	sysKill            = 62
+	sysTkill           = 200
+	sysTgkill          = 234
+	sysPidfdSendSignal = 424
+)

seccomp/arch_linux_arm64.go πŸ”—

@@ -0,0 +1,13 @@
+//go:build linux && arm64
+
+package seccomp
+
+import "golang.org/x/sys/unix"
+
+const (
+	auditArch          = unix.AUDIT_ARCH_AARCH64
+	sysKill            = 129
+	sysTkill           = 130
+	sysTgkill          = 131
+	sysPidfdSendSignal = 424
+)

seccomp/seccomp_linux.go πŸ”—

@@ -0,0 +1,132 @@
+//go:build linux
+
+// Package seccomp provides a seccomp filter to prevent child processes
+// from killing the parent process.
+//
+// Note: We use raw BPF instead of github.com/seccomp/libseccomp-golang
+// because that library requires cgo and links against libseccomp.
+// This pure-Go implementation avoids the cgo dependency.
+package seccomp
+
+import (
+	"fmt"
+	"os"
+	"unsafe"
+
+	"golang.org/x/sys/unix"
+)
+
+// BPF instruction constants
+const (
+	bpfLD  = 0x00
+	bpfW   = 0x00
+	bpfABS = 0x20
+	bpfJMP = 0x05
+	bpfJEQ = 0x10
+	bpfRET = 0x06
+	bpfK   = 0x00
+)
+
+// seccomp_data offsets
+const (
+	offsetNr   = 0  // syscall number (int, 4 bytes)
+	offsetArch = 4  // architecture (u32, 4 bytes)
+	offsetArgs = 16 // args[0] starts at offset 16 (u64 each)
+)
+
+// bpfStmt creates a BPF statement (no jump targets)
+func bpfStmt(code uint16, k uint32) unix.SockFilter {
+	return unix.SockFilter{Code: code, Jt: 0, Jf: 0, K: k}
+}
+
+// bpfJump creates a BPF jump instruction
+func bpfJump(code uint16, k uint32, jt, jf uint8) unix.SockFilter {
+	return unix.SockFilter{Code: code, Jt: jt, Jf: jf, K: k}
+}
+
+// BlockKillSelf installs a seccomp filter that prevents any process from
+// sending signals to the current process via kill(2) and related syscalls
+// (tkill, tgkill).
+// This must be called before spawning child processes.
+// The filter is inherited by child processes.
+//
+// The filter is installed with SECCOMP_FILTER_FLAG_TSYNC to synchronize
+// across all threads in the process, ensuring child processes spawned
+// from any goroutine will inherit the filter.
+func BlockKillSelf() error {
+	pid := uint32(os.Getpid())
+	// Negative PID in two's complement (for blocking kill(-pid, sig) which
+	// sends signals to the process group)
+	negPid := uint32(-int32(pid))
+
+	// Build BPF filter program that blocks kill/tkill/tgkill
+	// when arg0 (target pid) matches our pid or -pid.
+	//
+	// The filter structure:
+	// 1. Load and check architecture
+	// 2. Load syscall number
+	// 3. Check if it's one of the signal-sending syscalls
+	// 4. If so, check if arg0 == our pid OR arg0 == -our pid
+	// 5. If targeting us, return EPERM; otherwise allow
+	filter := []unix.SockFilter{
+		// [0] Load architecture
+		bpfStmt(bpfLD|bpfW|bpfABS, offsetArch),
+		// [1] If not our arch, jump to allow (end of filter)
+		bpfJump(bpfJMP|bpfJEQ|bpfK, auditArch, 0, 12), // skip to ALLOW at [14]
+
+		// [2] Load syscall number
+		bpfStmt(bpfLD|bpfW|bpfABS, offsetNr),
+
+		// [3] Check for kill
+		bpfJump(bpfJMP|bpfJEQ|bpfK, sysKill, 4, 0), // match -> check pid at [8]
+		// [4] Check for tkill
+		bpfJump(bpfJMP|bpfJEQ|bpfK, sysTkill, 3, 0), // match -> check pid at [8]
+		// [5] Check for tgkill (arg0 is tgid, arg2 is tid - we check arg0)
+		bpfJump(bpfJMP|bpfJEQ|bpfK, sysTgkill, 2, 0), // match -> check pid at [8]
+
+		// [6-7] Jump to allow for non-matching syscalls
+		bpfJump(bpfJMP|bpfJEQ|bpfK, 0xFFFFFFFF, 0, 7), // never matches, always jumps to ALLOW at [14]
+		bpfStmt(bpfRET|bpfK, unix.SECCOMP_RET_ALLOW),  // [7] unreachable filler
+
+		// [8] Load first argument (target PID) - lower 32 bits
+		bpfStmt(bpfLD|bpfW|bpfABS, offsetArgs),
+		// [9] Check if target PID matches our PID (positive)
+		bpfJump(bpfJMP|bpfJEQ|bpfK, pid, 3, 0), // if our pid, jump to EPERM at [13]
+		// [10] Check if target PID matches -our PID (for process group kills)
+		bpfJump(bpfJMP|bpfJEQ|bpfK, negPid, 2, 0), // if -our pid, jump to EPERM at [13]
+
+		// [11] Not targeting us, allow
+		bpfStmt(bpfRET|bpfK, unix.SECCOMP_RET_ALLOW),
+
+		// [12] Unreachable filler
+		bpfStmt(bpfRET|bpfK, unix.SECCOMP_RET_ALLOW),
+
+		// [13] Return EPERM for signal syscalls targeting our process
+		bpfStmt(bpfRET|bpfK, unix.SECCOMP_RET_ERRNO|uint32(unix.EPERM)),
+
+		// [14] Allow the syscall
+		bpfStmt(bpfRET|bpfK, unix.SECCOMP_RET_ALLOW),
+	}
+
+	// Set NO_NEW_PRIVS to allow unprivileged seccomp
+	if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
+		return fmt.Errorf("prctl(PR_SET_NO_NEW_PRIVS): %w", err)
+	}
+
+	// Install the seccomp filter
+	prog := unix.SockFprog{
+		Len:    uint16(len(filter)),
+		Filter: &filter[0],
+	}
+
+	// Use seccomp() syscall with SECCOMP_FILTER_FLAG_TSYNC to apply the filter
+	// to all threads in the process. This ensures that child processes spawned
+	// from any goroutine (which may run on different OS threads) will inherit
+	// the filter.
+	_, _, errno := unix.Syscall(unix.SYS_SECCOMP, unix.SECCOMP_SET_MODE_FILTER, unix.SECCOMP_FILTER_FLAG_TSYNC, uintptr(unsafe.Pointer(&prog)))
+	if errno != 0 {
+		return fmt.Errorf("seccomp(SECCOMP_SET_MODE_FILTER, TSYNC): %w", errno)
+	}
+
+	return nil
+}

seccomp/seccomp_linux_test.go πŸ”—

@@ -0,0 +1,180 @@
+//go:build linux
+
+package seccomp
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"strconv"
+	"strings"
+	"syscall"
+	"testing"
+)
+
+func TestBlockKillSelf(t *testing.T) {
+	// This test must run in a subprocess because seccomp filters are inherited
+	// by child processes and cannot be removed once installed.
+	if os.Getenv("TEST_SECCOMP_SUBPROCESS") == "1" {
+		runSeccompTestSubprocess(t)
+		return
+	}
+
+	// Re-exec this test in a subprocess
+	cmd := exec.Command(os.Args[0], "-test.run=TestBlockKillSelf$", "-test.v")
+	cmd.Env = append(os.Environ(), "TEST_SECCOMP_SUBPROCESS=1")
+	output, err := cmd.CombinedOutput()
+	t.Logf("Subprocess output:\n%s", output)
+	if err != nil {
+		t.Fatalf("Subprocess failed: %v", err)
+	}
+}
+
+func runSeccompTestSubprocess(t *testing.T) {
+	pid := os.Getpid()
+	t.Logf("Running seccomp test in subprocess with PID %d", pid)
+
+	// Install the seccomp filter
+	if err := BlockKillSelf(); err != nil {
+		t.Fatalf("BlockKillSelf failed: %v", err)
+	}
+	t.Log("Seccomp filter installed")
+
+	// Now spawn a child process that tries to kill us
+	// We use a shell command because we need a separate process
+	cmd := exec.Command("sh", "-c", "kill -TERM "+strconv.Itoa(pid)+" 2>&1; echo exit_code=$?")
+	output, _ := cmd.CombinedOutput()
+	t.Logf("Kill attempt output: %s", output)
+
+	// The kill should have failed with EPERM
+	// If we're still alive, the seccomp filter worked!
+	t.Log("We survived the kill attempt!")
+
+	// Also verify we can still kill other things (like a sleep process)
+	sleepCmd := exec.Command("sleep", "60")
+	if err := sleepCmd.Start(); err != nil {
+		t.Fatalf("Failed to start sleep: %v", err)
+	}
+	sleepPid := sleepCmd.Process.Pid
+
+	// Kill the sleep process - this should work
+	if err := syscall.Kill(sleepPid, syscall.SIGTERM); err != nil {
+		t.Errorf("Failed to kill sleep process: %v", err)
+	}
+	sleepCmd.Wait()
+	t.Logf("Successfully killed sleep process %d", sleepPid)
+
+	// Try to kill ourselves directly - this should fail
+	err := syscall.Kill(pid, syscall.SIGTERM)
+	if err == nil {
+		t.Fatal("Expected kill of self to fail, but it succeeded")
+	}
+	if err != syscall.EPERM {
+		t.Fatalf("Expected EPERM, got %v", err)
+	}
+	t.Logf("Kill of self correctly returned EPERM")
+
+	// Try to kill using negative PID (process group kill) - this should also fail
+	err = syscall.Kill(-pid, syscall.SIGTERM)
+	if err == nil {
+		t.Fatal("Expected kill of -self to fail, but it succeeded")
+	}
+	if err != syscall.EPERM {
+		t.Fatalf("Expected EPERM for negative PID, got %v", err)
+	}
+	t.Logf("Kill of -self correctly returned EPERM")
+}
+
+func TestBlockKillSelf_ChildCannotKillParent(t *testing.T) {
+	// This is the main test: verify that after installing seccomp,
+	// a child process cannot kill the parent (shelley) process.
+	if os.Getenv("TEST_SECCOMP_CHILD_SUBPROCESS") == "1" {
+		runChildCannotKillParentSubprocess(t)
+		return
+	}
+
+	// Re-exec this test in a subprocess
+	cmd := exec.Command(os.Args[0], "-test.run=TestBlockKillSelf_ChildCannotKillParent$", "-test.v")
+	cmd.Env = append(os.Environ(), "TEST_SECCOMP_CHILD_SUBPROCESS=1")
+	output, err := cmd.CombinedOutput()
+	t.Logf("Subprocess output:\n%s", output)
+	if err != nil {
+		t.Fatalf("Subprocess failed: %v", err)
+	}
+}
+
+func runChildCannotKillParentSubprocess(t *testing.T) {
+	pid := os.Getpid()
+	t.Logf("Parent process PID: %d", pid)
+
+	// Install the seccomp filter BEFORE spawning children
+	if err := BlockKillSelf(); err != nil {
+		t.Fatalf("BlockKillSelf failed: %v", err)
+	}
+	t.Log("Seccomp filter installed in parent")
+
+	// Spawn a child process that tries to kill the parent using positive PID
+	// The child inherits the seccomp filter, which blocks kill(parent_pid, ...)
+	script := fmt.Sprintf(`
+echo "Child attempting to kill parent PID %d"
+kill -TERM %d 2>&1
+result=$?
+echo "kill exit code: $result"
+if [ $result -ne 0 ]; then
+    echo "SUCCESS: kill was blocked"
+    exit 0
+else
+    echo "FAILURE: kill succeeded (parent should be dead)"
+    exit 1
+fi
+`, pid, pid)
+
+	cmd := exec.Command("sh", "-c", script)
+	output, err := cmd.CombinedOutput()
+	t.Logf("Child output (positive PID):\n%s", output)
+
+	// Check that the child reported success (kill was blocked)
+	if err != nil {
+		t.Fatalf("Child process reported failure (positive PID): %v", err)
+	}
+
+	// Verify the output contains our success message
+	if !strings.Contains(string(output), "SUCCESS: kill was blocked") {
+		t.Fatalf("Expected success message in output (positive PID)")
+	}
+
+	// We're still alive!
+	t.Logf("Parent (PID %d) survived child's positive PID kill attempt", pid)
+
+	// Now test with negative PID (process group kill)
+	negScript := fmt.Sprintf(`
+echo "Child attempting to kill parent process group with PID -%d"
+kill -TERM -%d 2>&1
+result=$?
+echo "kill exit code: $result"
+if [ $result -ne 0 ]; then
+    echo "SUCCESS: kill -pid was blocked"
+    exit 0
+else
+    echo "FAILURE: kill -pid succeeded (parent should be dead)"
+    exit 1
+fi
+`, pid, pid)
+
+	negCmd := exec.Command("sh", "-c", negScript)
+	negOutput, negErr := negCmd.CombinedOutput()
+	t.Logf("Child output (negative PID):\n%s", negOutput)
+
+	// Check that the child reported success (kill was blocked)
+	if negErr != nil {
+		t.Fatalf("Child process reported failure (negative PID): %v", negErr)
+	}
+
+	// Verify the output contains our success message
+	if !strings.Contains(string(negOutput), "SUCCESS: kill -pid was blocked") {
+		t.Fatalf("Expected success message in output (negative PID)")
+	}
+
+	// We're still alive!
+	t.Logf("Parent (PID %d) survived child's negative PID kill attempt", pid)
+}

seccomp/seccomp_other.go πŸ”—

@@ -0,0 +1,9 @@
+//go:build !linux
+
+package seccomp
+
+// BlockKillSelf is a no-op on non-Linux systems.
+// Seccomp is a Linux-specific feature.
+func BlockKillSelf() error {
+	return nil
+}

server/cancel_claude_test.go πŸ”—

@@ -0,0 +1,997 @@
+package server
+
+import (
+	"context"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/db/generated"
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/llm/ant"
+)
+
+// ClaudeTestHarness extends TestHarness with Claude-specific functionality
+type ClaudeTestHarness struct {
+	t                *testing.T
+	db               *db.DB
+	server           *Server
+	cleanup          func()
+	convID           string
+	timeout          time.Duration
+	llmService       *ant.Service
+	requestTokens    []uint64 // Track total tokens for each request
+	lastMessageCount int      // Track message count after last operation
+	mu               sync.Mutex
+}
+
+// NewClaudeTestHarness creates a test harness that uses the real Claude API
+func NewClaudeTestHarness(t *testing.T) *ClaudeTestHarness {
+	t.Helper()
+
+	apiKey := os.Getenv("ANTHROPIC_API_KEY")
+	if apiKey == "" {
+		t.Skip("ANTHROPIC_API_KEY not set, skipping Claude test")
+	}
+
+	database, cleanup := setupTestDB(t)
+
+	// Create Claude service with HTTP recorder to track token usage
+	h := &ClaudeTestHarness{
+		t:             t,
+		db:            database,
+		cleanup:       cleanup,
+		timeout:       60 * time.Second, // Longer timeout for real API calls
+		requestTokens: make([]uint64, 0),
+	}
+
+	service := &ant.Service{
+		APIKey:       apiKey,
+		Model:        ant.Claude45Haiku, // Use cheaper model for testing
+		HTTPRecorder: h.recordHTTPRequest,
+	}
+	h.llmService = service
+
+	llmManager := &claudeLLMManager{service: service}
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+	// Set up tools - bash for testing tool cancellation
+	toolSetConfig := claudetool.ToolSetConfig{
+		WorkingDir:    t.TempDir(),
+		EnableBrowser: false,
+	}
+
+	server := NewServer(database, llmManager, toolSetConfig, logger, true, "", "claude", "", nil)
+	h.server = server
+
+	return h
+}
+
+// recordHTTPRequest is a callback to record HTTP requests for token tracking
+func (h *ClaudeTestHarness) recordHTTPRequest(url string, requestBody, responseBody []byte, statusCode int, err error, duration time.Duration) {
+	h.t.Logf("HTTP callback: status=%d, err=%v, responseLen=%d", statusCode, err, len(responseBody))
+
+	if statusCode != http.StatusOK || responseBody == nil {
+		return
+	}
+
+	// Parse response to get token usage (including cache tokens)
+	var resp struct {
+		Usage struct {
+			InputTokens              uint64 `json:"input_tokens"`
+			OutputTokens             uint64 `json:"output_tokens"`
+			CacheCreationInputTokens uint64 `json:"cache_creation_input_tokens"`
+			CacheReadInputTokens     uint64 `json:"cache_read_input_tokens"`
+		} `json:"usage"`
+	}
+	if jsonErr := json.Unmarshal(responseBody, &resp); jsonErr == nil {
+		// Total tokens = input + cache_creation + cache_read (this represents total context)
+		totalTokens := resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens
+		h.mu.Lock()
+		h.requestTokens = append(h.requestTokens, totalTokens)
+		h.mu.Unlock()
+		h.t.Logf("Recorded request: input=%d, cache_creation=%d, cache_read=%d, total=%d",
+			resp.Usage.InputTokens, resp.Usage.CacheCreationInputTokens, resp.Usage.CacheReadInputTokens, totalTokens)
+	} else {
+		h.t.Logf("Failed to parse response: %v", jsonErr)
+	}
+}
+
+// GetRequestTokens returns a copy of recorded request token counts
+func (h *ClaudeTestHarness) GetRequestTokens() []uint64 {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	tokens := make([]uint64, len(h.requestTokens))
+	copy(tokens, h.requestTokens)
+	return tokens
+}
+
+// VerifyTokensNonDecreasing checks that tokens don't decrease below a baseline
+// This verifies that context is being preserved across requests
+func (h *ClaudeTestHarness) VerifyTokensNonDecreasing() {
+	h.t.Helper()
+	tokens := h.GetRequestTokens()
+	if len(tokens) == 0 {
+		h.t.Log("No tokens recorded, skipping token verification")
+		return
+	}
+
+	h.t.Logf("Token progression: %v", tokens)
+
+	// Find the baseline (first substantial token count, skipping small slug generation requests)
+	// Slug generation requests have ~100-200 tokens, conversation requests have 4000+
+	var baseline uint64
+	for _, t := range tokens {
+		if t > 1000 { // Skip small requests like slug generation
+			baseline = t
+			break
+		}
+	}
+
+	if baseline == 0 {
+		h.t.Log("No substantial baseline found, skipping token verification")
+		return
+	}
+
+	// Verify no substantial request drops significantly below baseline (allow 10% variance for caching)
+	minAllowed := baseline * 9 / 10
+	for i, t := range tokens {
+		if t > 1000 && t < minAllowed { // Only check substantial requests
+			h.t.Errorf("Token count at index %d dropped significantly: %d < %d (baseline=%d)", i, t, minAllowed, baseline)
+		}
+	}
+}
+
+// Close cleans up the test harness resources
+func (h *ClaudeTestHarness) Close() {
+	h.cleanup()
+}
+
+// NewConversation starts a new conversation with Claude
+func (h *ClaudeTestHarness) NewConversation(msg, cwd string) *ClaudeTestHarness {
+	h.t.Helper()
+
+	chatReq := ChatRequest{
+		Message: msg,
+		Model:   "claude",
+		Cwd:     cwd,
+	}
+	chatBody, _ := json.Marshal(chatReq)
+
+	req := httptest.NewRequest("POST", "/api/conversations/new", strings.NewReader(string(chatBody)))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+
+	h.server.handleNewConversation(w, req)
+	if w.Code != http.StatusCreated {
+		h.t.Fatalf("NewConversation: expected status 201, got %d: %s", w.Code, w.Body.String())
+	}
+
+	var resp struct {
+		ConversationID string `json:"conversation_id"`
+	}
+	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+		h.t.Fatalf("NewConversation: failed to parse response: %v", err)
+	}
+	h.convID = resp.ConversationID
+
+	// Reset lastMessageCount - new conversation starts fresh
+	h.mu.Lock()
+	h.lastMessageCount = 0
+	h.mu.Unlock()
+
+	return h
+}
+
+// Chat sends a message to the current conversation
+func (h *ClaudeTestHarness) Chat(msg string) *ClaudeTestHarness {
+	h.t.Helper()
+
+	if h.convID == "" {
+		h.t.Fatal("Chat: no conversation started, call NewConversation first")
+	}
+
+	// Record message count before sending
+	h.mu.Lock()
+	h.lastMessageCount = len(h.GetMessagesUnsafe())
+	h.mu.Unlock()
+
+	chatReq := ChatRequest{
+		Message: msg,
+		Model:   "claude",
+	}
+	chatBody, _ := json.Marshal(chatReq)
+
+	req := httptest.NewRequest("POST", "/api/conversation/"+h.convID+"/chat", strings.NewReader(string(chatBody)))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+
+	h.server.handleChatConversation(w, req, h.convID)
+	if w.Code != http.StatusAccepted {
+		h.t.Fatalf("Chat: expected status 202, got %d: %s", w.Code, w.Body.String())
+	}
+	return h
+}
+
+// GetMessagesUnsafe gets messages without locking (internal use only)
+func (h *ClaudeTestHarness) GetMessagesUnsafe() []generated.Message {
+	var messages []generated.Message
+	h.db.Queries(context.Background(), func(q *generated.Queries) error {
+		var qerr error
+		messages, qerr = q.ListMessages(context.Background(), h.convID)
+		return qerr
+	})
+	return messages
+}
+
+// Cancel cancels the current conversation
+func (h *ClaudeTestHarness) Cancel() *ClaudeTestHarness {
+	h.t.Helper()
+
+	if h.convID == "" {
+		h.t.Fatal("Cancel: no conversation started")
+	}
+
+	req := httptest.NewRequest("POST", "/api/conversation/"+h.convID+"/cancel", nil)
+	w := httptest.NewRecorder()
+
+	h.server.handleCancelConversation(w, req, h.convID)
+	if w.Code != http.StatusOK {
+		h.t.Fatalf("Cancel: expected status 200, got %d: %s", w.Code, w.Body.String())
+	}
+	return h
+}
+
+// WaitForAgentWorking waits until the agent is working (tool call started)
+func (h *ClaudeTestHarness) WaitForAgentWorking() *ClaudeTestHarness {
+	h.t.Helper()
+
+	deadline := time.Now().Add(h.timeout)
+	for time.Now().Before(deadline) {
+		if h.isAgentWorking() {
+			return h
+		}
+		time.Sleep(100 * time.Millisecond)
+	}
+
+	h.t.Fatal("WaitForAgentWorking: timed out waiting for agent to start working")
+	return h
+}
+
+// isAgentWorking checks if the agent is currently working
+func (h *ClaudeTestHarness) isAgentWorking() bool {
+	var messages []generated.Message
+	err := h.db.Queries(context.Background(), func(q *generated.Queries) error {
+		var qerr error
+		messages, qerr = q.ListMessages(context.Background(), h.convID)
+		return qerr
+	})
+	if err != nil {
+		return false
+	}
+
+	// Look for an assistant message with tool use that doesn't have a corresponding result
+	for i := len(messages) - 1; i >= 0; i-- {
+		msg := messages[i]
+		if msg.Type != string(db.MessageTypeAgent) || msg.LlmData == nil {
+			continue
+		}
+
+		var llmMsg llm.Message
+		if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+			continue
+		}
+
+		// Check if this assistant message has tool use
+		for _, content := range llmMsg.Content {
+			if content.Type == llm.ContentTypeToolUse {
+				// Check if there's a corresponding tool result
+				hasResult := false
+				for j := i + 1; j < len(messages); j++ {
+					nextMsg := messages[j]
+					if nextMsg.Type == string(db.MessageTypeUser) && nextMsg.LlmData != nil {
+						var userMsg llm.Message
+						if err := json.Unmarshal([]byte(*nextMsg.LlmData), &userMsg); err != nil {
+							continue
+						}
+						for _, c := range userMsg.Content {
+							if c.Type == llm.ContentTypeToolResult && c.ToolUseID == content.ID {
+								hasResult = true
+								break
+							}
+						}
+					}
+					if hasResult {
+						break
+					}
+				}
+				if !hasResult {
+					return true // Tool is in progress
+				}
+			}
+		}
+	}
+
+	return false
+}
+
+// WaitResponse waits for the assistant's text response (end of turn)
+// It waits for a NEW response after the last Chat/NewConversation call
+func (h *ClaudeTestHarness) WaitResponse() string {
+	h.t.Helper()
+
+	if h.convID == "" {
+		h.t.Fatal("WaitResponse: no conversation started")
+	}
+
+	h.mu.Lock()
+	minMessageCount := h.lastMessageCount
+	h.mu.Unlock()
+
+	deadline := time.Now().Add(h.timeout)
+	for time.Now().Before(deadline) {
+		var messages []generated.Message
+		err := h.db.Queries(context.Background(), func(q *generated.Queries) error {
+			var qerr error
+			messages, qerr = q.ListMessages(context.Background(), h.convID)
+			return qerr
+		})
+		if err != nil {
+			h.t.Fatalf("WaitResponse: failed to get messages: %v", err)
+		}
+
+		// Look for an assistant message with end_of_turn that came AFTER minMessageCount
+		// Start from the end to find the most recent one
+		for i := len(messages) - 1; i >= 0 && i >= minMessageCount; i-- {
+			msg := messages[i]
+			if msg.Type != string(db.MessageTypeAgent) || msg.LlmData == nil {
+				continue
+			}
+
+			var llmMsg llm.Message
+			if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+				continue
+			}
+
+			if llmMsg.EndOfTurn {
+				for _, content := range llmMsg.Content {
+					if content.Type == llm.ContentTypeText {
+						// Update lastMessageCount for the next wait
+						h.mu.Lock()
+						h.lastMessageCount = len(messages)
+						h.mu.Unlock()
+						return content.Text
+					}
+				}
+			}
+		}
+
+		time.Sleep(100 * time.Millisecond)
+	}
+
+	h.t.Fatalf("WaitResponse: timed out waiting for response (lastMessageCount=%d)", minMessageCount)
+	return ""
+}
+
+// WaitToolResult waits for a tool result and returns its text content
+func (h *ClaudeTestHarness) WaitToolResult() string {
+	h.t.Helper()
+
+	if h.convID == "" {
+		h.t.Fatal("WaitToolResult: no conversation started")
+	}
+
+	deadline := time.Now().Add(h.timeout)
+	for time.Now().Before(deadline) {
+		var messages []generated.Message
+		err := h.db.Queries(context.Background(), func(q *generated.Queries) error {
+			var qerr error
+			messages, qerr = q.ListMessages(context.Background(), h.convID)
+			return qerr
+		})
+		if err != nil {
+			h.t.Fatalf("WaitToolResult: failed to get messages: %v", err)
+		}
+
+		for _, msg := range messages {
+			if msg.Type != string(db.MessageTypeUser) || msg.LlmData == nil {
+				continue
+			}
+
+			var llmMsg llm.Message
+			if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+				continue
+			}
+
+			for _, content := range llmMsg.Content {
+				if content.Type == llm.ContentTypeToolResult {
+					for _, result := range content.ToolResult {
+						if result.Type == llm.ContentTypeText && result.Text != "" {
+							return result.Text
+						}
+					}
+				}
+			}
+		}
+
+		time.Sleep(100 * time.Millisecond)
+	}
+
+	h.t.Fatalf("WaitToolResult: timed out waiting for tool result")
+	return ""
+}
+
+// ConversationID returns the current conversation ID
+func (h *ClaudeTestHarness) ConversationID() string {
+	return h.convID
+}
+
+// GetMessages returns all messages in the conversation
+func (h *ClaudeTestHarness) GetMessages() []generated.Message {
+	var messages []generated.Message
+	err := h.db.Queries(context.Background(), func(q *generated.Queries) error {
+		var qerr error
+		messages, qerr = q.ListMessages(context.Background(), h.convID)
+		return qerr
+	})
+	if err != nil {
+		h.t.Fatalf("GetMessages: failed to get messages: %v", err)
+	}
+	return messages
+}
+
+// HasCancelledToolResult checks if there's a cancelled tool result in the conversation
+func (h *ClaudeTestHarness) HasCancelledToolResult() bool {
+	messages := h.GetMessages()
+	for _, msg := range messages {
+		if msg.Type != string(db.MessageTypeUser) || msg.LlmData == nil {
+			continue
+		}
+
+		var llmMsg llm.Message
+		if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+			continue
+		}
+
+		for _, content := range llmMsg.Content {
+			if content.Type == llm.ContentTypeToolResult && content.ToolError {
+				for _, result := range content.ToolResult {
+					if result.Type == llm.ContentTypeText && strings.Contains(result.Text, "cancelled") {
+						return true
+					}
+				}
+			}
+		}
+	}
+	return false
+}
+
+// HasCancellationMessage checks if there's a cancellation message in the conversation
+func (h *ClaudeTestHarness) HasCancellationMessage() bool {
+	messages := h.GetMessages()
+	for _, msg := range messages {
+		if msg.Type != string(db.MessageTypeAgent) || msg.LlmData == nil {
+			continue
+		}
+
+		var llmMsg llm.Message
+		if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+			continue
+		}
+
+		for _, content := range llmMsg.Content {
+			if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "Operation cancelled") {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+// claudeLLMManager is an LLMProvider that returns the Claude service
+type claudeLLMManager struct {
+	service llm.Service
+}
+
+func (m *claudeLLMManager) GetService(modelID string) (llm.Service, error) {
+	return m.service, nil
+}
+
+func (m *claudeLLMManager) GetAvailableModels() []string {
+	return []string{"claude", "claude-haiku-4.5"}
+}
+
+func (m *claudeLLMManager) HasModel(modelID string) bool {
+	return modelID == "claude" || modelID == "claude-haiku-4.5"
+}
+
+// TestClaudeCancelDuringToolCall tests cancellation during tool execution with Claude
+func TestClaudeCancelDuringToolCall(t *testing.T) {
+	h := NewClaudeTestHarness(t)
+	defer h.Close()
+
+	// Start a conversation that triggers a slow bash command
+	h.NewConversation("Please run the bash command: sleep 10", "")
+
+	// Wait for the tool to start executing
+	h.WaitForAgentWorking()
+	t.Log("Agent is working on tool call")
+
+	// Cancel the conversation
+	h.Cancel()
+	t.Log("Cancelled conversation")
+
+	// Wait a bit for cancellation to complete
+	time.Sleep(500 * time.Millisecond)
+
+	// Verify cancellation was recorded properly
+	if !h.HasCancelledToolResult() {
+		t.Error("expected cancelled tool result to be recorded")
+	}
+
+	if !h.HasCancellationMessage() {
+		t.Error("expected cancellation message to be recorded")
+	}
+
+	messages := h.GetMessages()
+	t.Logf("Total messages after cancellation: %d", len(messages))
+
+	// Verify tokens are maintained
+	h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelDuringLLMCall tests cancellation during LLM API call with Claude
+func TestClaudeCancelDuringLLMCall(t *testing.T) {
+	h := NewClaudeTestHarness(t)
+	defer h.Close()
+
+	// Start a conversation with a message that will take some time to process
+	h.NewConversation("Please write a very detailed essay about the history of computing, covering at least 10 major milestones.", "")
+
+	// Wait briefly for the request to be sent to Claude
+	time.Sleep(500 * time.Millisecond)
+
+	// Cancel during the LLM call
+	h.Cancel()
+	t.Log("Cancelled during LLM call")
+
+	// Wait for cancellation
+	time.Sleep(500 * time.Millisecond)
+
+	// Verify cancellation message exists
+	if !h.HasCancellationMessage() {
+		t.Error("expected cancellation message to be recorded")
+	}
+
+	messages := h.GetMessages()
+	t.Logf("Total messages after cancellation: %d", len(messages))
+
+	// Verify tokens are maintained
+	h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelDuringLLMCallThenResume tests cancellation during LLM API call and then resuming
+func TestClaudeCancelDuringLLMCallThenResume(t *testing.T) {
+	h := NewClaudeTestHarness(t)
+	defer h.Close()
+
+	// Start a conversation with context we can verify later
+	h.NewConversation("Remember this code: BLUE42. Write a long essay about colors.", "")
+
+	// Wait briefly for the request to be sent to Claude
+	time.Sleep(300 * time.Millisecond)
+
+	// Cancel during the LLM call (before response arrives)
+	h.Cancel()
+	t.Log("Cancelled during LLM call")
+	time.Sleep(500 * time.Millisecond)
+
+	if !h.HasCancellationMessage() {
+		t.Error("expected cancellation message to be recorded")
+	}
+
+	tokensAfterCancel := h.GetRequestTokens()
+	t.Logf("Tokens after cancel: %v", tokensAfterCancel)
+
+	// Now resume and verify context is preserved
+	h.Chat("What was the code I asked you to remember? Just tell me the code.")
+	response := h.WaitResponse()
+	t.Logf("Response after resume: %s", response)
+
+	// Verify context was preserved - Claude should remember BLUE42
+	if !strings.Contains(strings.ToUpper(response), "BLUE42") {
+		t.Errorf("expected response to contain BLUE42, got: %s", response)
+	}
+
+	// Verify tokens are maintained
+	h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelDuringLLMCallMultipleTimes tests multiple cancellations during LLM calls
+func TestClaudeCancelDuringLLMCallMultipleTimes(t *testing.T) {
+	h := NewClaudeTestHarness(t)
+	defer h.Close()
+
+	// First: cancel during LLM call
+	h.NewConversation("Write a very long detailed story about space exploration.", "")
+	time.Sleep(300 * time.Millisecond)
+	h.Cancel()
+	t.Log("First cancel during LLM")
+	time.Sleep(500 * time.Millisecond)
+
+	// Second: cancel during LLM call again
+	h.Chat("Write a very long detailed story about ocean exploration.")
+	time.Sleep(300 * time.Millisecond)
+	h.Cancel()
+	t.Log("Second cancel during LLM")
+	time.Sleep(500 * time.Millisecond)
+
+	// Third: cancel during LLM call again
+	h.Chat("Write a very long detailed story about mountain climbing.")
+	time.Sleep(300 * time.Millisecond)
+	h.Cancel()
+	t.Log("Third cancel during LLM")
+	time.Sleep(500 * time.Millisecond)
+
+	// Now resume normally - the conversation should still work
+	h.Chat("Just say 'conversation recovered' and nothing else.")
+	response := h.WaitResponse()
+	t.Logf("Response after multiple cancels: %s", response)
+
+	// Verify the conversation is functional - response should not indicate an error
+	lowerResp := strings.ToLower(response)
+	if strings.Contains(lowerResp, "error") || strings.Contains(lowerResp, "invalid") {
+		t.Errorf("response may indicate an error: %s", response)
+	}
+
+	// Verify tokens are maintained
+	h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelDuringLLMCallAndVerifyMessageStructure verifies message structure after LLM cancellation
+func TestClaudeCancelDuringLLMCallAndVerifyMessageStructure(t *testing.T) {
+	h := NewClaudeTestHarness(t)
+	defer h.Close()
+
+	h.NewConversation("Write a very long detailed story about a wizard.", "")
+	time.Sleep(300 * time.Millisecond)
+	h.Cancel()
+	time.Sleep(500 * time.Millisecond)
+
+	// Check message structure
+	messages := h.GetMessages()
+	t.Logf("Messages after LLM cancel: %d", len(messages))
+
+	// Should have: system message, user message, cancellation message
+	// The user message should be recorded even if Claude didn't respond
+	userMessageFound := false
+	cancelMessageFound := false
+
+	for _, msg := range messages {
+		t.Logf("Message type: %s", msg.Type)
+		if msg.Type == string(db.MessageTypeUser) {
+			userMessageFound = true
+		}
+		if msg.Type == string(db.MessageTypeAgent) && msg.LlmData != nil {
+			var llmMsg llm.Message
+			if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err == nil {
+				for _, content := range llmMsg.Content {
+					if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "cancelled") {
+						cancelMessageFound = true
+					}
+				}
+			}
+		}
+	}
+
+	if !userMessageFound {
+		t.Error("expected user message to be recorded")
+	}
+	if !cancelMessageFound {
+		t.Error("expected cancellation message to be recorded")
+	}
+
+	// Now send a follow-up and verify no API errors about message format
+	h.Chat("Just say hello.")
+	response := h.WaitResponse()
+	t.Logf("Follow-up response: %s", response)
+
+	// Response should not indicate an error
+	lowerResp := strings.ToLower(response)
+	if strings.Contains(lowerResp, "error") || strings.Contains(lowerResp, "invalid") {
+		t.Errorf("response may indicate API error: %s", response)
+	}
+
+	h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeResumeAfterCancellation tests that a conversation can be resumed after cancellation
+func TestClaudeResumeAfterCancellation(t *testing.T) {
+	h := NewClaudeTestHarness(t)
+	defer h.Close()
+
+	// Start a conversation
+	h.NewConversation("Please run: sleep 5", "")
+
+	// Wait for tool to start
+	h.WaitForAgentWorking()
+	t.Log("Agent started tool call")
+
+	// Cancel
+	h.Cancel()
+	t.Log("Cancelled")
+	time.Sleep(500 * time.Millisecond)
+
+	// Verify cancellation
+	if !h.HasCancellationMessage() {
+		t.Error("expected cancellation message")
+	}
+
+	messagesAfterCancel := len(h.GetMessages())
+	t.Logf("Messages after cancel: %d", messagesAfterCancel)
+
+	// Resume the conversation
+	h.Chat("Hello, let's continue. Please just say 'resumed' and nothing else.")
+
+	// Wait for response
+	response := h.WaitResponse()
+	t.Logf("Response after resume: %s", response)
+
+	// Verify we got more messages
+	messagesAfterResume := len(h.GetMessages())
+	t.Logf("Messages after resume: %d", messagesAfterResume)
+
+	if messagesAfterResume <= messagesAfterCancel {
+		t.Error("expected more messages after resume")
+	}
+
+	// Verify tokens are maintained
+	h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeTokensMonotonicallyIncreasing tests that token count increases when resuming
+// With prompt caching, total tokens = input + cache_creation + cache_read
+func TestClaudeTokensMonotonicallyIncreasing(t *testing.T) {
+	h := NewClaudeTestHarness(t)
+	defer h.Close()
+
+	// First conversation turn
+	h.NewConversation("Hello, please respond with 'first response' and nothing else.", "")
+	h.WaitResponse()
+	time.Sleep(500 * time.Millisecond) // Wait for any pending operations
+
+	tokens1 := h.GetRequestTokens()
+	if len(tokens1) == 0 {
+		t.Skip("No token data recorded (API may not be returning it)")
+	}
+	lastToken1 := tokens1[len(tokens1)-1]
+	t.Logf("First turn total tokens: %d", lastToken1)
+
+	// Second conversation turn
+	h.Chat("Now please respond with 'second response' and nothing else.")
+	h.WaitResponse()
+	time.Sleep(500 * time.Millisecond)
+
+	tokens2 := h.GetRequestTokens()
+	if len(tokens2) <= len(tokens1) {
+		t.Fatal("expected more requests in second turn")
+	}
+	lastToken2 := tokens2[len(tokens2)-1]
+	t.Logf("Second turn total tokens: %d", lastToken2)
+
+	// With prompt caching, tokens should increase or stay similar
+	// The key is that we're still sending context (total should be meaningful)
+	if lastToken2 < lastToken1 {
+		t.Errorf("tokens decreased significantly: first=%d, second=%d", lastToken1, lastToken2)
+	}
+
+	// Third turn
+	h.Chat("Third turn - respond with 'third response' only.")
+	h.WaitResponse()
+	time.Sleep(500 * time.Millisecond)
+
+	tokens3 := h.GetRequestTokens()
+	if len(tokens3) <= len(tokens2) {
+		t.Fatal("expected more requests in third turn")
+	}
+	lastToken3 := tokens3[len(tokens3)-1]
+	t.Logf("Third turn total tokens: %d", lastToken3)
+
+	// Each subsequent turn should have at least as many tokens as the first turn
+	// (because we're including more conversation history)
+	if lastToken3 < lastToken1 {
+		t.Errorf("third turn has fewer tokens than first: first=%d, third=%d", lastToken1, lastToken3)
+	}
+
+	t.Logf("Token progression: %d -> %d -> %d", lastToken1, lastToken2, lastToken3)
+}
+
+// TestClaudeResumeAfterCancellationPreservesContext tests context preservation after cancellation
+func TestClaudeResumeAfterCancellationPreservesContext(t *testing.T) {
+	h := NewClaudeTestHarness(t)
+	defer h.Close()
+
+	// Start with specific context
+	h.NewConversation("Remember this secret word: ELEPHANT. I will ask you about it later. For now, just acknowledge with 'understood'.", "")
+	response1 := h.WaitResponse()
+	t.Logf("First response: %s", response1)
+
+	tokens1 := h.GetRequestTokens()
+	if len(tokens1) == 0 {
+		t.Skip("No token data recorded")
+	}
+	t.Logf("Tokens after first exchange: %v", tokens1)
+
+	// Start a slow command to trigger cancellation
+	h.Chat("Run this command: sleep 10")
+	h.WaitForAgentWorking()
+
+	// Cancel
+	h.Cancel()
+	time.Sleep(500 * time.Millisecond)
+
+	tokensAfterCancel := h.GetRequestTokens()
+	t.Logf("Tokens after cancel: %v", tokensAfterCancel)
+
+	// Resume and ask about the secret word
+	h.Chat("What was the secret word I told you to remember?")
+	response2 := h.WaitResponse()
+	t.Logf("Response after resume: %s", response2)
+
+	tokensAfterResume := h.GetRequestTokens()
+	t.Logf("Tokens after resume: %v", tokensAfterResume)
+
+	// Check that the response mentions ELEPHANT
+	if !strings.Contains(strings.ToUpper(response2), "ELEPHANT") {
+		t.Errorf("expected response to mention ELEPHANT, got: %s", response2)
+	}
+
+	// Verify tokens are maintained
+	h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeMultipleCancellations tests multiple cancellations in a row
+func TestClaudeMultipleCancellations(t *testing.T) {
+	h := NewClaudeTestHarness(t)
+	defer h.Close()
+
+	// First cancellation
+	h.NewConversation("Run: sleep 10", "")
+	h.WaitForAgentWorking()
+	h.Cancel()
+	time.Sleep(300 * time.Millisecond)
+
+	if !h.HasCancellationMessage() {
+		t.Error("expected first cancellation message")
+	}
+
+	// Second cancellation
+	h.Chat("Run: sleep 10")
+	time.Sleep(2 * time.Second) // Wait for Claude to respond and start tool
+	h.Cancel()
+	time.Sleep(300 * time.Millisecond)
+
+	// Third: complete normally
+	h.Chat("Just say 'done' and nothing else.")
+	response := h.WaitResponse()
+	t.Logf("Final response: %s", response)
+
+	// Verify tokens are maintained
+	h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelImmediately tests cancelling immediately after sending a message
+func TestClaudeCancelImmediately(t *testing.T) {
+	h := NewClaudeTestHarness(t)
+	defer h.Close()
+
+	h.NewConversation("Write a very long essay about everything.", "")
+
+	// Cancel immediately
+	time.Sleep(50 * time.Millisecond)
+	h.Cancel()
+
+	time.Sleep(500 * time.Millisecond)
+
+	// Should still be able to resume
+	h.Chat("Just say 'hello'")
+	response := h.WaitResponse()
+	t.Logf("Response after immediate cancel: %s", response)
+
+	if response == "" {
+		t.Error("expected a response after resuming from immediate cancel")
+	}
+
+	// Verify tokens are maintained
+	h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelWithPendingToolResult tests that missing tool results are handled properly
+func TestClaudeCancelWithPendingToolResult(t *testing.T) {
+	h := NewClaudeTestHarness(t)
+	defer h.Close()
+
+	// This tests the insertMissingToolResults logic
+	h.NewConversation("Run: sleep 20", "")
+	h.WaitForAgentWorking()
+
+	// Cancel during tool execution
+	h.Cancel()
+	time.Sleep(500 * time.Millisecond)
+
+	// Resume - this should handle the missing tool result
+	h.Chat("Please just say 'recovered' if you can hear me.")
+	response := h.WaitResponse()
+	t.Logf("Recovery response: %s", response)
+
+	// The conversation should have recovered
+	// Claude should not complain about bad messages
+	if strings.Contains(strings.ToLower(response), "error") {
+		t.Errorf("response indicates an error, which may mean message handling failed: %s", response)
+	}
+
+	// Verify tokens are maintained
+	h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelDuringLLMCallRapidFire tests rapid cancellations during LLM calls
+func TestClaudeCancelDuringLLMCallRapidFire(t *testing.T) {
+	h := NewClaudeTestHarness(t)
+	defer h.Close()
+
+	// Send message and cancel as fast as possible, multiple times
+	for i := 0; i < 3; i++ {
+		if i == 0 {
+			h.NewConversation("Write a long story.", "")
+		} else {
+			h.Chat("Write another long story.")
+		}
+		time.Sleep(100 * time.Millisecond)
+		h.Cancel()
+		time.Sleep(200 * time.Millisecond)
+		t.Logf("Rapid cancel %d complete", i+1)
+	}
+
+	// Now do a normal conversation
+	h.Chat("Just say 'stable' and nothing else.")
+	response := h.WaitResponse()
+	t.Logf("Final response after rapid cancels: %s", response)
+
+	// Verify tokens are maintained
+	h.VerifyTokensNonDecreasing()
+}
+
+// TestClaudeCancelDuringLLMCallWithToolUseResponse tests cancel when Claude is about to use a tool
+func TestClaudeCancelDuringLLMCallWithToolUseResponse(t *testing.T) {
+	h := NewClaudeTestHarness(t)
+	defer h.Close()
+
+	// Ask Claude to use a tool - the response will contain tool_use
+	// Cancel before the tool actually executes
+	h.NewConversation("Run: echo hello world", "")
+
+	// Wait just enough for the LLM request to be sent but not for tool execution
+	time.Sleep(500 * time.Millisecond)
+
+	// Cancel - this might catch the LLM responding with tool_use but before tool execution
+	h.Cancel()
+	time.Sleep(500 * time.Millisecond)
+
+	t.Logf("Cancelled during potential tool_use response")
+
+	// Resume and verify conversation works
+	h.Chat("Just say 'ok' if you can hear me.")
+	response := h.WaitResponse()
+	t.Logf("Response: %s", response)
+
+	// Verify tokens are maintained
+	h.VerifyTokensNonDecreasing()
+}

server/cancel_test.go πŸ”—

@@ -0,0 +1,376 @@
+package server
+
+import (
+	"context"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/db/generated"
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/loop"
+)
+
+// setupTestDB creates a test database
+func setupTestDB(t *testing.T) (*db.DB, func()) {
+	t.Helper()
+	tmpDir := t.TempDir()
+	database, err := db.New(db.Config{DSN: tmpDir + "/test.db"})
+	if err != nil {
+		t.Fatalf("Failed to create test database: %v", err)
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	if err := database.Migrate(ctx); err != nil {
+		t.Fatalf("Failed to migrate test database: %v", err)
+	}
+
+	return database, func() {
+		database.Close()
+	}
+}
+
+// TestCancelWithPredictableModel tests cancellation with the predictable model
+func TestCancelWithPredictableModel(t *testing.T) {
+	// Create test database
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.Default()
+
+	// Register the bash tool so the sleep command actually runs and can be cancelled
+	toolSetConfig := claudetool.ToolSetConfig{EnableBrowser: false}
+	server := NewServer(database, llmManager, toolSetConfig, logger, true, "", "predictable", "", nil)
+
+	// Create conversation
+	conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+	if err != nil {
+		t.Fatalf("failed to create conversation: %v", err)
+	}
+	conversationID := conversation.ConversationID
+
+	// Start a conversation with a message that triggers a slow bash command
+	chatReq := ChatRequest{
+		Message: "bash: sleep 5",
+		Model:   "predictable",
+	}
+	chatBody, _ := json.Marshal(chatReq)
+
+	req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+
+	server.handleChatConversation(w, req, conversationID)
+
+	if w.Code != http.StatusAccepted {
+		t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+	}
+
+	// Wait for the tool to start executing
+	time.Sleep(300 * time.Millisecond)
+
+	// Verify agent is working
+	var messages []generated.Message
+	err = database.Queries(context.Background(), func(q *generated.Queries) error {
+		var qerr error
+		messages, qerr = q.ListMessages(context.Background(), conversationID)
+		return qerr
+	})
+	if err != nil {
+		t.Fatalf("failed to get messages: %v", err)
+	}
+
+	// Should have user message and assistant message with tool use
+	if len(messages) < 2 {
+		t.Fatalf("expected at least 2 messages, got %d", len(messages))
+	}
+
+	// Cancel the conversation
+	cancelReq := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/cancel", nil)
+	cancelW := httptest.NewRecorder()
+
+	server.handleCancelConversation(cancelW, cancelReq, conversationID)
+
+	if cancelW.Code != http.StatusOK {
+		t.Fatalf("expected status 200, got %d: %s", cancelW.Code, cancelW.Body.String())
+	}
+
+	var cancelResp map[string]string
+	if err := json.Unmarshal(cancelW.Body.Bytes(), &cancelResp); err != nil {
+		t.Fatalf("failed to parse cancel response: %v", err)
+	}
+
+	if cancelResp["status"] != "cancelled" {
+		t.Errorf("expected status 'cancelled', got '%s'", cancelResp["status"])
+	}
+
+	// Wait for cancellation to complete and cancelled message to be recorded
+	time.Sleep(300 * time.Millisecond)
+
+	// Verify that a cancelled tool result was recorded
+	err = database.Queries(context.Background(), func(q *generated.Queries) error {
+		var qerr error
+		messages, qerr = q.ListMessages(context.Background(), conversationID)
+		return qerr
+	})
+	if err != nil {
+		t.Fatalf("failed to get messages after cancel: %v", err)
+	}
+
+	// Should have: user message, assistant message with tool use, cancelled tool result, and end turn message
+	if len(messages) < 4 {
+		t.Fatalf("expected at least 4 messages after cancel, got %d", len(messages))
+	}
+
+	// Check that we have the cancelled tool result
+	foundCancelledResult := false
+	foundEndTurnMessage := false
+	for i := len(messages) - 1; i >= 0; i-- {
+		msg := messages[i]
+		if msg.LlmData == nil {
+			continue
+		}
+
+		var llmMsg llm.Message
+		if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+			continue
+		}
+
+		// Check for cancelled tool result
+		for _, content := range llmMsg.Content {
+			if content.Type == llm.ContentTypeToolResult && content.ToolError {
+				for _, result := range content.ToolResult {
+					if result.Type == llm.ContentTypeText && strings.Contains(result.Text, "cancelled") {
+						foundCancelledResult = true
+						break
+					}
+				}
+			}
+		}
+
+		// Check for end turn message
+		if msg.Type == string(db.MessageTypeAgent) && llmMsg.EndOfTurn {
+			for _, content := range llmMsg.Content {
+				if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "Operation cancelled") {
+					foundEndTurnMessage = true
+					break
+				}
+			}
+		}
+	}
+
+	if !foundCancelledResult {
+		t.Error("expected to find cancelled tool result in conversation")
+	}
+
+	if !foundEndTurnMessage {
+		t.Error("expected to find end turn message after cancellation")
+	}
+
+	// Test that conversation can be resumed after cancellation
+	resumeReq := ChatRequest{
+		Message: "echo: test after cancel",
+		Model:   "predictable",
+	}
+	resumeBody, _ := json.Marshal(resumeReq)
+
+	resumeChatReq := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(resumeBody)))
+	resumeChatReq.Header.Set("Content-Type", "application/json")
+	resumeW := httptest.NewRecorder()
+
+	server.handleChatConversation(resumeW, resumeChatReq, conversationID)
+
+	if resumeW.Code != http.StatusAccepted {
+		t.Fatalf("expected status 202 for resume, got %d: %s", resumeW.Code, resumeW.Body.String())
+	}
+
+	// Wait for the response
+	time.Sleep(300 * time.Millisecond)
+
+	// Verify conversation continued
+	err = database.Queries(context.Background(), func(q *generated.Queries) error {
+		var qerr error
+		messages, qerr = q.ListMessages(context.Background(), conversationID)
+		return qerr
+	})
+	if err != nil {
+		t.Fatalf("failed to get messages after resume: %v", err)
+	}
+
+	// Should have additional messages from the resumed conversation
+	if len(messages) < 5 {
+		t.Fatalf("expected at least 5 messages after resume, got %d", len(messages))
+	}
+
+	// Check that we got the expected response
+	foundContinueResponse := false
+	for _, msg := range messages {
+		if msg.Type != string(db.MessageTypeAgent) {
+			continue
+		}
+		if msg.LlmData == nil {
+			continue
+		}
+		var llmMsg llm.Message
+		if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+			continue
+		}
+		for _, content := range llmMsg.Content {
+			if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "test after cancel") {
+				foundContinueResponse = true
+				break
+			}
+		}
+	}
+
+	if !foundContinueResponse {
+		t.Error("expected to find 'test after cancel' response")
+	}
+}
+
+// TestCancelWithNoActiveConversation tests cancelling when there's no active conversation
+func TestCancelWithNoActiveConversation(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.Default()
+
+	server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+	// Create a conversation but don't start it
+	conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+	if err != nil {
+		t.Fatalf("failed to create conversation: %v", err)
+	}
+	conversationID := conversation.ConversationID
+
+	// Try to cancel without any active loop
+	cancelReq := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/cancel", nil)
+	cancelW := httptest.NewRecorder()
+
+	server.handleCancelConversation(cancelW, cancelReq, conversationID)
+
+	if cancelW.Code != http.StatusOK {
+		t.Fatalf("expected status 200, got %d: %s", cancelW.Code, cancelW.Body.String())
+	}
+
+	var cancelResp map[string]string
+	if err := json.Unmarshal(cancelW.Body.Bytes(), &cancelResp); err != nil {
+		t.Fatalf("failed to parse cancel response: %v", err)
+	}
+
+	if cancelResp["status"] != "no_active_conversation" {
+		t.Errorf("expected status 'no_active_conversation', got '%s'", cancelResp["status"])
+	}
+}
+
+// TestCancelDuringTextGeneration tests cancelling during text generation (no tool call)
+func TestCancelDuringTextGeneration(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	// Use delay: prefix to trigger slow response
+	predictableService := loop.NewPredictableService()
+
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.Default()
+	server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+	conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+	if err != nil {
+		t.Fatalf("failed to create conversation: %v", err)
+	}
+	conversationID := conversation.ConversationID
+
+	// Start conversation with a delay to simulate slow text generation
+	chatReq := ChatRequest{
+		Message: "delay: 2",
+		Model:   "predictable",
+	}
+	chatBody, _ := json.Marshal(chatReq)
+
+	req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+
+	server.handleChatConversation(w, req, conversationID)
+
+	if w.Code != http.StatusAccepted {
+		t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+	}
+
+	// Wait briefly for processing to start
+	time.Sleep(100 * time.Millisecond)
+
+	// Cancel during text generation
+	cancelReq := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/cancel", nil)
+	cancelW := httptest.NewRecorder()
+
+	server.handleCancelConversation(cancelW, cancelReq, conversationID)
+
+	if cancelW.Code != http.StatusOK {
+		t.Fatalf("expected status 200, got %d: %s", cancelW.Code, cancelW.Body.String())
+	}
+
+	// Wait for cancellation
+	time.Sleep(200 * time.Millisecond)
+
+	// Verify that no cancelled tool result was added (since there was no tool call)
+	var messages []generated.Message
+	err = database.Queries(context.Background(), func(q *generated.Queries) error {
+		var qerr error
+		messages, qerr = q.ListMessages(context.Background(), conversationID)
+		return qerr
+	})
+	if err != nil {
+		t.Fatalf("failed to get messages: %v", err)
+	}
+
+	// Should only have user message (and possibly incomplete assistant message)
+	// Should NOT have a tool result message
+	for _, msg := range messages {
+		if msg.Type == string(db.MessageTypeUser) {
+			if msg.LlmData == nil {
+				continue
+			}
+			var llmMsg llm.Message
+			if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+				continue
+			}
+			for _, content := range llmMsg.Content {
+				if content.Type == llm.ContentTypeToolResult {
+					t.Error("did not expect tool result when cancelling during text generation")
+				}
+			}
+		}
+	}
+}
+
+// testLLMManager is a simple test implementation of LLMProvider
+type testLLMManager struct {
+	service llm.Service
+}
+
+func (m *testLLMManager) GetService(modelID string) (llm.Service, error) {
+	return m.service, nil
+}
+
+func (m *testLLMManager) GetAvailableModels() []string {
+	return []string{"predictable"}
+}
+
+func (m *testLLMManager) HasModel(modelID string) bool {
+	return modelID == "predictable"
+}

server/context_window_test.go πŸ”—

@@ -0,0 +1,163 @@
+package server
+
+import (
+	"encoding/json"
+	"testing"
+
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/llm"
+)
+
+// TestContextWindowSizeCalculation tests that the context window size is correctly
+// calculated including cached tokens.
+func TestContextWindowSizeCalculation(t *testing.T) {
+	// Test the calculateContextWindowSize function directly
+	t.Run("includes_all_token_types", func(t *testing.T) {
+		// Create usage data with all token types
+		usage := llm.Usage{
+			InputTokens:              100,
+			CacheCreationInputTokens: 50,
+			CacheReadInputTokens:     200,
+			OutputTokens:             30,
+		}
+		usageJSON, _ := json.Marshal(usage)
+		usageStr := string(usageJSON)
+
+		messages := []APIMessage{
+			{
+				Type:      string(db.MessageTypeAgent),
+				UsageData: &usageStr,
+			},
+		}
+
+		// Expected: 100 + 50 + 200 + 30 = 380
+		got := calculateContextWindowSize(messages)
+		want := uint64(380)
+
+		if got != want {
+			t.Errorf("calculateContextWindowSize() = %d, want %d", got, want)
+		}
+	})
+
+	t.Run("only_input_tokens", func(t *testing.T) {
+		// Test with just input tokens (no caching)
+		usage := llm.Usage{
+			InputTokens:  150,
+			OutputTokens: 50,
+		}
+		usageJSON, _ := json.Marshal(usage)
+		usageStr := string(usageJSON)
+
+		messages := []APIMessage{
+			{
+				Type:      string(db.MessageTypeAgent),
+				UsageData: &usageStr,
+			},
+		}
+
+		// Expected: 150 + 50 = 200
+		got := calculateContextWindowSize(messages)
+		want := uint64(200)
+
+		if got != want {
+			t.Errorf("calculateContextWindowSize() = %d, want %d", got, want)
+		}
+	})
+
+	t.Run("uses_last_message_with_usage", func(t *testing.T) {
+		// Test that we use the last message, not the first
+		usage1 := llm.Usage{
+			InputTokens:  100,
+			OutputTokens: 50,
+		}
+		usage1JSON, _ := json.Marshal(usage1)
+		usage1Str := string(usage1JSON)
+
+		usage2 := llm.Usage{
+			InputTokens:          200,
+			CacheReadInputTokens: 100,
+			OutputTokens:         75,
+		}
+		usage2JSON, _ := json.Marshal(usage2)
+		usage2Str := string(usage2JSON)
+
+		messages := []APIMessage{
+			{
+				Type:      string(db.MessageTypeAgent),
+				UsageData: &usage1Str,
+			},
+			{
+				Type:      string(db.MessageTypeUser),
+				UsageData: nil, // User messages typically don't have usage
+			},
+			{
+				Type:      string(db.MessageTypeAgent),
+				UsageData: &usage2Str,
+			},
+		}
+
+		// Expected: 200 + 100 + 75 = 375 (from the last message)
+		got := calculateContextWindowSize(messages)
+		want := uint64(375)
+
+		if got != want {
+			t.Errorf("calculateContextWindowSize() = %d, want %d", got, want)
+		}
+	})
+
+	t.Run("empty_messages", func(t *testing.T) {
+		messages := []APIMessage{}
+		got := calculateContextWindowSize(messages)
+		want := uint64(0)
+
+		if got != want {
+			t.Errorf("calculateContextWindowSize() = %d, want %d", got, want)
+		}
+	})
+}
+
+// TestContextWindowGrowsWithConversation tests that the context window size grows
+// as the conversation progresses, using the test harness and predictable service.
+func TestContextWindowGrowsWithConversation(t *testing.T) {
+	h := NewTestHarness(t)
+	defer h.Close()
+
+	// Start a new conversation
+	h.NewConversation("echo: first message", "/tmp")
+
+	// Wait for the response
+	resp1 := h.WaitResponse()
+	t.Logf("First response: %q", resp1)
+
+	// Get the context window size from the first message
+	firstSize := h.GetContextWindowSize()
+	t.Logf("First context window size: %d", firstSize)
+	if firstSize == 0 {
+		t.Fatal("expected non-zero context window size after first message")
+	}
+
+	// Send another message
+	h.Chat("echo: second message that is longer")
+	resp2 := h.WaitResponse()
+	t.Logf("Second response: %q", resp2)
+
+	// Context window should have grown
+	secondSize := h.GetContextWindowSize()
+	t.Logf("Second context window size: %d", secondSize)
+	if secondSize <= firstSize {
+		t.Errorf("context window should grow: first=%d, second=%d", firstSize, secondSize)
+	}
+
+	// Send a third message
+	h.Chat("echo: third message with even more text to demonstrate growth")
+	resp3 := h.WaitResponse()
+	t.Logf("Third response: %q", resp3)
+
+	thirdSize := h.GetContextWindowSize()
+	t.Logf("Third context window size: %d", thirdSize)
+	if thirdSize <= secondSize {
+		t.Errorf("context window should grow: second=%d, third=%d", secondSize, thirdSize)
+	}
+
+	t.Logf("Context window sizes: first=%d, second=%d, third=%d", firstSize, secondSize, thirdSize)
+}

server/conversation_by_slug_test.go πŸ”—

@@ -0,0 +1,92 @@
+package server
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"shelley.exe.dev/db/generated"
+)
+
+func TestGetConversationBySlug(t *testing.T) {
+	h := NewTestHarness(t)
+	defer h.Close()
+
+	// Create a conversation with a slug
+	slug := "my-test-slug"
+	conv, err := h.db.CreateConversation(t.Context(), &slug, true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create conversation: %v", err)
+	}
+
+	mux := http.NewServeMux()
+	h.server.RegisterRoutes(mux)
+
+	// Test successful lookup
+	req := httptest.NewRequest("GET", "/api/conversation-by-slug/"+slug, nil)
+	rec := httptest.NewRecorder()
+	mux.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusOK {
+		t.Errorf("Expected status 200, got %d: %s", rec.Code, rec.Body.String())
+	}
+
+	var result generated.Conversation
+	if err := json.NewDecoder(rec.Body).Decode(&result); err != nil {
+		t.Fatalf("Failed to decode response: %v", err)
+	}
+
+	if result.ConversationID != conv.ConversationID {
+		t.Errorf("Expected conversation ID %s, got %s", conv.ConversationID, result.ConversationID)
+	}
+
+	// Test non-existent slug
+	req = httptest.NewRequest("GET", "/api/conversation-by-slug/non-existent-slug", nil)
+	rec = httptest.NewRecorder()
+	mux.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusNotFound {
+		t.Errorf("Expected status 404, got %d: %s", rec.Code, rec.Body.String())
+	}
+
+	// Test empty slug
+	req = httptest.NewRequest("GET", "/api/conversation-by-slug/", nil)
+	rec = httptest.NewRecorder()
+	mux.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusBadRequest {
+		t.Errorf("Expected status 400, got %d: %s", rec.Code, rec.Body.String())
+	}
+}
+
+func TestIsConversationSlugPath(t *testing.T) {
+	tests := []struct {
+		path   string
+		expect bool
+	}{
+		// Should NOT be treated as slugs
+		{"/", false},
+		{"/api/conversations", false},
+		{"/api/conversation/abc", false},
+		{"/debug/llm", false},
+		{"/main.js", false},
+		{"/styles.css", false},
+		{"/index.html", false},
+		{"/version", false},
+		{"/my-conversation", false}, // not in /c/ namespace
+		{"/hello-world", false},
+		// Should be treated as slugs (must be under /c/)
+		{"/c/my-conversation", true},
+		{"/c/hello-world", true},
+		{"/c/fix-the-bug", true},
+		{"/c/c123abc", true},
+	}
+
+	for _, tt := range tests {
+		got := isConversationSlugPath(tt.path)
+		if got != tt.expect {
+			t.Errorf("isConversationSlugPath(%q) = %v, want %v", tt.path, got, tt.expect)
+		}
+	}
+}

server/conversation_flow_test.go πŸ”—

@@ -0,0 +1,292 @@
+package server
+
+import (
+	"context"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/db/generated"
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/loop"
+)
+
+// TestMessageQueuedDuringThinking tests that messages sent while the LLM is
+// processing (thinking/tool execution) are properly queued and eventually processed.
+func TestMessageQueuedDuringThinking(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.Default()
+	server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+	// Create conversation
+	conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+	if err != nil {
+		t.Fatalf("failed to create conversation: %v", err)
+	}
+	conversationID := conversation.ConversationID
+
+	// Send first message that triggers a slow response via "delay:" prefix
+	chatReq := ChatRequest{
+		Message: "delay: 2",
+		Model:   "predictable",
+	}
+	chatBody, _ := json.Marshal(chatReq)
+
+	req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+
+	server.handleChatConversation(w, req, conversationID)
+	if w.Code != http.StatusAccepted {
+		t.Fatalf("expected status 202 for first message, got %d: %s", w.Code, w.Body.String())
+	}
+
+	// Wait for the LLM to start processing (but still be in the delay)
+	time.Sleep(200 * time.Millisecond)
+
+	// Now send a SECOND message while the first is still processing
+	// This is the bug: this message should be immediately recorded and visible,
+	// not lost until the first message finishes processing
+	secondReq := ChatRequest{
+		Message: "echo: second message while thinking",
+		Model:   "predictable",
+	}
+	secondBody, _ := json.Marshal(secondReq)
+
+	req2 := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(secondBody)))
+	req2.Header.Set("Content-Type", "application/json")
+	w2 := httptest.NewRecorder()
+
+	server.handleChatConversation(w2, req2, conversationID)
+	if w2.Code != http.StatusAccepted {
+		t.Fatalf("expected status 202 for second message, got %d: %s", w2.Code, w2.Body.String())
+	}
+
+	// The second message should be recorded in the database IMMEDIATELY
+	// (or at least very soon), not waiting for the first message to finish
+	// Wait a short time for the message to be recorded
+	time.Sleep(100 * time.Millisecond)
+
+	var messages []generated.Message
+	err = database.Queries(context.Background(), func(q *generated.Queries) error {
+		var qerr error
+		messages, qerr = q.ListMessages(context.Background(), conversationID)
+		return qerr
+	})
+	if err != nil {
+		t.Fatalf("failed to get messages: %v", err)
+	}
+
+	// Look for the second user message in the database
+	foundSecondUserMessage := false
+	for _, msg := range messages {
+		if msg.Type != string(db.MessageTypeUser) {
+			continue
+		}
+		if msg.LlmData == nil {
+			continue
+		}
+		var llmMsg llm.Message
+		if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+			continue
+		}
+		for _, content := range llmMsg.Content {
+			if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "second message while thinking") {
+				foundSecondUserMessage = true
+				break
+			}
+		}
+	}
+
+	if !foundSecondUserMessage {
+		t.Error("BUG: second user message sent during LLM processing was not immediately recorded to database")
+		t.Logf("Found %d messages total:", len(messages))
+		for i, msg := range messages {
+			t.Logf("  Message %d: type=%s", i, msg.Type)
+		}
+	}
+
+	// Wait for everything to complete
+	deadline := time.Now().Add(5 * time.Second)
+	for time.Now().Before(deadline) {
+		err = database.Queries(context.Background(), func(q *generated.Queries) error {
+			var qerr error
+			messages, qerr = q.ListMessages(context.Background(), conversationID)
+			return qerr
+		})
+		if err != nil {
+			t.Fatalf("failed to get messages: %v", err)
+		}
+		// Look for response to second message
+		for _, msg := range messages {
+			if msg.Type != string(db.MessageTypeAgent) || msg.LlmData == nil {
+				continue
+			}
+			var llmMsg llm.Message
+			if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+				continue
+			}
+			for _, content := range llmMsg.Content {
+				if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "second message while thinking") {
+					// Found the response
+					return
+				}
+			}
+		}
+		time.Sleep(100 * time.Millisecond)
+	}
+	t.Error("timed out waiting for response to second message")
+}
+
+// TestContextPreservedAfterCancel tests that conversation context is properly
+// preserved after cancellation and the conversation can be resumed correctly.
+func TestContextPreservedAfterCancel(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.Default()
+	server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+	// Create conversation
+	conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+	if err != nil {
+		t.Fatalf("failed to create conversation: %v", err)
+	}
+	conversationID := conversation.ConversationID
+
+	// Send first message and let it complete
+	chatReq := ChatRequest{
+		Message: "echo: initial context message",
+		Model:   "predictable",
+	}
+	chatBody, _ := json.Marshal(chatReq)
+
+	req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+
+	server.handleChatConversation(w, req, conversationID)
+	if w.Code != http.StatusAccepted {
+		t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+	}
+
+	// Wait for first message to complete
+	time.Sleep(300 * time.Millisecond)
+
+	// Now start a slow operation and cancel it
+	slowReq := ChatRequest{
+		Message: "bash: sleep 5",
+		Model:   "predictable",
+	}
+	slowBody, _ := json.Marshal(slowReq)
+
+	req2 := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(slowBody)))
+	req2.Header.Set("Content-Type", "application/json")
+	w2 := httptest.NewRecorder()
+
+	server.handleChatConversation(w2, req2, conversationID)
+	if w2.Code != http.StatusAccepted {
+		t.Fatalf("expected status 202, got %d: %s", w2.Code, w2.Body.String())
+	}
+
+	// Wait for tool to start
+	time.Sleep(200 * time.Millisecond)
+
+	// Cancel the conversation
+	cancelReq := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/cancel", nil)
+	cancelW := httptest.NewRecorder()
+	server.handleCancelConversation(cancelW, cancelReq, conversationID)
+
+	if cancelW.Code != http.StatusOK {
+		t.Fatalf("expected cancel status 200, got %d: %s", cancelW.Code, cancelW.Body.String())
+	}
+
+	// Wait for cancellation to complete
+	time.Sleep(200 * time.Millisecond)
+
+	// Clear the predictable service request history so we can inspect the next request
+	predictableService.ClearRequests()
+
+	// Resume the conversation
+	resumeReq := ChatRequest{
+		Message: "echo: after cancel",
+		Model:   "predictable",
+	}
+	resumeBody, _ := json.Marshal(resumeReq)
+
+	req3 := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(resumeBody)))
+	req3.Header.Set("Content-Type", "application/json")
+	w3 := httptest.NewRecorder()
+
+	server.handleChatConversation(w3, req3, conversationID)
+	if w3.Code != http.StatusAccepted {
+		t.Fatalf("expected status 202 for resume, got %d: %s", w3.Code, w3.Body.String())
+	}
+
+	// Wait for the request to be processed
+	time.Sleep(300 * time.Millisecond)
+
+	// Check that the LLM request included the conversation history
+	lastReq := predictableService.GetLastRequest()
+	if lastReq == nil {
+		t.Fatal("BUG: no LLM request was made after resume")
+	}
+
+	// The request should include ALL previous messages:
+	// 1. Initial context message (user)
+	// 2. Response to initial context (assistant)
+	// 3. bash: sleep 5 (user)
+	// 4. Assistant response with tool use
+	// 5. Cancelled tool result (user)
+	// 6. [Operation cancelled] (assistant)
+	// 7. echo: after cancel (user)
+	//
+	// If context is lost, we'll only have the last message (#7)
+
+	if len(lastReq.Messages) < 3 {
+		t.Errorf("BUG: context lost after cancellation! Expected at least 3 messages in LLM request, got %d", len(lastReq.Messages))
+		t.Log("Messages in request:")
+		for i, msg := range lastReq.Messages {
+			t.Logf("  Message %d: role=%s, content_count=%d", i, msg.Role, len(msg.Content))
+			for j, content := range msg.Content {
+				if content.Type == llm.ContentTypeText {
+					// Truncate long text
+					text := content.Text
+					if len(text) > 100 {
+						text = text[:100] + "..."
+					}
+					t.Logf("    Content %d: type=%s, text=%q", j, content.Type, text)
+				} else {
+					t.Logf("    Content %d: type=%s", j, content.Type)
+				}
+			}
+		}
+	}
+
+	// Check that "initial context message" appears somewhere in the history
+	foundInitialContext := false
+	for _, msg := range lastReq.Messages {
+		for _, content := range msg.Content {
+			if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "initial context message") {
+				foundInitialContext = true
+				break
+			}
+		}
+	}
+
+	if !foundInitialContext {
+		t.Error("BUG: initial context message was not preserved after cancellation")
+	}
+}

server/convo.go πŸ”—

@@ -0,0 +1,483 @@
+package server
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+	"sync"
+	"time"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/db/generated"
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/loop"
+	"shelley.exe.dev/subpub"
+)
+
+var errConversationModelMismatch = errors.New("conversation model mismatch")
+
+// ConversationManager manages a single active conversation
+type ConversationManager struct {
+	conversationID string
+	db             *db.DB
+	loop           *loop.Loop
+	loopCancel     context.CancelFunc
+	loopCtx        context.Context
+	mu             sync.Mutex
+	lastActivity   time.Time
+	modelID        string
+	history        []llm.Message
+	system         []llm.SystemContent
+	recordMessage  loop.MessageRecordFunc
+	logger         *slog.Logger
+	toolSetConfig  claudetool.ToolSetConfig
+	toolSet        *claudetool.ToolSet // created per-conversation when loop starts
+
+	subpub *subpub.SubPub[StreamResponse]
+
+	hydrated              bool
+	hasConversationEvents bool
+	cwd                   string // working directory for tools
+}
+
+// NewConversationManager constructs a manager with dependencies but defers hydration until needed.
+func NewConversationManager(conversationID string, database *db.DB, baseLogger *slog.Logger, toolSetConfig claudetool.ToolSetConfig, recordMessage loop.MessageRecordFunc) *ConversationManager {
+	logger := baseLogger
+	if logger == nil {
+		logger = slog.Default()
+	}
+	logger = logger.With("conversationID", conversationID)
+
+	return &ConversationManager{
+		conversationID: conversationID,
+		db:             database,
+		lastActivity:   time.Now(),
+		recordMessage:  recordMessage,
+		logger:         logger,
+		toolSetConfig:  toolSetConfig,
+		subpub:         subpub.New[StreamResponse](),
+	}
+}
+
+// Hydrate loads conversation state from the database, generating a system prompt if missing.
+func (cm *ConversationManager) Hydrate(ctx context.Context) error {
+	cm.mu.Lock()
+	if cm.hydrated {
+		cm.lastActivity = time.Now()
+		cm.mu.Unlock()
+		return nil
+	}
+	cm.mu.Unlock()
+
+	conversation, err := cm.db.GetConversationByID(ctx, cm.conversationID)
+	if err != nil {
+		return fmt.Errorf("conversation not found: %w", err)
+	}
+
+	var messages []generated.Message
+	err = cm.db.Queries(ctx, func(q *generated.Queries) error {
+		var err error
+		messages, err = q.ListMessages(ctx, cm.conversationID)
+		return err
+	})
+	if err != nil {
+		return fmt.Errorf("failed to get conversation history: %w", err)
+	}
+
+	if conversation.UserInitiated && !hasSystemMessage(messages) {
+		systemMsg, err := cm.createSystemPrompt(ctx)
+		if err != nil {
+			return err
+		}
+		if systemMsg != nil {
+			messages = append(messages, *systemMsg)
+		}
+	}
+
+	history, system := cm.partitionMessages(messages)
+
+	// Load cwd from conversation if available
+	cwd := ""
+	if conversation.Cwd != nil {
+		cwd = *conversation.Cwd
+	}
+
+	cm.mu.Lock()
+	cm.history = history
+	cm.system = system
+	cm.hasConversationEvents = len(history) > 0
+	cm.lastActivity = time.Now()
+	cm.hydrated = true
+	cm.cwd = cwd
+	cm.mu.Unlock()
+
+	cm.logSystemPromptState(system, len(messages))
+
+	return nil
+}
+
+// AcceptUserMessage enqueues a user message, ensuring the loop is ready first.
+// The message is recorded to the database immediately so it appears in the UI,
+// even if the loop is busy processing a previous request.
+func (cm *ConversationManager) AcceptUserMessage(ctx context.Context, service llm.Service, modelID string, message llm.Message) (bool, error) {
+	if service == nil {
+		return false, fmt.Errorf("llm service is required")
+	}
+
+	if err := cm.Hydrate(ctx); err != nil {
+		return false, err
+	}
+
+	if err := cm.ensureLoop(service, modelID); err != nil {
+		return false, err
+	}
+
+	cm.mu.Lock()
+	isFirst := !cm.hasConversationEvents
+	cm.hasConversationEvents = true
+	loopInstance := cm.loop
+	cm.lastActivity = time.Now()
+	recordMessage := cm.recordMessage
+	cm.mu.Unlock()
+
+	if loopInstance == nil {
+		return false, fmt.Errorf("conversation loop not initialized")
+	}
+
+	// Record the user message to the database immediately so it appears in the UI,
+	// even if the loop is busy processing a previous request
+	if recordMessage != nil {
+		if err := recordMessage(ctx, message, llm.Usage{}); err != nil {
+			cm.logger.Error("failed to record user message immediately", "error", err)
+			// Continue anyway - the loop will also try to record it
+		}
+	}
+
+	loopInstance.QueueUserMessage(message)
+
+	return isFirst, nil
+}
+
+// Touch updates last activity timestamp.
+func (cm *ConversationManager) Touch() {
+	cm.mu.Lock()
+	cm.lastActivity = time.Now()
+	cm.mu.Unlock()
+}
+
+func hasSystemMessage(messages []generated.Message) bool {
+	for _, msg := range messages {
+		if msg.Type == string(db.MessageTypeSystem) {
+			return true
+		}
+	}
+	return false
+}
+
+func (cm *ConversationManager) createSystemPrompt(ctx context.Context) (*generated.Message, error) {
+	systemPrompt, err := GenerateSystemPrompt(cm.cwd)
+	if err != nil {
+		return nil, fmt.Errorf("failed to generate system prompt: %w", err)
+	}
+
+	if systemPrompt == "" {
+		cm.logger.Info("Skipping empty system prompt generation")
+		return nil, nil
+	}
+
+	systemMessage := llm.Message{
+		Role:    llm.MessageRoleUser,
+		Content: []llm.Content{{Type: llm.ContentTypeText, Text: systemPrompt}},
+	}
+
+	created, err := cm.db.CreateMessage(ctx, db.CreateMessageParams{
+		ConversationID: cm.conversationID,
+		Type:           db.MessageTypeSystem,
+		LLMData:        systemMessage,
+		UsageData:      llm.Usage{},
+	})
+	if err != nil {
+		return nil, fmt.Errorf("failed to store system prompt: %w", err)
+	}
+
+	if err := cm.db.QueriesTx(ctx, func(q *generated.Queries) error {
+		return q.UpdateConversationTimestamp(ctx, cm.conversationID)
+	}); err != nil {
+		cm.logger.Warn("Failed to update conversation timestamp after system prompt", "error", err)
+	}
+
+	cm.logger.Info("Stored system prompt", "length", len(systemPrompt))
+	return created, nil
+}
+
+func (cm *ConversationManager) partitionMessages(messages []generated.Message) ([]llm.Message, []llm.SystemContent) {
+	var history []llm.Message
+	var system []llm.SystemContent
+
+	for _, msg := range messages {
+		llmMsg, err := convertToLLMMessage(msg)
+		if err != nil {
+			cm.logger.Warn("Failed to convert message to LLM format", "messageID", msg.MessageID, "error", err)
+			continue
+		}
+
+		if msg.Type == string(db.MessageTypeSystem) {
+			for _, content := range llmMsg.Content {
+				if content.Type == llm.ContentTypeText && content.Text != "" {
+					system = append(system, llm.SystemContent{Type: "text", Text: content.Text})
+				}
+			}
+			continue
+		}
+
+		history = append(history, llmMsg)
+	}
+
+	return history, system
+}
+
+func (cm *ConversationManager) logSystemPromptState(system []llm.SystemContent, messageCount int) {
+	if len(system) == 0 {
+		cm.logger.Warn("No system prompt found in database", "message_count", messageCount)
+		return
+	}
+
+	length := 0
+	for _, sys := range system {
+		length += len(sys.Text)
+	}
+	cm.logger.Info("Loaded system prompt from database", "system_items", len(system), "total_length", length)
+}
+
+func (cm *ConversationManager) ensureLoop(service llm.Service, modelID string) error {
+	cm.mu.Lock()
+	if cm.loop != nil {
+		existingModel := cm.modelID
+		cm.mu.Unlock()
+		if existingModel != "" && modelID != "" && existingModel != modelID {
+			return fmt.Errorf("%w: conversation already uses model %s; requested %s", errConversationModelMismatch, existingModel, modelID)
+		}
+		return nil
+	}
+
+	history := append([]llm.Message(nil), cm.history...)
+	system := append([]llm.SystemContent(nil), cm.system...)
+	recordMessage := cm.recordMessage
+	logger := cm.logger
+	cwd := cm.cwd
+	toolSetConfig := cm.toolSetConfig
+	conversationID := cm.conversationID
+	db := cm.db
+	cm.mu.Unlock()
+
+	// Create tools for this conversation with the conversation's working directory
+	toolSetConfig.WorkingDir = cwd
+	toolSetConfig.ModelID = modelID
+	toolSetConfig.OnWorkingDirChange = func(newDir string) {
+		// Persist working directory change to database
+		if err := db.UpdateConversationCwd(context.Background(), conversationID, newDir); err != nil {
+			logger.Error("failed to persist working directory change", "error", err, "newDir", newDir)
+		}
+	}
+
+	processCtx, cancel := context.WithTimeout(context.Background(), 12*time.Hour)
+	toolSet := claudetool.NewToolSet(processCtx, toolSetConfig)
+
+	loopInstance := loop.NewLoop(loop.Config{
+		LLM:           service,
+		History:       history,
+		Tools:         toolSet.Tools(),
+		RecordMessage: recordMessage,
+		Logger:        logger,
+		System:        system,
+		WorkingDir:    cwd,
+	})
+
+	cm.mu.Lock()
+	if cm.loop != nil {
+		cm.mu.Unlock()
+		cancel()
+		toolSet.Cleanup()
+		existingModel := cm.modelID
+		if existingModel != "" && modelID != "" && existingModel != modelID {
+			return fmt.Errorf("%w: conversation already uses model %s; requested %s", errConversationModelMismatch, existingModel, modelID)
+		}
+		return nil
+	}
+	cm.loop = loopInstance
+	cm.loopCancel = cancel
+	cm.loopCtx = processCtx
+	cm.modelID = modelID
+	cm.toolSet = toolSet
+	cm.history = nil
+	cm.system = nil
+	cm.mu.Unlock()
+
+	go func() {
+		if err := loopInstance.Go(processCtx); err != nil && err != context.DeadlineExceeded && err != context.Canceled {
+			if logger != nil {
+				logger.Error("Conversation loop stopped", "error", err)
+			} else {
+				slog.Default().Error("Conversation loop stopped", "error", err)
+			}
+		}
+	}()
+
+	return nil
+}
+
+func (cm *ConversationManager) stopLoop() {
+	cm.mu.Lock()
+	cancel := cm.loopCancel
+	toolSet := cm.toolSet
+	cm.loopCancel = nil
+	cm.loopCtx = nil
+	cm.loop = nil
+	cm.modelID = ""
+	cm.toolSet = nil
+	cm.mu.Unlock()
+
+	if cancel != nil {
+		cancel()
+	}
+	if toolSet != nil {
+		toolSet.Cleanup()
+	}
+}
+
+// CancelConversation cancels the current conversation loop and records a cancelled tool result if a tool was in progress
+func (cm *ConversationManager) CancelConversation(ctx context.Context) error {
+	cm.mu.Lock()
+	loopInstance := cm.loop
+	loopCtx := cm.loopCtx
+	cancel := cm.loopCancel
+	cm.mu.Unlock()
+
+	if loopInstance == nil {
+		cm.logger.Info("No active loop to cancel")
+		return nil
+	}
+
+	cm.logger.Info("Cancelling conversation")
+
+	// Check if there's an in-progress tool call by examining the history
+	history := loopInstance.GetHistory()
+	var inProgressToolID string
+	var inProgressToolName string
+
+	// Find tool_uses that don't have corresponding tool_results.
+	// Strategy:
+	// 1. Find the last assistant message that contains tool_uses
+	// 2. Collect all tool_result IDs from user messages AFTER that assistant message
+	// 3. Find tool_uses that don't have matching results
+
+	// Step 1: Find the index of the last assistant message with tool_uses
+	lastToolUseAssistantIdx := -1
+	for i := len(history) - 1; i >= 0; i-- {
+		msg := history[i]
+		if msg.Role == llm.MessageRoleAssistant {
+			hasToolUse := false
+			for _, content := range msg.Content {
+				if content.Type == llm.ContentTypeToolUse {
+					hasToolUse = true
+					break
+				}
+			}
+			if hasToolUse {
+				lastToolUseAssistantIdx = i
+				break
+			}
+		}
+	}
+
+	if lastToolUseAssistantIdx >= 0 {
+		// Step 2: Collect all tool_result IDs from messages after the assistant message
+		toolResultIDs := make(map[string]bool)
+		for i := lastToolUseAssistantIdx + 1; i < len(history); i++ {
+			msg := history[i]
+			if msg.Role == llm.MessageRoleUser {
+				for _, content := range msg.Content {
+					if content.Type == llm.ContentTypeToolResult {
+						toolResultIDs[content.ToolUseID] = true
+					}
+				}
+			}
+		}
+
+		// Step 3: Find the first tool_use that doesn't have a result
+		assistantMsg := history[lastToolUseAssistantIdx]
+		for _, content := range assistantMsg.Content {
+			if content.Type == llm.ContentTypeToolUse {
+				if !toolResultIDs[content.ID] {
+					inProgressToolID = content.ID
+					inProgressToolName = content.ToolName
+					break
+				}
+			}
+		}
+	}
+
+	// Cancel the context
+	if cancel != nil {
+		cancel()
+	}
+
+	// Wait briefly for the loop to stop
+	if loopCtx != nil {
+		select {
+		case <-loopCtx.Done():
+		case <-time.After(100 * time.Millisecond):
+		}
+	}
+
+	// Record cancellation messages
+	if inProgressToolID != "" {
+		// If there was an in-progress tool, record a cancelled result
+		cm.logger.Info("Recording cancelled tool result", "tool_id", inProgressToolID, "tool_name", inProgressToolName)
+		cancelTime := time.Now()
+		cancelledMessage := llm.Message{
+			Role: llm.MessageRoleUser,
+			Content: []llm.Content{
+				{
+					Type:             llm.ContentTypeToolResult,
+					ToolUseID:        inProgressToolID,
+					ToolError:        true,
+					ToolResult:       []llm.Content{{Type: llm.ContentTypeText, Text: "Tool execution cancelled by user"}},
+					ToolUseStartTime: &cancelTime,
+					ToolUseEndTime:   &cancelTime,
+				},
+			},
+		}
+
+		if err := cm.recordMessage(ctx, cancelledMessage, llm.Usage{}); err != nil {
+			cm.logger.Error("Failed to record cancelled tool result", "error", err)
+			return fmt.Errorf("failed to record cancelled tool result: %w", err)
+		}
+	}
+
+	// Always record an assistant message with EndOfTurn to properly end the turn
+	// This ensures agentWorking() returns false, even if no tool was executing
+	endTurnMessage := llm.Message{
+		Role:      llm.MessageRoleAssistant,
+		Content:   []llm.Content{{Type: llm.ContentTypeText, Text: "[Operation cancelled]"}},
+		EndOfTurn: true,
+	}
+
+	if err := cm.recordMessage(ctx, endTurnMessage, llm.Usage{}); err != nil {
+		cm.logger.Error("Failed to record end turn message", "error", err)
+		return fmt.Errorf("failed to record end turn message: %w", err)
+	}
+
+	cm.mu.Lock()
+	cm.loopCancel = nil
+	cm.loopCtx = nil
+	cm.loop = nil
+	cm.modelID = ""
+	// Reset hydrated so that the next AcceptUserMessage will reload history from the database
+	cm.hydrated = false
+	cm.mu.Unlock()
+
+	return nil
+}

server/cwd_test.go πŸ”—

@@ -0,0 +1,296 @@
+package server
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// TestWorkingDirectoryConfiguration tests that the working directory (cwd) setting
+// is properly passed through from HTTP requests to tool execution.
+func TestWorkingDirectoryConfiguration(t *testing.T) {
+	h := NewTestHarness(t)
+	defer h.Close()
+
+	t.Run("cwd_tmp", func(t *testing.T) {
+		h.NewConversation("bash: pwd", "/tmp")
+		result := strings.TrimSpace(h.WaitToolResult())
+		// Resolve symlinks for comparison (on macOS, /tmp -> /private/tmp)
+		expected, _ := filepath.EvalSymlinks("/tmp")
+		if result != expected {
+			t.Errorf("expected %q, got: %s", expected, result)
+		}
+	})
+
+	t.Run("cwd_root", func(t *testing.T) {
+		h.NewConversation("bash: pwd", "/")
+		result := strings.TrimSpace(h.WaitToolResult())
+		if result != "/" {
+			t.Errorf("expected '/', got: %s", result)
+		}
+	})
+}
+
+// TestListDirectory tests the list-directory API endpoint used by the directory picker.
+func TestListDirectory(t *testing.T) {
+	h := NewTestHarness(t)
+	defer h.Close()
+
+	t.Run("list_tmp", func(t *testing.T) {
+		req := httptest.NewRequest("GET", "/api/list-directory?path=/tmp", nil)
+		w := httptest.NewRecorder()
+		h.server.handleListDirectory(w, req)
+
+		if w.Code != http.StatusOK {
+			t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+		}
+
+		var resp ListDirectoryResponse
+		if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+			t.Fatalf("failed to parse response: %v", err)
+		}
+
+		if resp.Path != "/tmp" {
+			t.Errorf("expected path '/tmp', got: %s", resp.Path)
+		}
+
+		if resp.Parent != "/" {
+			t.Errorf("expected parent '/', got: %s", resp.Parent)
+		}
+	})
+
+	t.Run("list_root", func(t *testing.T) {
+		req := httptest.NewRequest("GET", "/api/list-directory?path=/", nil)
+		w := httptest.NewRecorder()
+		h.server.handleListDirectory(w, req)
+
+		if w.Code != http.StatusOK {
+			t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+		}
+
+		var resp ListDirectoryResponse
+		if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+			t.Fatalf("failed to parse response: %v", err)
+		}
+
+		if resp.Path != "/" {
+			t.Errorf("expected path '/', got: %s", resp.Path)
+		}
+
+		// Root should have no parent
+		if resp.Parent != "" {
+			t.Errorf("expected no parent, got: %s", resp.Parent)
+		}
+
+		// Root should have at least some directories (tmp, etc, home, etc.)
+		if len(resp.Entries) == 0 {
+			t.Error("expected at least some entries in root")
+		}
+	})
+
+	t.Run("list_default_path", func(t *testing.T) {
+		req := httptest.NewRequest("GET", "/api/list-directory", nil)
+		w := httptest.NewRecorder()
+		h.server.handleListDirectory(w, req)
+
+		if w.Code != http.StatusOK {
+			t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+		}
+
+		var resp ListDirectoryResponse
+		if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+			t.Fatalf("failed to parse response: %v", err)
+		}
+
+		// Should default to home directory
+		homeDir, _ := os.UserHomeDir()
+		if homeDir != "" && resp.Path != homeDir {
+			t.Errorf("expected path '%s', got: %s", homeDir, resp.Path)
+		}
+	})
+
+	t.Run("list_nonexistent", func(t *testing.T) {
+		req := httptest.NewRequest("GET", "/api/list-directory?path=/nonexistent/path/123456", nil)
+		w := httptest.NewRecorder()
+		h.server.handleListDirectory(w, req)
+
+		if w.Code != http.StatusOK {
+			t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+		}
+
+		var resp map[string]interface{}
+		if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+			t.Fatalf("failed to parse response: %v", err)
+		}
+
+		if resp["error"] == nil {
+			t.Error("expected error field in response")
+		}
+	})
+
+	t.Run("list_file_not_directory", func(t *testing.T) {
+		// Create a temp file
+		f, err := os.CreateTemp("", "test")
+		if err != nil {
+			t.Fatalf("failed to create temp file: %v", err)
+		}
+		defer os.Remove(f.Name())
+		f.Close()
+
+		req := httptest.NewRequest("GET", "/api/list-directory?path="+f.Name(), nil)
+		w := httptest.NewRecorder()
+		h.server.handleListDirectory(w, req)
+
+		if w.Code != http.StatusOK {
+			t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+		}
+
+		var resp map[string]interface{}
+		if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+			t.Fatalf("failed to parse response: %v", err)
+		}
+
+		errMsg, ok := resp["error"].(string)
+		if !ok || errMsg != "path is not a directory" {
+			t.Errorf("expected error 'path is not a directory', got: %v", resp["error"])
+		}
+	})
+
+	t.Run("only_directories_returned", func(t *testing.T) {
+		// Create a temp directory with both files and directories
+		tmpDir, err := os.MkdirTemp("", "listdir_test")
+		if err != nil {
+			t.Fatalf("failed to create temp dir: %v", err)
+		}
+		defer os.RemoveAll(tmpDir)
+
+		// Create a subdirectory
+		subDir := tmpDir + "/subdir"
+		if err := os.Mkdir(subDir, 0o755); err != nil {
+			t.Fatalf("failed to create subdir: %v", err)
+		}
+
+		// Create a file
+		file := tmpDir + "/file.txt"
+		if err := os.WriteFile(file, []byte("test"), 0o644); err != nil {
+			t.Fatalf("failed to create file: %v", err)
+		}
+
+		req := httptest.NewRequest("GET", "/api/list-directory?path="+tmpDir, nil)
+		w := httptest.NewRecorder()
+		h.server.handleListDirectory(w, req)
+
+		if w.Code != http.StatusOK {
+			t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+		}
+
+		var resp ListDirectoryResponse
+		if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+			t.Fatalf("failed to parse response: %v", err)
+		}
+
+		// Should only include the directory, not the file
+		if len(resp.Entries) != 1 {
+			t.Errorf("expected 1 entry, got: %d", len(resp.Entries))
+		}
+
+		if len(resp.Entries) > 0 && resp.Entries[0].Name != "subdir" {
+			t.Errorf("expected entry 'subdir', got: %s", resp.Entries[0].Name)
+		}
+	})
+
+	t.Run("hidden_directories_excluded", func(t *testing.T) {
+		// Create a temp directory with a hidden directory
+		tmpDir, err := os.MkdirTemp("", "listdir_hidden_test")
+		if err != nil {
+			t.Fatalf("failed to create temp dir: %v", err)
+		}
+		defer os.RemoveAll(tmpDir)
+
+		// Create a visible subdirectory
+		visibleDir := tmpDir + "/visible"
+		if err := os.Mkdir(visibleDir, 0o755); err != nil {
+			t.Fatalf("failed to create visible dir: %v", err)
+		}
+
+		// Create a hidden subdirectory
+		hiddenDir := tmpDir + "/.hidden"
+		if err := os.Mkdir(hiddenDir, 0o755); err != nil {
+			t.Fatalf("failed to create hidden dir: %v", err)
+		}
+
+		req := httptest.NewRequest("GET", "/api/list-directory?path="+tmpDir, nil)
+		w := httptest.NewRecorder()
+		h.server.handleListDirectory(w, req)
+
+		if w.Code != http.StatusOK {
+			t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+		}
+
+		var resp ListDirectoryResponse
+		if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+			t.Fatalf("failed to parse response: %v", err)
+		}
+
+		// Should only include the visible directory, not the hidden one
+		if len(resp.Entries) != 1 {
+			t.Errorf("expected 1 entry, got: %d", len(resp.Entries))
+		}
+
+		if len(resp.Entries) > 0 && resp.Entries[0].Name != "visible" {
+			t.Errorf("expected entry 'visible', got: %s", resp.Entries[0].Name)
+		}
+	})
+}
+
+// TestConversationCwdReturnedInList tests that CWD is returned in the conversations list.
+func TestConversationCwdReturnedInList(t *testing.T) {
+	h := NewTestHarness(t)
+	defer h.Close()
+
+	// Create a conversation with a specific CWD
+	h.NewConversation("bash: pwd", "/tmp")
+	h.WaitToolResult() // Wait for the conversation to complete
+
+	// Get the conversations list
+	req := httptest.NewRequest("GET", "/api/conversations", nil)
+	w := httptest.NewRecorder()
+	h.server.handleConversations(w, req)
+
+	if w.Code != http.StatusOK {
+		t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+	}
+
+	var convs []map[string]interface{}
+	if err := json.Unmarshal(w.Body.Bytes(), &convs); err != nil {
+		t.Fatalf("failed to parse response: %v", err)
+	}
+
+	if len(convs) == 0 {
+		t.Fatal("expected at least one conversation")
+	}
+
+	// Find our conversation
+	found := false
+	for _, conv := range convs {
+		if conv["conversation_id"] == h.ConversationID() {
+			found = true
+			cwd, ok := conv["cwd"].(string)
+			if !ok {
+				t.Errorf("expected cwd to be a string, got: %T", conv["cwd"])
+			}
+			if cwd != "/tmp" {
+				t.Errorf("expected cwd '/tmp', got: %s", cwd)
+			}
+			break
+		}
+	}
+
+	if !found {
+		t.Error("conversation not found in list")
+	}
+}

server/duplicate_tool_result_test.go πŸ”—

@@ -0,0 +1,209 @@
+package server
+
+import (
+	"context"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/db/generated"
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/loop"
+)
+
+// TestCancelAfterToolCompletesCreatesDuplicateToolResult reproduces the bug where
+// cancelling a conversation after a tool has already completed creates a duplicate
+// tool_result for the same tool_use_id.
+//
+// The bug is in CancelConversation's search logic: it finds the first tool_use in
+// the last assistant message and immediately breaks without checking if that tool
+// already has a result. This causes it to create a cancelled tool_result even when
+// the tool already completed successfully.
+//
+// This leads to the Anthropic API error:
+// "each tool_use must have a single result. Found multiple `tool_result` blocks with id: ..."
+func TestCancelAfterToolCompletesCreatesDuplicateToolResult(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+
+	toolSetConfig := claudetool.ToolSetConfig{EnableBrowser: false}
+	server := NewServer(database, llmManager, toolSetConfig, logger, true, "", "predictable", "", nil)
+
+	// Create conversation
+	conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+	if err != nil {
+		t.Fatalf("failed to create conversation: %v", err)
+	}
+	conversationID := conversation.ConversationID
+
+	// Start a conversation with a fast tool call that completes quickly
+	chatReq := ChatRequest{
+		Message: "bash: echo hello",
+		Model:   "predictable",
+	}
+	chatBody, _ := json.Marshal(chatReq)
+
+	req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+
+	server.handleChatConversation(w, req, conversationID)
+	if w.Code != http.StatusAccepted {
+		t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+	}
+
+	// Wait for the tool to complete - this is important!
+	// The bash command "echo hello" should complete very quickly
+	deadline := time.Now().Add(5 * time.Second)
+	var toolResultFound bool
+	for time.Now().Before(deadline) {
+		var messages []generated.Message
+		err := database.Queries(context.Background(), func(q *generated.Queries) error {
+			var qerr error
+			messages, qerr = q.ListMessages(context.Background(), conversationID)
+			return qerr
+		})
+		if err != nil {
+			t.Fatalf("failed to get messages: %v", err)
+		}
+
+		// Look for a tool_result message
+		for _, msg := range messages {
+			if msg.Type != string(db.MessageTypeUser) || msg.LlmData == nil {
+				continue
+			}
+			var llmMsg llm.Message
+			if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+				continue
+			}
+			for _, content := range llmMsg.Content {
+				if content.Type == llm.ContentTypeToolResult && !content.ToolError {
+					// Found a successful tool result
+					toolResultFound = true
+					break
+				}
+			}
+			if toolResultFound {
+				break
+			}
+		}
+		if toolResultFound {
+			break
+		}
+		time.Sleep(50 * time.Millisecond)
+	}
+
+	if !toolResultFound {
+		t.Fatal("tool result was not found - tool didn't complete")
+	}
+
+	// Give a tiny bit more time for the loop to stabilize
+	time.Sleep(100 * time.Millisecond)
+
+	// Now cancel the conversation AFTER the tool has completed
+	// This should NOT create a new tool_result because the tool already finished
+	cancelReq := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/cancel", nil)
+	cancelW := httptest.NewRecorder()
+
+	server.handleCancelConversation(cancelW, cancelReq, conversationID)
+	if cancelW.Code != http.StatusOK {
+		t.Fatalf("cancel: expected status 200, got %d: %s", cancelW.Code, cancelW.Body.String())
+	}
+
+	// Wait for cancel to process
+	time.Sleep(200 * time.Millisecond)
+
+	// Check the messages to see if there are duplicate tool_results for the same tool_use_id
+	var messages []generated.Message
+	err = database.Queries(context.Background(), func(q *generated.Queries) error {
+		var qerr error
+		messages, qerr = q.ListMessages(context.Background(), conversationID)
+		return qerr
+	})
+	if err != nil {
+		t.Fatalf("failed to get messages after cancel: %v", err)
+	}
+
+	// Count tool_results by tool_use_id
+	toolResultsByID := make(map[string]int)
+	for _, msg := range messages {
+		if msg.LlmData == nil {
+			continue
+		}
+		var llmMsg llm.Message
+		if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+			continue
+		}
+		for _, content := range llmMsg.Content {
+			if content.Type == llm.ContentTypeToolResult && content.ToolUseID != "" {
+				toolResultsByID[content.ToolUseID]++
+			}
+		}
+	}
+
+	// Check for duplicates - this is the bug!
+	for toolID, count := range toolResultsByID {
+		if count > 1 {
+			t.Errorf("BUG: found %d tool_results for tool_use_id %s (expected 1)", count, toolID)
+		}
+	}
+
+	// Clear requests to get a clean slate for the next request
+	predictableService.ClearRequests()
+
+	// Now try to continue the conversation - this should trigger the API error
+	// if duplicates exist
+	resumeReq := ChatRequest{
+		Message: "echo: test after cancel",
+		Model:   "predictable",
+	}
+	resumeBody, _ := json.Marshal(resumeReq)
+
+	resumeChatReq := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(resumeBody)))
+	resumeChatReq.Header.Set("Content-Type", "application/json")
+	resumeW := httptest.NewRecorder()
+
+	server.handleChatConversation(resumeW, resumeChatReq, conversationID)
+	if resumeW.Code != http.StatusAccepted {
+		t.Fatalf("resume: expected status 202, got %d: %s", resumeW.Code, resumeW.Body.String())
+	}
+
+	// Wait for the request to be processed
+	time.Sleep(300 * time.Millisecond)
+
+	// Check the last request sent to the LLM for duplicate tool_results
+	lastRequest := predictableService.GetLastRequest()
+	if lastRequest == nil {
+		t.Fatal("no request was sent to the LLM")
+	}
+
+	// Count tool_results in the request by tool_use_id
+	requestToolResultsByID := make(map[string]int)
+	for _, msg := range lastRequest.Messages {
+		for _, content := range msg.Content {
+			if content.Type == llm.ContentTypeToolResult && content.ToolUseID != "" {
+				requestToolResultsByID[content.ToolUseID]++
+			}
+		}
+	}
+
+	// Check for duplicates in the request - this would cause the Anthropic API error
+	for toolID, count := range requestToolResultsByID {
+		if count > 1 {
+			t.Errorf("BUG: LLM request contains %d tool_results for tool_use_id %s (expected 1). "+
+				"This would cause Anthropic API error: 'each tool_use must have a single result'",
+				count, toolID)
+		}
+	}
+}

server/git_handlers.go πŸ”—

@@ -0,0 +1,329 @@
+package server
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// GitDiffInfo represents a commit or working changes
+type GitDiffInfo struct {
+	ID         string    `json:"id"`
+	Message    string    `json:"message"`
+	Author     string    `json:"author"`
+	Timestamp  time.Time `json:"timestamp"`
+	FilesCount int       `json:"filesCount"`
+	Additions  int       `json:"additions"`
+	Deletions  int       `json:"deletions"`
+}
+
+// GitFileInfo represents a file in a diff
+type GitFileInfo struct {
+	Path      string `json:"path"`
+	Status    string `json:"status"` // added, modified, deleted
+	Additions int    `json:"additions"`
+	Deletions int    `json:"deletions"`
+}
+
+// GitFileDiff represents the content of a file diff
+type GitFileDiff struct {
+	Path       string `json:"path"`
+	OldContent string `json:"oldContent"`
+	NewContent string `json:"newContent"`
+}
+
+// getGitRoot returns the git repository root for the given directory
+func getGitRoot(dir string) (string, error) {
+	cmd := exec.Command("git", "rev-parse", "--show-toplevel")
+	cmd.Dir = dir
+	output, err := cmd.Output()
+	if err != nil {
+		return "", err
+	}
+	return strings.TrimSpace(string(output)), nil
+}
+
+// parseDiffStat parses git diff --numstat output
+func parseDiffStat(output string) (additions, deletions, filesCount int) {
+	lines := strings.Split(strings.TrimSpace(output), "\n")
+	for _, line := range lines {
+		if line == "" {
+			continue
+		}
+		parts := strings.Fields(line)
+		if len(parts) >= 2 {
+			if parts[0] != "-" {
+				add, _ := strconv.Atoi(parts[0])
+				additions += add
+			}
+			if parts[1] != "-" {
+				del, _ := strconv.Atoi(parts[1])
+				deletions += del
+			}
+			filesCount++
+		}
+	}
+	return additions, deletions, filesCount
+}
+
+// handleGitDiffs returns available diffs (working changes + recent commits)
+func (s *Server) handleGitDiffs(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	cwd := r.URL.Query().Get("cwd")
+	if cwd == "" {
+		http.Error(w, "cwd parameter required", http.StatusBadRequest)
+		return
+	}
+
+	// Validate cwd is a directory
+	fi, err := os.Stat(cwd)
+	if err != nil || !fi.IsDir() {
+		http.Error(w, "invalid cwd", http.StatusBadRequest)
+		return
+	}
+
+	gitRoot, err := getGitRoot(cwd)
+	if err != nil {
+		http.Error(w, "not a git repository", http.StatusBadRequest)
+		return
+	}
+
+	var diffs []GitDiffInfo
+
+	// Working changes
+	workingStatCmd := exec.Command("git", "diff", "HEAD", "--numstat")
+	workingStatCmd.Dir = gitRoot
+	workingStatOutput, _ := workingStatCmd.Output()
+	workingAdditions, workingDeletions, workingFilesCount := parseDiffStat(string(workingStatOutput))
+
+	diffs = append(diffs, GitDiffInfo{
+		ID:         "working",
+		Message:    "Working Changes",
+		Author:     "",
+		Timestamp:  time.Now(),
+		FilesCount: workingFilesCount,
+		Additions:  workingAdditions,
+		Deletions:  workingDeletions,
+	})
+
+	// Get commits
+	cmd := exec.Command("git", "log", "--oneline", "-20", "--pretty=format:%H%x00%s%x00%an%x00%at")
+	cmd.Dir = gitRoot
+	output, err := cmd.Output()
+	if err == nil {
+		lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+		for _, line := range lines {
+			if line == "" {
+				continue
+			}
+			parts := strings.Split(line, "\x00")
+			if len(parts) < 4 {
+				continue
+			}
+
+			timestamp, _ := strconv.ParseInt(parts[3], 10, 64)
+
+			// Get diffstat
+			statCmd := exec.Command("git", "diff", parts[0]+"^", parts[0], "--numstat")
+			statCmd.Dir = gitRoot
+			statOutput, _ := statCmd.Output()
+			additions, deletions, filesCount := parseDiffStat(string(statOutput))
+
+			diffs = append(diffs, GitDiffInfo{
+				ID:         parts[0],
+				Message:    parts[1],
+				Author:     parts[2],
+				Timestamp:  time.Unix(timestamp, 0),
+				FilesCount: filesCount,
+				Additions:  additions,
+				Deletions:  deletions,
+			})
+		}
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]interface{}{
+		"diffs":   diffs,
+		"gitRoot": gitRoot,
+	})
+}
+
+// handleGitDiffFiles returns the files changed in a specific diff
+func (s *Server) handleGitDiffFiles(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	// Extract diff ID from path: /api/git/diffs/{id}/files
+	path := strings.TrimPrefix(r.URL.Path, "/api/git/diffs/")
+	parts := strings.SplitN(path, "/", 2)
+	if len(parts) < 2 || parts[1] != "files" {
+		http.Error(w, "invalid path", http.StatusBadRequest)
+		return
+	}
+	diffID := parts[0]
+
+	cwd := r.URL.Query().Get("cwd")
+	if cwd == "" {
+		http.Error(w, "cwd parameter required", http.StatusBadRequest)
+		return
+	}
+
+	gitRoot, err := getGitRoot(cwd)
+	if err != nil {
+		http.Error(w, "not a git repository", http.StatusBadRequest)
+		return
+	}
+
+	var cmd *exec.Cmd
+	var statBaseArg string
+
+	if diffID == "working" {
+		cmd = exec.Command("git", "diff", "--name-status", "HEAD")
+		statBaseArg = "HEAD"
+	} else {
+		cmd = exec.Command("git", "diff", "--name-status", diffID+"^")
+		statBaseArg = diffID + "^"
+	}
+	cmd.Dir = gitRoot
+
+	output, err := cmd.Output()
+	if err != nil {
+		http.Error(w, "failed to get diff files", http.StatusInternalServerError)
+		return
+	}
+
+	lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+	var files []GitFileInfo
+
+	for _, line := range lines {
+		if line == "" {
+			continue
+		}
+		parts := strings.Fields(line)
+		if len(parts) < 2 {
+			continue
+		}
+
+		status := "modified"
+		switch parts[0] {
+		case "A":
+			status = "added"
+		case "D":
+			status = "deleted"
+		case "M":
+			status = "modified"
+		}
+
+		// Get additions/deletions for this file
+		statCmd := exec.Command("git", "diff", statBaseArg, "--numstat", "--", parts[1])
+		statCmd.Dir = gitRoot
+		statOutput, _ := statCmd.Output()
+		additions, deletions := 0, 0
+		if statOutput != nil {
+			statParts := strings.Fields(string(statOutput))
+			if len(statParts) >= 2 {
+				additions, _ = strconv.Atoi(statParts[0])
+				deletions, _ = strconv.Atoi(statParts[1])
+			}
+		}
+
+		files = append(files, GitFileInfo{
+			Path:      parts[1],
+			Status:    status,
+			Additions: additions,
+			Deletions: deletions,
+		})
+	}
+
+	sort.Slice(files, func(i, j int) bool {
+		return files[i].Path < files[j].Path
+	})
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(files)
+}
+
+// handleGitFileDiff returns the old and new content for a file
+func (s *Server) handleGitFileDiff(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	// Extract diff ID and file path from: /api/git/file-diff/{id}/*filepath
+	path := strings.TrimPrefix(r.URL.Path, "/api/git/file-diff/")
+	slashIdx := strings.Index(path, "/")
+	if slashIdx < 0 {
+		http.Error(w, "invalid path", http.StatusBadRequest)
+		return
+	}
+	diffID := path[:slashIdx]
+	filePath := path[slashIdx+1:]
+
+	if diffID == "" || filePath == "" {
+		http.Error(w, "invalid path", http.StatusBadRequest)
+		return
+	}
+
+	cwd := r.URL.Query().Get("cwd")
+	if cwd == "" {
+		http.Error(w, "cwd parameter required", http.StatusBadRequest)
+		return
+	}
+
+	gitRoot, err := getGitRoot(cwd)
+	if err != nil {
+		http.Error(w, "not a git repository", http.StatusBadRequest)
+		return
+	}
+
+	// Prevent path traversal
+	cleanPath := filepath.Clean(filePath)
+	if strings.HasPrefix(cleanPath, "..") || filepath.IsAbs(cleanPath) {
+		http.Error(w, "invalid file path", http.StatusBadRequest)
+		return
+	}
+
+	var oldCmd *exec.Cmd
+	if diffID == "working" {
+		oldCmd = exec.Command("git", "show", "HEAD:"+filePath)
+	} else {
+		oldCmd = exec.Command("git", "show", diffID+"^:"+filePath)
+	}
+	oldCmd.Dir = gitRoot
+
+	oldOutput, _ := oldCmd.Output()
+	oldContent := string(oldOutput)
+
+	// Get new version from working tree
+	newContent := ""
+	fullPath := filepath.Join(gitRoot, cleanPath)
+	if file, err := os.Open(fullPath); err == nil {
+		if fileData, err := io.ReadAll(file); err == nil {
+			newContent = string(fileData)
+		}
+		file.Close()
+	}
+
+	fileDiff := GitFileDiff{
+		Path:       filePath,
+		OldContent: oldContent,
+		NewContent: newContent,
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(fileDiff)
+}

server/handlers.go πŸ”—

@@ -0,0 +1,1130 @@
+package server
+
+import (
+	"context"
+	"crypto/rand"
+	"database/sql"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"shelley.exe.dev/claudetool/browse"
+	"shelley.exe.dev/db/generated"
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/models"
+	"shelley.exe.dev/slug"
+	"shelley.exe.dev/version"
+)
+
+// handleRead serves files from limited allowed locations via /api/read?path=
+func (s *Server) handleRead(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+	p := r.URL.Query().Get("path")
+	if p == "" {
+		http.Error(w, "path required", http.StatusBadRequest)
+		return
+	}
+	// Clean and enforce prefix restriction
+	clean := p
+	// Do not resolve symlinks here; enforce string prefix restriction only
+	if !(strings.HasPrefix(clean, browse.ScreenshotDir+"/")) {
+		http.Error(w, "path not allowed", http.StatusForbidden)
+		return
+	}
+	f, err := os.Open(clean)
+	if err != nil {
+		http.Error(w, "not found", http.StatusNotFound)
+		return
+	}
+	defer f.Close()
+	// Determine content type by extension first, then fallback to sniffing
+	ext := strings.ToLower(filepath.Ext(clean))
+	switch ext {
+	case ".png":
+		w.Header().Set("Content-Type", "image/png")
+	case ".jpg", ".jpeg":
+		w.Header().Set("Content-Type", "image/jpeg")
+	case ".gif":
+		w.Header().Set("Content-Type", "image/gif")
+	case ".webp":
+		w.Header().Set("Content-Type", "image/webp")
+	case ".svg":
+		w.Header().Set("Content-Type", "image/svg+xml")
+	default:
+		buf := make([]byte, 512)
+		n, _ := f.Read(buf)
+		contentType := http.DetectContentType(buf[:n])
+		if _, err := f.Seek(0, 0); err != nil {
+			http.Error(w, "seek failed", http.StatusInternalServerError)
+			return
+		}
+		w.Header().Set("Content-Type", contentType)
+	}
+	// Reasonable short-term caching for assets, allow quick refresh during sessions
+	w.Header().Set("Cache-Control", "public, max-age=300")
+	io.Copy(w, f)
+}
+
+// handleWriteFile writes content to a file (for diff viewer edit mode)
+func (s *Server) handleWriteFile(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	var req struct {
+		Path    string `json:"path"`
+		Content string `json:"content"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "invalid request body", http.StatusBadRequest)
+		return
+	}
+
+	if req.Path == "" {
+		http.Error(w, "path required", http.StatusBadRequest)
+		return
+	}
+
+	// Security: only allow writing within certain directories
+	// For now, require the path to be within a git repository
+	clean := filepath.Clean(req.Path)
+	if !filepath.IsAbs(clean) {
+		http.Error(w, "absolute path required", http.StatusBadRequest)
+		return
+	}
+
+	// Write the file
+	if err := os.WriteFile(clean, []byte(req.Content), 0o644); err != nil {
+		http.Error(w, fmt.Sprintf("failed to write file: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
+}
+
+// handleUpload handles file uploads via POST /api/upload
+// Files are saved to the ScreenshotDir with a random filename
+func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	// Limit to 10MB file size
+	r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
+
+	// Parse the multipart form
+	if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
+		http.Error(w, "failed to parse form: "+err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	// Get the file from the multipart form
+	file, handler, err := r.FormFile("file")
+	if err != nil {
+		http.Error(w, "failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
+		return
+	}
+	defer file.Close()
+
+	// Generate a unique ID (8 random bytes converted to 16 hex chars)
+	randBytes := make([]byte, 8)
+	if _, err := rand.Read(randBytes); err != nil {
+		http.Error(w, "failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	// Get file extension from the original filename
+	ext := filepath.Ext(handler.Filename)
+
+	// Create a unique filename in the ScreenshotDir
+	filename := filepath.Join(browse.ScreenshotDir, fmt.Sprintf("upload_%s%s", hex.EncodeToString(randBytes), ext))
+
+	// Ensure the directory exists
+	if err := os.MkdirAll(browse.ScreenshotDir, 0o755); err != nil {
+		http.Error(w, "failed to create directory: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	// Create the destination file
+	destFile, err := os.Create(filename)
+	if err != nil {
+		http.Error(w, "failed to create destination file: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+	defer destFile.Close()
+
+	// Copy the file contents to the destination file
+	if _, err := io.Copy(destFile, file); err != nil {
+		http.Error(w, "failed to save file: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	// Return the path to the saved file
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]string{"path": filename})
+}
+
+// staticHandler serves files from the provided filesystem and disables caching for HTML/CSS/JS to avoid stale bundles
+// isConversationSlugPath returns true if the path looks like a conversation slug route
+// (e.g., /c/my-conversation-slug)
+func isConversationSlugPath(path string) bool {
+	return strings.HasPrefix(path, "/c/")
+}
+
+func (s *Server) staticHandler(fs http.FileSystem) http.Handler {
+	fileServer := http.FileServer(fs)
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// Inject initialization data into index.html
+		if r.URL.Path == "/" || r.URL.Path == "/index.html" || isConversationSlugPath(r.URL.Path) {
+			w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+			w.Header().Set("Pragma", "no-cache")
+			w.Header().Set("Expires", "0")
+			w.Header().Set("Content-Type", "text/html")
+			s.serveIndexWithInit(w, r, fs)
+			return
+		}
+
+		if strings.HasSuffix(r.URL.Path, ".html") || strings.HasSuffix(r.URL.Path, ".js") || strings.HasSuffix(r.URL.Path, ".css") {
+			w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+			w.Header().Set("Pragma", "no-cache")
+			w.Header().Set("Expires", "0")
+		}
+		fileServer.ServeHTTP(w, r)
+	})
+}
+
+// hashString computes a simple hash of a string
+func hashString(s string) uint32 {
+	var hash uint32
+	for _, c := range s {
+		hash = ((hash << 5) - hash) + uint32(c)
+	}
+	return hash
+}
+
+// generateFaviconSVG creates a seashell favicon with color based on hostname hash
+func generateFaviconSVG(hostname string) string {
+	hash := hashString(hostname)
+	h := hash % 360
+	s := 55
+	l := 65
+	lightL := l + 15
+	if lightL > 90 {
+		lightL = 90
+	}
+	darkL := l - 15
+	if darkL < 40 {
+		darkL = 40
+	}
+	strokeL := darkL - 15
+	if strokeL < 25 {
+		strokeL = 25
+	}
+
+	return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+  <defs>
+    <linearGradient id="shellGrad" x1="0%%" y1="0%%" x2="100%%" y2="100%%">
+      <stop offset="0%%" style="stop-color:hsl(%d, %d%%, %d%%)"/>
+      <stop offset="50%%" style="stop-color:hsl(%d, %d%%, %d%%)"/>
+      <stop offset="100%%" style="stop-color:hsl(%d, %d%%, %d%%)"/>
+    </linearGradient>
+  </defs>
+  <path d="M16 4 C8 4 3 12 3 20 C3 24 6 28 16 28 C26 28 29 24 29 20 C29 12 24 4 16 4"
+        fill="url(#shellGrad)" stroke="hsl(%d, %d%%, %d%%)" stroke-width="1"/>
+  <path d="M16 6 L16 26" stroke="hsl(%d, %d%%, %d%%)" stroke-width="1" fill="none"/>
+  <path d="M16 6 L8 25" stroke="hsl(%d, %d%%, %d%%)" stroke-width="1" fill="none"/>
+  <path d="M16 6 L24 25" stroke="hsl(%d, %d%%, %d%%)" stroke-width="1" fill="none"/>
+  <path d="M16 6 L5 22" stroke="hsl(%d, %d%%, %d%%)" stroke-width="1" fill="none"/>
+  <path d="M16 6 L27 22" stroke="hsl(%d, %d%%, %d%%)" stroke-width="1" fill="none"/>
+  <path d="M16 6 L11 26" stroke="hsl(%d, %d%%, %d%%)" stroke-width="0.8" fill="none"/>
+  <path d="M16 6 L21 26" stroke="hsl(%d, %d%%, %d%%)" stroke-width="0.8" fill="none"/>
+</svg>`,
+		h, s, lightL,
+		h, s, l,
+		h, s, darkL,
+		h, s-10, strokeL,
+		h, s-20, darkL,
+		h, s-20, darkL,
+		h, s-20, darkL,
+		h, s-20, darkL,
+		h, s-20, darkL,
+		h, s-20, darkL,
+		h, s-20, darkL,
+	)
+}
+
+// serveIndexWithInit serves index.html with injected initialization data
+func (s *Server) serveIndexWithInit(w http.ResponseWriter, r *http.Request, fs http.FileSystem) {
+	// Read index.html from the filesystem
+	file, err := fs.Open("/index.html")
+	if err != nil {
+		http.Error(w, "index.html not found", http.StatusNotFound)
+		return
+	}
+	defer file.Close()
+
+	indexHTML, err := io.ReadAll(file)
+	if err != nil {
+		http.Error(w, "Failed to read index.html", http.StatusInternalServerError)
+		return
+	}
+
+	// Build initialization data
+	type ModelInfo struct {
+		ID               string `json:"id"`
+		Ready            bool   `json:"ready"`
+		MaxContextTokens int    `json:"max_context_tokens,omitempty"`
+	}
+
+	var modelList []ModelInfo
+	if s.predictableOnly {
+		modelList = append(modelList, ModelInfo{ID: "predictable", Ready: true, MaxContextTokens: 200000})
+	} else {
+		modelIDs := s.llmManager.GetAvailableModels()
+		for _, id := range modelIDs {
+			// Skip predictable model unless predictable-only flag is set
+			if id == "predictable" {
+				continue
+			}
+			svc, err := s.llmManager.GetService(id)
+			maxCtx := 0
+			if err == nil && svc != nil {
+				maxCtx = svc.TokenContextWindow()
+			}
+			modelList = append(modelList, ModelInfo{ID: id, Ready: err == nil, MaxContextTokens: maxCtx})
+		}
+	}
+
+	// Select default model - use configured default if available, otherwise first ready model
+	defaultModel := s.defaultModel
+	if defaultModel == "" {
+		defaultModel = models.Default().ID
+	}
+	defaultModelAvailable := false
+	for _, m := range modelList {
+		if m.ID == defaultModel && m.Ready {
+			defaultModelAvailable = true
+			break
+		}
+	}
+	if !defaultModelAvailable {
+		// Fall back to first ready model
+		for _, m := range modelList {
+			if m.Ready {
+				defaultModel = m.ID
+				break
+			}
+		}
+	}
+
+	// Get hostname
+	hostname := "localhost"
+	if h, err := os.Hostname(); err == nil {
+		hostname = h
+	}
+
+	// Get default working directory
+	defaultCwd, err := os.Getwd()
+	if err != nil {
+		defaultCwd = "/"
+	}
+
+	// Get home directory for tilde display
+	homeDir, _ := os.UserHomeDir()
+
+	initData := map[string]interface{}{
+		"models":        modelList,
+		"default_model": defaultModel,
+		"hostname":      hostname,
+		"default_cwd":   defaultCwd,
+		"home_dir":      homeDir,
+	}
+	if s.terminalURL != "" {
+		initData["terminal_url"] = s.terminalURL
+	}
+	if len(s.links) > 0 {
+		initData["links"] = s.links
+	}
+
+	initJSON, err := json.Marshal(initData)
+	if err != nil {
+		http.Error(w, "Failed to marshal init data", http.StatusInternalServerError)
+		return
+	}
+
+	// Generate favicon as data URI
+	faviconSVG := generateFaviconSVG(hostname)
+	faviconDataURI := "data:image/svg+xml," + url.PathEscape(faviconSVG)
+	faviconLink := fmt.Sprintf(`<link rel="icon" type="image/svg+xml" href="%s"/>`, faviconDataURI)
+
+	// Inject the script tag and favicon before </head>
+	initScript := fmt.Sprintf(`<script>window.__SHELLEY_INIT__=%s;</script>`, initJSON)
+	injection := faviconLink + initScript
+	modifiedHTML := strings.Replace(string(indexHTML), "</head>", injection+"</head>", 1)
+
+	w.Write([]byte(modifiedHTML))
+}
+
+// handleConfig returns server configuration
+// handleConversations handles GET /conversations
+func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+	ctx := r.Context()
+	limit := 5000
+	offset := 0
+	var query string
+
+	// Parse query parameters
+	if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+		if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
+			limit = l
+		}
+	}
+	if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
+		if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
+			offset = o
+		}
+	}
+	query = r.URL.Query().Get("q")
+
+	// Get conversations from database
+	var conversations []generated.Conversation
+	var err error
+
+	if query != "" {
+		conversations, err = s.db.SearchConversations(ctx, query, int64(limit), int64(offset))
+	} else {
+		conversations, err = s.db.ListConversations(ctx, int64(limit), int64(offset))
+	}
+
+	if err != nil {
+		s.logger.Error("Failed to get conversations", "error", err)
+		http.Error(w, "Internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(conversations)
+}
+
+// handleConversation handles conversation-specific routes
+func (s *Server) handleConversation(w http.ResponseWriter, r *http.Request) {
+	path := strings.TrimPrefix(r.URL.Path, "/api/conversation/")
+	parts := strings.SplitN(path, "/", 2)
+	if len(parts) == 0 || parts[0] == "" {
+		http.Error(w, "Conversation ID required", http.StatusBadRequest)
+		return
+	}
+
+	conversationID := parts[0]
+
+	// Handle different endpoints
+	if len(parts) == 1 {
+		// /conversation/<id>
+		s.handleGetConversation(w, r, conversationID)
+	} else {
+		switch parts[1] {
+		case "stream":
+			// /conversation/<id>/stream
+			s.handleStreamConversation(w, r, conversationID)
+		case "chat":
+			// /conversation/<id>/chat
+			s.handleChatConversation(w, r, conversationID)
+		case "cancel":
+			// /conversation/<id>/cancel
+			s.handleCancelConversation(w, r, conversationID)
+		case "archive":
+			// /conversation/<id>/archive
+			s.handleArchiveConversation(w, r, conversationID)
+		case "unarchive":
+			// /conversation/<id>/unarchive
+			s.handleUnarchiveConversation(w, r, conversationID)
+		case "delete":
+			// /conversation/<id>/delete
+			s.handleDeleteConversation(w, r, conversationID)
+		case "rename":
+			// /conversation/<id>/rename
+			s.handleRenameConversation(w, r, conversationID)
+		default:
+			http.Error(w, "Not found", http.StatusNotFound)
+		}
+	}
+}
+
+// handleGetConversation handles GET /conversation/<id>
+func (s *Server) handleGetConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	ctx := r.Context()
+	var (
+		messages     []generated.Message
+		conversation generated.Conversation
+	)
+	err := s.db.Queries(ctx, func(q *generated.Queries) error {
+		var err error
+		messages, err = q.ListMessages(ctx, conversationID)
+		if err != nil {
+			return err
+		}
+		conversation, err = q.GetConversation(ctx, conversationID)
+		return err
+	})
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			http.Error(w, "Conversation not found", http.StatusNotFound)
+			return
+		}
+		s.logger.Error("Failed to get conversation messages", "conversationID", conversationID, "error", err)
+		http.Error(w, "Internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	apiMessages := toAPIMessages(messages)
+	json.NewEncoder(w).Encode(StreamResponse{
+		Messages:          apiMessages,
+		Conversation:      conversation,
+		AgentWorking:      agentWorking(apiMessages),
+		ContextWindowSize: calculateContextWindowSize(apiMessages),
+	})
+}
+
+// ChatRequest represents a chat message from the user
+type ChatRequest struct {
+	Message string `json:"message"`
+	Model   string `json:"model,omitempty"`
+	Cwd     string `json:"cwd,omitempty"`
+}
+
+// handleChatConversation handles POST /conversation/<id>/chat
+func (s *Server) handleChatConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	ctx := r.Context()
+
+	// Parse request
+	var req ChatRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "Invalid JSON", http.StatusBadRequest)
+		return
+	}
+
+	if req.Message == "" {
+		http.Error(w, "Message is required", http.StatusBadRequest)
+		return
+	}
+
+	// Get LLM service for the requested model
+	modelID := req.Model
+	if modelID == "" {
+		modelID = s.defaultModel
+	}
+
+	llmService, err := s.llmManager.GetService(modelID)
+	if err != nil {
+		s.logger.Error("Unsupported model requested", "model", modelID, "error", err)
+		http.Error(w, fmt.Sprintf("Unsupported model: %s", modelID), http.StatusBadRequest)
+		return
+	}
+
+	// Get or create conversation manager
+	manager, err := s.getOrCreateConversationManager(ctx, conversationID)
+	if err != nil {
+		if errors.Is(err, errConversationModelMismatch) {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		s.logger.Error("Failed to get conversation manager", "conversationID", conversationID, "error", err)
+		http.Error(w, "Internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	// Create user message
+	userMessage := llm.Message{
+		Role: llm.MessageRoleUser,
+		Content: []llm.Content{
+			{Type: llm.ContentTypeText, Text: req.Message},
+		},
+	}
+
+	firstMessage, err := manager.AcceptUserMessage(ctx, llmService, modelID, userMessage)
+	if err != nil {
+		if errors.Is(err, errConversationModelMismatch) {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		s.logger.Error("Failed to accept user message", "conversationID", conversationID, "error", err)
+		http.Error(w, "Internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	if firstMessage {
+		ctxNoCancel := context.WithoutCancel(ctx)
+		go func() {
+			slugCtx, cancel := context.WithTimeout(ctxNoCancel, 15*time.Second)
+			defer cancel()
+			_, err := slug.GenerateSlug(slugCtx, s.llmManager, s.db, s.logger, conversationID, req.Message, modelID)
+			if err != nil {
+				s.logger.Warn("Failed to generate slug for conversation", "conversationID", conversationID, "error", err)
+			} else {
+				go s.notifySubscribers(ctxNoCancel, conversationID)
+			}
+		}()
+	}
+
+	w.WriteHeader(http.StatusAccepted)
+	json.NewEncoder(w).Encode(map[string]string{"status": "accepted"})
+}
+
+// handleNewConversation handles POST /api/conversations/new - creates conversation implicitly on first message
+func (s *Server) handleNewConversation(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	ctx := r.Context()
+
+	// Parse request
+	var req ChatRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "Invalid JSON", http.StatusBadRequest)
+		return
+	}
+
+	if req.Message == "" {
+		http.Error(w, "Message is required", http.StatusBadRequest)
+		return
+	}
+
+	// Get LLM service for the requested model
+	modelID := req.Model
+	if modelID == "" {
+		// Default to Qwen3 Coder on Fireworks
+		modelID = "qwen3-coder-fireworks"
+	}
+
+	llmService, err := s.llmManager.GetService(modelID)
+	if err != nil {
+		s.logger.Error("Unsupported model requested", "model", modelID, "error", err)
+		http.Error(w, fmt.Sprintf("Unsupported model: %s", modelID), http.StatusBadRequest)
+		return
+	}
+
+	// Create new conversation with optional cwd
+	var cwdPtr *string
+	if req.Cwd != "" {
+		cwdPtr = &req.Cwd
+	}
+	conversation, err := s.db.CreateConversation(ctx, nil, true, cwdPtr)
+	if err != nil {
+		s.logger.Error("Failed to create conversation", "error", err)
+		http.Error(w, "Internal server error", http.StatusInternalServerError)
+		return
+	}
+	conversationID := conversation.ConversationID
+
+	// Get or create conversation manager
+	manager, err := s.getOrCreateConversationManager(ctx, conversationID)
+	if err != nil {
+		if errors.Is(err, errConversationModelMismatch) {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		s.logger.Error("Failed to get conversation manager", "conversationID", conversationID, "error", err)
+		http.Error(w, "Internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	// Create user message
+	userMessage := llm.Message{
+		Role: llm.MessageRoleUser,
+		Content: []llm.Content{
+			{Type: llm.ContentTypeText, Text: req.Message},
+		},
+	}
+
+	firstMessage, err := manager.AcceptUserMessage(ctx, llmService, modelID, userMessage)
+	if err != nil {
+		if errors.Is(err, errConversationModelMismatch) {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		s.logger.Error("Failed to accept user message", "conversationID", conversationID, "error", err)
+		http.Error(w, "Internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	if firstMessage {
+		ctxNoCancel := context.WithoutCancel(ctx)
+		go func() {
+			slugCtx, cancel := context.WithTimeout(ctxNoCancel, 15*time.Second)
+			defer cancel()
+			_, err := slug.GenerateSlug(slugCtx, s.llmManager, s.db, s.logger, conversationID, req.Message, modelID)
+			if err != nil {
+				s.logger.Warn("Failed to generate slug for conversation", "conversationID", conversationID, "error", err)
+			} else {
+				go s.notifySubscribers(ctxNoCancel, conversationID)
+			}
+		}()
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusCreated)
+	json.NewEncoder(w).Encode(map[string]interface{}{
+		"status":          "accepted",
+		"conversation_id": conversationID,
+	})
+}
+
+// handleCancelConversation handles POST /conversation/<id>/cancel
+func (s *Server) handleCancelConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	ctx := r.Context()
+
+	// Get the conversation manager if it exists
+	s.mu.Lock()
+	manager, exists := s.activeConversations[conversationID]
+	s.mu.Unlock()
+
+	if !exists {
+		// No active conversation to cancel
+		w.WriteHeader(http.StatusOK)
+		json.NewEncoder(w).Encode(map[string]string{"status": "no_active_conversation"})
+		return
+	}
+
+	// Cancel the conversation
+	if err := manager.CancelConversation(ctx); err != nil {
+		s.logger.Error("Failed to cancel conversation", "conversationID", conversationID, "error", err)
+		http.Error(w, "Failed to cancel conversation", http.StatusInternalServerError)
+		return
+	}
+
+	s.logger.Info("Conversation cancelled", "conversationID", conversationID)
+	w.WriteHeader(http.StatusOK)
+	json.NewEncoder(w).Encode(map[string]string{"status": "cancelled"})
+}
+
+// handleStreamConversation handles GET /conversation/<id>/stream
+func (s *Server) handleStreamConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	ctx := r.Context()
+
+	// Set up SSE headers
+	w.Header().Set("Content-Type", "text/event-stream")
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Connection", "keep-alive")
+	w.Header().Set("Access-Control-Allow-Origin", "*")
+
+	// Get current messages and conversation data
+	var messages []generated.Message
+	var conversation generated.Conversation
+	err := s.db.Queries(ctx, func(q *generated.Queries) error {
+		var err error
+		messages, err = q.ListMessages(ctx, conversationID)
+		if err != nil {
+			return err
+		}
+		conversation, err = q.GetConversation(ctx, conversationID)
+		return err
+	})
+	if err != nil {
+		s.logger.Error("Failed to get conversation data", "conversationID", conversationID, "error", err)
+		http.Error(w, "Internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	// Send current messages and conversation data
+	apiMessages := toAPIMessages(messages)
+	streamData := StreamResponse{
+		Messages:          apiMessages,
+		Conversation:      conversation,
+		AgentWorking:      agentWorking(apiMessages),
+		ContextWindowSize: calculateContextWindowSize(apiMessages),
+	}
+	data, _ := json.Marshal(streamData)
+	fmt.Fprintf(w, "data: %s\n\n", data)
+	w.(http.Flusher).Flush()
+
+	// Get or create conversation manager
+	manager, err := s.getOrCreateConversationManager(ctx, conversationID)
+	if err != nil {
+		s.logger.Error("Failed to get conversation manager", "conversationID", conversationID, "error", err)
+		return
+	}
+
+	// Subscribe to new messages after the last one we sent
+	last := int64(-1)
+	if len(messages) > 0 {
+		last = messages[len(messages)-1].SequenceID
+	}
+	next := manager.subpub.Subscribe(ctx, last)
+	for {
+		streamData, cont := next()
+		if !cont {
+			break
+		}
+		// Always forward updates, even if only the conversation changed (e.g., slug added)
+		data, _ := json.Marshal(streamData)
+		fmt.Fprintf(w, "data: %s\n\n", data)
+		w.(http.Flusher).Flush()
+	}
+}
+
+// handleDebugLLM serves recent LLM requests and responses for debugging
+func (s *Server) handleDebugLLM(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	// Check if requesting a specific record JSON
+	if idx := r.URL.Query().Get("index"); idx != "" {
+		var i int
+		if _, err := fmt.Sscanf(idx, "%d", &i); err != nil {
+			http.Error(w, "Invalid index", http.StatusBadRequest)
+			return
+		}
+
+		type historyProvider interface {
+			GetHistory() *models.LLMRequestHistory
+		}
+
+		var records []models.LLMRequestRecord
+		if hp, ok := s.llmManager.(historyProvider); ok && hp.GetHistory() != nil {
+			records = hp.GetHistory().GetRecords()
+		}
+
+		if i < 0 || i >= len(records) {
+			http.Error(w, "Index out of range", http.StatusNotFound)
+			return
+		}
+
+		record := records[i]
+		recordType := r.URL.Query().Get("type")
+
+		switch recordType {
+		case "request":
+			w.Header().Set("Content-Type", "application/json")
+			w.Write(record.HTTPRequest)
+		case "response":
+			w.Header().Set("Content-Type", "application/json")
+			w.Write(record.HTTPResponse)
+		default:
+			// Return the full record
+			w.Header().Set("Content-Type", "application/json")
+			json.NewEncoder(w).Encode(record)
+		}
+		return
+	}
+
+	// Get history from the LLM manager if it's a models.Manager
+	type historyProvider interface {
+		GetHistory() *models.LLMRequestHistory
+	}
+
+	var records []models.LLMRequestRecord
+	if hp, ok := s.llmManager.(historyProvider); ok && hp.GetHistory() != nil {
+		records = hp.GetHistory().GetRecords()
+	}
+
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	w.WriteHeader(http.StatusOK)
+
+	// Write simple HTML with links to JSON
+	fmt.Fprint(w, `<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>LLM Debug - Recent Requests</title>
+<style>
+body {
+	font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+	margin: 20px;
+	background: #ffffff;
+	color: #000000;
+}
+h1 {
+	margin-bottom: 20px;
+}
+table {
+	border-collapse: collapse;
+	width: 100%;
+}
+th, td {
+	padding: 8px 12px;
+	text-align: left;
+	border-bottom: 1px solid #ddd;
+}
+th {
+	background: #f5f5f5;
+	font-weight: 600;
+}
+tr:hover {
+	background: #f9f9f9;
+}
+.error {
+	color: #d32f2f;
+}
+.success {
+	color: #388e3c;
+}
+a {
+	color: #1976d2;
+	text-decoration: none;
+}
+a:hover {
+	text-decoration: underline;
+}
+</style>
+</head>
+<body>
+<h1>LLM Debug - Recent Requests</h1>
+`)
+
+	if len(records) == 0 {
+		fmt.Fprint(w, "<p>No requests recorded yet.</p>")
+	} else {
+		fmt.Fprint(w, "<table>")
+		fmt.Fprint(w, "<tr><th>#</th><th>Time</th><th>Model</th><th>URL</th><th>Status</th><th>Duration</th><th>Request</th><th>Response</th></tr>")
+		for i := len(records) - 1; i >= 0; i-- {
+			record := records[i]
+			num := len(records) - i
+			statusClass := "success"
+			statusText := fmt.Sprintf("%d", record.HTTPStatusCode)
+			if record.Error != "" {
+				statusClass = "error"
+				statusText = record.Error
+			} else if record.HTTPStatusCode >= 400 {
+				statusClass = "error"
+			}
+			fmt.Fprintf(w, "<tr>")
+			fmt.Fprintf(w, "<td>%d</td>", num)
+			fmt.Fprintf(w, "<td>%s</td>", record.Timestamp.Format("15:04:05"))
+			fmt.Fprintf(w, "<td>%s</td>", record.ModelID)
+			fmt.Fprintf(w, "<td>%s</td>", record.URL)
+			fmt.Fprintf(w, "<td class=\"%s\">%s</td>", statusClass, statusText)
+			fmt.Fprintf(w, "<td>%.2fs</td>", record.Duration)
+			fmt.Fprintf(w, "<td><a href=\"/debug/llm?index=%d&type=request\" target=\"_blank\">json</a></td>", i)
+			fmt.Fprintf(w, "<td><a href=\"/debug/llm?index=%d&type=response\" target=\"_blank\">json</a></td>", i)
+			fmt.Fprintf(w, "</tr>")
+		}
+		fmt.Fprint(w, "</table>")
+	}
+
+	fmt.Fprint(w, `
+</body>
+</html>
+`)
+}
+
+// handleVersion returns version information as JSON
+func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(version.GetInfo())
+}
+
+// handleArchivedConversations handles GET /api/conversations/archived
+func (s *Server) handleArchivedConversations(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+	ctx := r.Context()
+	limit := 5000
+	offset := 0
+	var query string
+
+	// Parse query parameters
+	if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+		if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
+			limit = l
+		}
+	}
+	if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
+		if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
+			offset = o
+		}
+	}
+	query = r.URL.Query().Get("q")
+
+	// Get archived conversations from database
+	var conversations []generated.Conversation
+	var err error
+
+	if query != "" {
+		conversations, err = s.db.SearchArchivedConversations(ctx, query, int64(limit), int64(offset))
+	} else {
+		conversations, err = s.db.ListArchivedConversations(ctx, int64(limit), int64(offset))
+	}
+
+	if err != nil {
+		s.logger.Error("Failed to get archived conversations", "error", err)
+		http.Error(w, "Internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(conversations)
+}
+
+// handleArchiveConversation handles POST /conversation/<id>/archive
+func (s *Server) handleArchiveConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	ctx := r.Context()
+	conversation, err := s.db.ArchiveConversation(ctx, conversationID)
+	if err != nil {
+		s.logger.Error("Failed to archive conversation", "conversationID", conversationID, "error", err)
+		http.Error(w, "Internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(conversation)
+}
+
+// handleUnarchiveConversation handles POST /conversation/<id>/unarchive
+func (s *Server) handleUnarchiveConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	ctx := r.Context()
+	conversation, err := s.db.UnarchiveConversation(ctx, conversationID)
+	if err != nil {
+		s.logger.Error("Failed to unarchive conversation", "conversationID", conversationID, "error", err)
+		http.Error(w, "Internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(conversation)
+}
+
+// handleDeleteConversation handles POST /conversation/<id>/delete
+func (s *Server) handleDeleteConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	ctx := r.Context()
+	if err := s.db.DeleteConversation(ctx, conversationID); err != nil {
+		s.logger.Error("Failed to delete conversation", "conversationID", conversationID, "error", err)
+		http.Error(w, "Internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
+}
+
+// handleConversationBySlug handles GET /api/conversation-by-slug/<slug>
+func (s *Server) handleConversationBySlug(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	slug := strings.TrimPrefix(r.URL.Path, "/api/conversation-by-slug/")
+	if slug == "" {
+		http.Error(w, "Slug required", http.StatusBadRequest)
+		return
+	}
+
+	ctx := r.Context()
+	conversation, err := s.db.GetConversationBySlug(ctx, slug)
+	if err != nil {
+		if strings.Contains(err.Error(), "not found") {
+			http.Error(w, "Conversation not found", http.StatusNotFound)
+			return
+		}
+		s.logger.Error("Failed to get conversation by slug", "slug", slug, "error", err)
+		http.Error(w, "Internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(conversation)
+}
+
+// RenameRequest represents a request to rename a conversation
+type RenameRequest struct {
+	Slug string `json:"slug"`
+}
+
+// handleRenameConversation handles POST /conversation/<id>/rename
+func (s *Server) handleRenameConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	ctx := r.Context()
+
+	var req RenameRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, "Invalid JSON", http.StatusBadRequest)
+		return
+	}
+
+	// Sanitize the slug using the same rules as auto-generated slugs
+	sanitized := slug.Sanitize(req.Slug)
+	if sanitized == "" {
+		http.Error(w, "Slug is required (must contain alphanumeric characters)", http.StatusBadRequest)
+		return
+	}
+
+	conversation, err := s.db.UpdateConversationSlug(ctx, conversationID, sanitized)
+	if err != nil {
+		s.logger.Error("Failed to rename conversation", "conversationID", conversationID, "error", err)
+		http.Error(w, "Internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(conversation)
+}

server/llmconfig.go πŸ”—

@@ -0,0 +1,33 @@
+package server
+
+import "log/slog"
+
+// Link represents a custom link to be displayed in the UI
+type Link struct {
+	Title   string `json:"title"`
+	IconSVG string `json:"icon_svg,omitempty"` // SVG path data for the icon
+	URL     string `json:"url"`
+}
+
+// LLMConfig holds all configuration for LLM services
+type LLMConfig struct {
+	// API keys for each provider
+	AnthropicAPIKey string
+	OpenAIAPIKey    string
+	GeminiAPIKey    string
+	FireworksAPIKey string
+
+	// Gateway is the base URL of the LLM gateway (optional)
+	Gateway string
+
+	// TerminalURL is the URL to the terminal interface (optional)
+	TerminalURL string
+
+	// DefaultModel is the default model to use (optional, defaults to models.Default())
+	DefaultModel string
+
+	// Links are custom links to be displayed in the UI (optional)
+	Links []Link
+
+	Logger *slog.Logger
+}

server/message_bandwidth_test.go πŸ”—

@@ -0,0 +1,292 @@
+package server
+
+import (
+	"bufio"
+	"context"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/loop"
+)
+
+// TestMessageSentOnlyOnce verifies that each message is sent to SSE subscribers
+// only once, not with every update.
+func TestMessageSentOnlyOnce(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+
+	server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+	// Create conversation
+	conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+	if err != nil {
+		t.Fatalf("failed to create conversation: %v", err)
+	}
+	conversationID := conversation.ConversationID
+
+	// Set up real HTTP server
+	mux := http.NewServeMux()
+	server.RegisterRoutes(mux)
+	httpServer := httptest.NewServer(mux)
+	defer httpServer.Close()
+
+	// Connect to SSE stream
+	sseResp, err := http.Get(httpServer.URL + "/api/conversation/" + conversationID + "/stream")
+	if err != nil {
+		t.Fatalf("failed to connect to SSE stream: %v", err)
+	}
+	defer sseResp.Body.Close()
+
+	// Start reading SSE events in background
+	type sseEvent struct {
+		data      StreamResponse
+		msgCount  int
+		totalSize int
+	}
+	sseEvents := make(chan sseEvent, 100)
+
+	go func() {
+		scanner := bufio.NewScanner(sseResp.Body)
+		for scanner.Scan() {
+			line := scanner.Text()
+			if !strings.HasPrefix(line, "data: ") {
+				continue
+			}
+			jsonStr := strings.TrimPrefix(line, "data: ")
+			var streamResp StreamResponse
+			if err := json.Unmarshal([]byte(jsonStr), &streamResp); err != nil {
+				continue
+			}
+			sseEvents <- sseEvent{
+				data:      streamResp,
+				msgCount:  len(streamResp.Messages),
+				totalSize: len(jsonStr),
+			}
+		}
+	}()
+
+	// Wait for initial SSE event (empty)
+	select {
+	case ev := <-sseEvents:
+		t.Logf("Initial SSE event: %d messages, %d bytes", ev.msgCount, ev.totalSize)
+	case <-time.After(2 * time.Second):
+		t.Fatal("timed out waiting for initial SSE event")
+	}
+
+	// Send first user message
+	chatReq := ChatRequest{
+		Message: "hello",
+		Model:   "predictable",
+	}
+	chatBody, _ := json.Marshal(chatReq)
+
+	resp, err := http.Post(
+		httpServer.URL+"/api/conversation/"+conversationID+"/chat",
+		"application/json",
+		strings.NewReader(string(chatBody)),
+	)
+	if err != nil {
+		t.Fatalf("failed to send chat message: %v", err)
+	}
+	resp.Body.Close()
+
+	// Collect SSE events for a short time to see the message progression
+	var receivedEvents []sseEvent
+	deadline := time.Now().Add(3 * time.Second)
+
+	for time.Now().Before(deadline) {
+		select {
+		case ev := <-sseEvents:
+			receivedEvents = append(receivedEvents, ev)
+			t.Logf("SSE event %d: %d messages, %d bytes", len(receivedEvents), ev.msgCount, ev.totalSize)
+
+			// Check if we have end_of_turn
+			if len(ev.data.Messages) > 0 {
+				lastMsg := ev.data.Messages[len(ev.data.Messages)-1]
+				if lastMsg.EndOfTurn != nil && *lastMsg.EndOfTurn {
+					t.Log("Got end_of_turn, stopping collection")
+					goto done
+				}
+			}
+		case <-time.After(100 * time.Millisecond):
+			// Keep waiting
+		}
+	}
+
+done:
+	if len(receivedEvents) == 0 {
+		t.Fatal("received no SSE events after sending message")
+	}
+
+	// Analyze: count how many times each message was sent
+	messagesSent := make(map[int64]int) // sequence_id -> count
+	totalBytes := 0
+
+	for _, ev := range receivedEvents {
+		totalBytes += ev.totalSize
+		for _, msg := range ev.data.Messages {
+			messagesSent[msg.SequenceID]++
+		}
+	}
+
+	t.Logf("Total bytes sent across all SSE events: %d", totalBytes)
+	t.Logf("Message send counts:")
+	for seqID, count := range messagesSent {
+		t.Logf("  Sequence %d: sent %d times", seqID, count)
+		if count > 1 {
+			t.Errorf("BUG: Message with sequence_id=%d was sent %d times (expected 1)", seqID, count)
+		}
+	}
+}
+
+// TestContextWindowSizeInSSE verifies that context_window_size is correctly
+// included only when agent messages with usage data are sent.
+func TestContextWindowSizeInSSE(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+
+	server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+	// Create conversation
+	conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+	if err != nil {
+		t.Fatalf("failed to create conversation: %v", err)
+	}
+	conversationID := conversation.ConversationID
+
+	// Set up real HTTP server
+	mux := http.NewServeMux()
+	server.RegisterRoutes(mux)
+	httpServer := httptest.NewServer(mux)
+	defer httpServer.Close()
+
+	// Connect to SSE stream
+	sseResp, err := http.Get(httpServer.URL + "/api/conversation/" + conversationID + "/stream")
+	if err != nil {
+		t.Fatalf("failed to connect to SSE stream: %v", err)
+	}
+	defer sseResp.Body.Close()
+
+	// Start reading SSE events in background
+	type sseEvent struct {
+		data              StreamResponse
+		contextWindowSize uint64
+		hasContextWindow  bool
+	}
+	sseEvents := make(chan sseEvent, 100)
+
+	go func() {
+		scanner := bufio.NewScanner(sseResp.Body)
+		for scanner.Scan() {
+			line := scanner.Text()
+			if !strings.HasPrefix(line, "data: ") {
+				continue
+			}
+			jsonStr := strings.TrimPrefix(line, "data: ")
+			var streamResp StreamResponse
+			if err := json.Unmarshal([]byte(jsonStr), &streamResp); err != nil {
+				continue
+			}
+			// Check if context_window_size was present in the JSON
+			var raw map[string]interface{}
+			json.Unmarshal([]byte(jsonStr), &raw)
+			_, hasCtx := raw["context_window_size"]
+
+			sseEvents <- sseEvent{
+				data:              streamResp,
+				contextWindowSize: streamResp.ContextWindowSize,
+				hasContextWindow:  hasCtx,
+			}
+		}
+	}()
+
+	// Wait for initial SSE event (empty)
+	select {
+	case ev := <-sseEvents:
+		t.Logf("Initial: context_window_size present=%v value=%d", ev.hasContextWindow, ev.contextWindowSize)
+	case <-time.After(2 * time.Second):
+		t.Fatal("timed out waiting for initial SSE event")
+	}
+
+	// Send user message
+	chatReq := ChatRequest{
+		Message: "hello",
+		Model:   "predictable",
+	}
+	chatBody, _ := json.Marshal(chatReq)
+
+	resp, err := http.Post(
+		httpServer.URL+"/api/conversation/"+conversationID+"/chat",
+		"application/json",
+		strings.NewReader(string(chatBody)),
+	)
+	if err != nil {
+		t.Fatalf("failed to send chat message: %v", err)
+	}
+	resp.Body.Close()
+
+	// Collect SSE events
+	var receivedEvents []sseEvent
+	deadline := time.Now().Add(3 * time.Second)
+
+	for time.Now().Before(deadline) {
+		select {
+		case ev := <-sseEvents:
+			receivedEvents = append(receivedEvents, ev)
+			msgType := "unknown"
+			if len(ev.data.Messages) > 0 {
+				msgType = ev.data.Messages[0].Type
+			}
+			t.Logf("Event %d: type=%s context_window_size present=%v value=%d",
+				len(receivedEvents), msgType, ev.hasContextWindow, ev.contextWindowSize)
+
+			// Check if we have end_of_turn
+			if len(ev.data.Messages) > 0 {
+				lastMsg := ev.data.Messages[len(ev.data.Messages)-1]
+				if lastMsg.EndOfTurn != nil && *lastMsg.EndOfTurn {
+					goto done
+				}
+			}
+		case <-time.After(100 * time.Millisecond):
+		}
+	}
+
+done:
+	// Verify: user messages should NOT have context_window_size (omitted via omitempty)
+	// Agent messages with usage data SHOULD have context_window_size
+	for i, ev := range receivedEvents {
+		if len(ev.data.Messages) == 0 {
+			continue
+		}
+		msg := ev.data.Messages[0]
+		if msg.Type == "user" {
+			// User messages have no usage data, context_window_size should be omitted (0)
+			if ev.hasContextWindow && ev.contextWindowSize != 0 {
+				t.Errorf("Event %d: user message should not have context_window_size, got %d", i+1, ev.contextWindowSize)
+			}
+		} else if msg.Type == "agent" && msg.UsageData != nil {
+			// Agent messages with usage data should have context_window_size
+			if !ev.hasContextWindow {
+				t.Errorf("Event %d: agent message with usage data should have context_window_size", i+1)
+			}
+			if ev.contextWindowSize == 0 {
+				t.Errorf("Event %d: agent message context_window_size should not be 0", i+1)
+			}
+		}
+	}
+}

server/middleware.go πŸ”—

@@ -0,0 +1,57 @@
+package server
+
+import (
+	"log/slog"
+	"net/http"
+	"strings"
+
+	sloghttp "github.com/samber/slog-http"
+)
+
+// LoggerMiddleware adds request logging using slog-http
+func LoggerMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
+	config := sloghttp.Config{
+		DefaultLevel:     slog.LevelInfo,
+		ClientErrorLevel: slog.LevelInfo,
+		ServerErrorLevel: slog.LevelInfo,
+		WithRequestID:    false,
+	}
+	return sloghttp.NewWithConfig(logger, config)
+}
+
+// CSRFMiddleware protects against CSRF attacks by requiring the X-Shelley-Request header
+// on state-changing requests (POST, PUT, DELETE). This works because browsers will not
+// add custom headers to simple cross-origin requests, and CORS preflight will block
+// complex requests from other origins that don't have explicit permission.
+func CSRFMiddleware() func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			// Only check state-changing methods
+			if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete {
+				// Require X-Shelley-Request header (value doesn't matter, just presence)
+				if r.Header.Get("X-Shelley-Request") == "" {
+					http.Error(w, "CSRF protection: X-Shelley-Request header required", http.StatusForbidden)
+					return
+				}
+			}
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
+// RequireHeaderMiddleware requires a specific header to be present on all API requests.
+// This is used to ensure requests come through an authenticated proxy.
+func RequireHeaderMiddleware(headerName string) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			// Only check API routes
+			if strings.HasPrefix(r.URL.Path, "/api/") {
+				if r.Header.Get(headerName) == "" {
+					http.Error(w, "missing required header: "+headerName, http.StatusForbidden)
+					return
+				}
+			}
+			next.ServeHTTP(w, r)
+		})
+	}
+}

server/middleware_test.go πŸ”—

@@ -0,0 +1,144 @@
+package server
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestCSRFMiddleware_BlocksPostWithoutHeader(t *testing.T) {
+	handler := CSRFMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("POST", "/api/test", nil)
+	w := httptest.NewRecorder()
+
+	handler.ServeHTTP(w, req)
+
+	if w.Code != http.StatusForbidden {
+		t.Errorf("expected status 403 for POST without X-Shelley-Request, got %d", w.Code)
+	}
+}
+
+func TestCSRFMiddleware_AllowsPostWithHeader(t *testing.T) {
+	handler := CSRFMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("POST", "/api/test", nil)
+	req.Header.Set("X-Shelley-Request", "1")
+	w := httptest.NewRecorder()
+
+	handler.ServeHTTP(w, req)
+
+	if w.Code != http.StatusOK {
+		t.Errorf("expected status 200 for POST with X-Shelley-Request, got %d", w.Code)
+	}
+}
+
+func TestCSRFMiddleware_AllowsGetWithoutHeader(t *testing.T) {
+	handler := CSRFMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("GET", "/api/test", nil)
+	w := httptest.NewRecorder()
+
+	handler.ServeHTTP(w, req)
+
+	if w.Code != http.StatusOK {
+		t.Errorf("expected status 200 for GET without X-Shelley-Request, got %d", w.Code)
+	}
+}
+
+func TestCSRFMiddleware_BlocksPutWithoutHeader(t *testing.T) {
+	handler := CSRFMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("PUT", "/api/test", nil)
+	w := httptest.NewRecorder()
+
+	handler.ServeHTTP(w, req)
+
+	if w.Code != http.StatusForbidden {
+		t.Errorf("expected status 403 for PUT without X-Shelley-Request, got %d", w.Code)
+	}
+}
+
+func TestCSRFMiddleware_BlocksDeleteWithoutHeader(t *testing.T) {
+	handler := CSRFMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("DELETE", "/api/test", nil)
+	w := httptest.NewRecorder()
+
+	handler.ServeHTTP(w, req)
+
+	if w.Code != http.StatusForbidden {
+		t.Errorf("expected status 403 for DELETE without X-Shelley-Request, got %d", w.Code)
+	}
+}
+
+func TestRequireHeaderMiddleware_BlocksAPIWithoutHeader(t *testing.T) {
+	handler := RequireHeaderMiddleware("X-Exedev-Userid")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("GET", "/api/conversations", nil)
+	w := httptest.NewRecorder()
+
+	handler.ServeHTTP(w, req)
+
+	if w.Code != http.StatusForbidden {
+		t.Errorf("expected status 403 for API request without required header, got %d", w.Code)
+	}
+}
+
+func TestRequireHeaderMiddleware_AllowsAPIWithHeader(t *testing.T) {
+	handler := RequireHeaderMiddleware("X-Exedev-Userid")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("GET", "/api/conversations", nil)
+	req.Header.Set("X-Exedev-Userid", "user123")
+	w := httptest.NewRecorder()
+
+	handler.ServeHTTP(w, req)
+
+	if w.Code != http.StatusOK {
+		t.Errorf("expected status 200 for API request with required header, got %d", w.Code)
+	}
+}
+
+func TestRequireHeaderMiddleware_AllowsNonAPIWithoutHeader(t *testing.T) {
+	handler := RequireHeaderMiddleware("X-Exedev-Userid")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("GET", "/", nil)
+	w := httptest.NewRecorder()
+
+	handler.ServeHTTP(w, req)
+
+	if w.Code != http.StatusOK {
+		t.Errorf("expected status 200 for non-API request without required header, got %d", w.Code)
+	}
+}
+
+func TestRequireHeaderMiddleware_AllowsVersionEndpointWithoutHeader(t *testing.T) {
+	handler := RequireHeaderMiddleware("X-Exedev-Userid")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	req := httptest.NewRequest("GET", "/version", nil)
+	w := httptest.NewRecorder()
+
+	handler.ServeHTTP(w, req)
+
+	if w.Code != http.StatusOK {
+		t.Errorf("expected status 200 for /version without required header, got %d", w.Code)
+	}
+}

server/orphan_tool_result_test.go πŸ”—

@@ -0,0 +1,339 @@
+package server
+
+import (
+	"context"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/loop"
+)
+
+// TestOrphanToolResultAfterCancellation reproduces the bug where a tool_result
+// is written after CancelConversation has already written an end-turn message.
+//
+// This leads to the Anthropic API error:
+// "unexpected `tool_use_id` found in `tool_result` blocks: <id>.
+// Each `tool_result` block must have a corresponding `tool_use` block in the previous message."
+//
+// The sequence is:
+// 1. LLM returns assistant message with tool_use X
+// 2. Tool X starts executing
+// 3. User cancels
+// 4. CancelConversation writes:
+//   - user message with cancelled tool_result X
+//   - assistant message with end-turn "[Operation cancelled]"
+//
+// 5. Tool X completes and writes its result AFTER the cancel messages
+// 6. DB now has:
+//   - assistant with tool_use X
+//   - user with tool_result X (cancelled)
+//   - assistant end-turn
+//   - user with tool_result X (actual) <- ORPHAN - references X but previous msg has no tool_use!
+func TestOrphanToolResultAfterCancellation(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+
+	toolSetConfig := claudetool.ToolSetConfig{EnableBrowser: false}
+	server := NewServer(database, llmManager, toolSetConfig, logger, true, "", "predictable", "", nil)
+
+	// Create conversation
+	conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+	if err != nil {
+		t.Fatalf("failed to create conversation: %v", err)
+	}
+	conversationID := conversation.ConversationID
+
+	// Manually create the problematic message sequence in the database
+	// This simulates the race condition where a tool result is written after cancellation
+
+	toolUseID := "toolu_test_orphan_12345"
+
+	// Message 1: User message "run something"
+	userMsg1 := llm.Message{
+		Role: llm.MessageRoleUser,
+		Content: []llm.Content{
+			{Type: llm.ContentTypeText, Text: "bash: echo hello"},
+		},
+	}
+	if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+		ConversationID: conversationID,
+		Type:           db.MessageTypeUser,
+		LLMData:        userMsg1,
+		UsageData:      llm.Usage{},
+	}); err != nil {
+		t.Fatalf("failed to create user message: %v", err)
+	}
+
+	// Message 2: Assistant message with tool_use
+	assistantMsg1 := llm.Message{
+		Role: llm.MessageRoleAssistant,
+		Content: []llm.Content{
+			{Type: llm.ContentTypeText, Text: "I'll run the command"},
+			{
+				ID:        toolUseID,
+				Type:      llm.ContentTypeToolUse,
+				ToolName:  "bash",
+				ToolInput: json.RawMessage(`{"command": "echo hello"}`),
+			},
+		},
+	}
+	if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+		ConversationID: conversationID,
+		Type:           db.MessageTypeAgent,
+		LLMData:        assistantMsg1,
+		UsageData:      llm.Usage{},
+	}); err != nil {
+		t.Fatalf("failed to create assistant message: %v", err)
+	}
+
+	// Message 3: User message with cancelled tool_result (from CancelConversation)
+	now := time.Now()
+	cancelledToolResult := llm.Message{
+		Role: llm.MessageRoleUser,
+		Content: []llm.Content{
+			{
+				Type:             llm.ContentTypeToolResult,
+				ToolUseID:        toolUseID,
+				ToolError:        true,
+				ToolResult:       []llm.Content{{Type: llm.ContentTypeText, Text: "Tool execution cancelled by user"}},
+				ToolUseStartTime: &now,
+				ToolUseEndTime:   &now,
+			},
+		},
+	}
+	if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+		ConversationID: conversationID,
+		Type:           db.MessageTypeUser,
+		LLMData:        cancelledToolResult,
+		UsageData:      llm.Usage{},
+	}); err != nil {
+		t.Fatalf("failed to create cancelled tool_result message: %v", err)
+	}
+
+	// Message 4: Assistant end-turn message (from CancelConversation)
+	endTurnMsg := llm.Message{
+		Role:      llm.MessageRoleAssistant,
+		Content:   []llm.Content{{Type: llm.ContentTypeText, Text: "[Operation cancelled]"}},
+		EndOfTurn: true,
+	}
+	if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+		ConversationID: conversationID,
+		Type:           db.MessageTypeAgent,
+		LLMData:        endTurnMsg,
+		UsageData:      llm.Usage{},
+	}); err != nil {
+		t.Fatalf("failed to create end-turn message: %v", err)
+	}
+
+	// Message 5: ORPHAN - User message with actual tool_result (written after cancel due to race)
+	// This references the tool_use from message 2, but the previous message (4) has no tool_use!
+	actualToolResult := llm.Message{
+		Role: llm.MessageRoleUser,
+		Content: []llm.Content{
+			{
+				Type:             llm.ContentTypeToolResult,
+				ToolUseID:        toolUseID,
+				ToolError:        false,
+				ToolResult:       []llm.Content{{Type: llm.ContentTypeText, Text: "hello\n"}},
+				ToolUseStartTime: &now,
+				ToolUseEndTime:   &now,
+			},
+		},
+	}
+	if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+		ConversationID: conversationID,
+		Type:           db.MessageTypeUser,
+		LLMData:        actualToolResult,
+		UsageData:      llm.Usage{},
+	}); err != nil {
+		t.Fatalf("failed to create orphan tool_result message: %v", err)
+	}
+
+	// Now try to resume the conversation
+	// This should trigger the Anthropic API error if we don't fix the orphan tool_result
+	resumeReq := ChatRequest{
+		Message: "echo: continue",
+		Model:   "predictable",
+	}
+	resumeBody, _ := json.Marshal(resumeReq)
+
+	req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(resumeBody)))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+
+	server.handleChatConversation(w, req, conversationID)
+	if w.Code != http.StatusAccepted {
+		t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+	}
+
+	// Wait for the request to be processed
+	time.Sleep(300 * time.Millisecond)
+
+	// Check the last request sent to the LLM for orphan tool_results
+	lastRequest := predictableService.GetLastRequest()
+	if lastRequest == nil {
+		t.Fatal("no request was sent to the LLM")
+	}
+
+	// Check that orphan tool_results have been removed
+	// An orphan tool_result is one that references a tool_use_id that doesn't exist
+	// in the immediately preceding assistant message
+
+	var previousAssistantToolUses map[string]bool
+	for i, msg := range lastRequest.Messages {
+		if msg.Role == llm.MessageRoleAssistant {
+			// Track all tool_use IDs in this assistant message
+			previousAssistantToolUses = make(map[string]bool)
+			for _, content := range msg.Content {
+				if content.Type == llm.ContentTypeToolUse {
+					previousAssistantToolUses[content.ID] = true
+				}
+			}
+		} else if msg.Role == llm.MessageRoleUser {
+			// Check if any tool_results reference IDs not in previous assistant message
+			for _, content := range msg.Content {
+				if content.Type == llm.ContentTypeToolResult {
+					if previousAssistantToolUses != nil && !previousAssistantToolUses[content.ToolUseID] {
+						t.Errorf("BUG: Found orphan tool_result at message index %d with ToolUseID=%s that doesn't match any tool_use in the previous assistant message. "+
+							"This would cause Anthropic API error: 'Each tool_result block must have a corresponding tool_use block in the previous message'",
+							i, content.ToolUseID)
+					}
+				}
+			}
+			// Clear previousAssistantToolUses since user messages reset the expectation
+			previousAssistantToolUses = nil
+		}
+	}
+
+	t.Logf("LLM request has %d messages - test verified orphan tool_results are handled", len(lastRequest.Messages))
+}
+
+// TestOrphanToolResultFiltering tests that orphan tool_results are filtered out
+// even when they appear in the middle of the conversation
+func TestOrphanToolResultFiltering(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+
+	server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+	conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+	if err != nil {
+		t.Fatalf("failed to create conversation: %v", err)
+	}
+	conversationID := conversation.ConversationID
+
+	// Create a conversation where there's an orphan tool_result in the middle
+	// followed by valid messages
+
+	// Message 1: User message
+	userMsg1 := llm.Message{
+		Role:    llm.MessageRoleUser,
+		Content: []llm.Content{{Type: llm.ContentTypeText, Text: "hello"}},
+	}
+	if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+		ConversationID: conversationID,
+		Type:           db.MessageTypeUser,
+		LLMData:        userMsg1,
+	}); err != nil {
+		t.Fatalf("failed to create message: %v", err)
+	}
+
+	// Message 2: Assistant response with end_of_turn (no tool_use)
+	assistantMsg := llm.Message{
+		Role:      llm.MessageRoleAssistant,
+		Content:   []llm.Content{{Type: llm.ContentTypeText, Text: "Hi there!"}},
+		EndOfTurn: true,
+	}
+	if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+		ConversationID: conversationID,
+		Type:           db.MessageTypeAgent,
+		LLMData:        assistantMsg,
+	}); err != nil {
+		t.Fatalf("failed to create message: %v", err)
+	}
+
+	// Message 3: ORPHAN tool_result - previous assistant has no tool_use!
+	now := time.Now()
+	orphanResult := llm.Message{
+		Role: llm.MessageRoleUser,
+		Content: []llm.Content{
+			{
+				Type:             llm.ContentTypeToolResult,
+				ToolUseID:        "toolu_orphan_xyz",
+				ToolError:        false,
+				ToolResult:       []llm.Content{{Type: llm.ContentTypeText, Text: "orphan result"}},
+				ToolUseStartTime: &now,
+				ToolUseEndTime:   &now,
+			},
+		},
+	}
+	if _, err := database.CreateMessage(context.Background(), db.CreateMessageParams{
+		ConversationID: conversationID,
+		Type:           db.MessageTypeUser,
+		LLMData:        orphanResult,
+	}); err != nil {
+		t.Fatalf("failed to create orphan message: %v", err)
+	}
+
+	// Now try to chat
+	chatReq := ChatRequest{
+		Message: "echo: test",
+		Model:   "predictable",
+	}
+	chatBody, _ := json.Marshal(chatReq)
+
+	req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+
+	server.handleChatConversation(w, req, conversationID)
+	if w.Code != http.StatusAccepted {
+		t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+	}
+
+	time.Sleep(300 * time.Millisecond)
+
+	lastRequest := predictableService.GetLastRequest()
+	if lastRequest == nil {
+		t.Fatal("no request was sent to the LLM")
+	}
+
+	// Verify no orphan tool_results in the request
+	var prevToolUses map[string]bool
+	for i, msg := range lastRequest.Messages {
+		if msg.Role == llm.MessageRoleAssistant {
+			prevToolUses = make(map[string]bool)
+			for _, content := range msg.Content {
+				if content.Type == llm.ContentTypeToolUse {
+					prevToolUses[content.ID] = true
+				}
+			}
+		} else if msg.Role == llm.MessageRoleUser {
+			for _, content := range msg.Content {
+				if content.Type == llm.ContentTypeToolResult {
+					if prevToolUses != nil && !prevToolUses[content.ToolUseID] {
+						t.Errorf("BUG: Found orphan tool_result at message index %d", i)
+					}
+				}
+			}
+			prevToolUses = nil
+		}
+	}
+}

server/server.go πŸ”—

@@ -0,0 +1,799 @@
+package server
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"net"
+	"net/http"
+	"os"
+	"os/exec"
+	"os/signal"
+	"path/filepath"
+	"strings"
+	"sync"
+	"syscall"
+	"time"
+
+	"tailscale.com/util/singleflight"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/db/generated"
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/models"
+	"shelley.exe.dev/ui"
+)
+
+// APIMessage is the message format sent to clients
+// TODO: We could maybe omit llm_data when display_data is available
+type APIMessage struct {
+	MessageID      string    `json:"message_id"`
+	ConversationID string    `json:"conversation_id"`
+	SequenceID     int64     `json:"sequence_id"`
+	Type           string    `json:"type"`
+	LlmData        *string   `json:"llm_data,omitempty"`
+	UserData       *string   `json:"user_data,omitempty"`
+	UsageData      *string   `json:"usage_data,omitempty"`
+	CreatedAt      time.Time `json:"created_at"`
+	DisplayData    *string   `json:"display_data,omitempty"`
+	EndOfTurn      *bool     `json:"end_of_turn,omitempty"`
+}
+
+// StreamResponse represents the response format for conversation streaming
+type StreamResponse struct {
+	Messages          []APIMessage           `json:"messages"`
+	Conversation      generated.Conversation `json:"conversation"`
+	AgentWorking      bool                   `json:"agent_working"`
+	ContextWindowSize uint64                 `json:"context_window_size,omitempty"`
+}
+
+// LLMProvider is an interface for getting LLM services
+type LLMProvider interface {
+	GetService(modelID string) (llm.Service, error)
+	GetAvailableModels() []string
+	HasModel(modelID string) bool
+}
+
+// NewLLMServiceManager creates a new LLM service manager from config
+func NewLLMServiceManager(cfg *LLMConfig, history *models.LLMRequestHistory) LLMProvider {
+	// Convert LLMConfig to models.Config
+	modelConfig := &models.Config{
+		AnthropicAPIKey: cfg.AnthropicAPIKey,
+		OpenAIAPIKey:    cfg.OpenAIAPIKey,
+		GeminiAPIKey:    cfg.GeminiAPIKey,
+		FireworksAPIKey: cfg.FireworksAPIKey,
+		Gateway:         cfg.Gateway,
+		Logger:          cfg.Logger,
+	}
+
+	manager, err := models.NewManager(modelConfig, history)
+	if err != nil {
+		// This shouldn't happen in practice, but handle it gracefully
+		cfg.Logger.Error("Failed to create models manager", "error", err)
+	}
+
+	return manager
+}
+
+// toAPIMessages converts database messages to API messages.
+// When display_data is present (tool results), llm_data is omitted to save bandwidth
+// since the display_data contains all information needed for UI rendering.
+func toAPIMessages(messages []generated.Message) []APIMessage {
+	apiMessages := make([]APIMessage, len(messages))
+	for i, msg := range messages {
+		var endOfTurnPtr *bool
+		if msg.LlmData != nil && msg.Type == string(db.MessageTypeAgent) {
+			if endOfTurn, ok := extractEndOfTurn(*msg.LlmData); ok {
+				endOfTurnCopy := endOfTurn
+				endOfTurnPtr = &endOfTurnCopy
+			}
+		}
+
+		// TODO: Consider omitting llm_data when display_data is present to save bandwidth.
+		// The display_data contains all info needed for UI rendering of tool results,
+		// but the UI currently still uses llm_data for some checks.
+
+		apiMsg := APIMessage{
+			MessageID:      msg.MessageID,
+			ConversationID: msg.ConversationID,
+			SequenceID:     msg.SequenceID,
+			Type:           msg.Type,
+			LlmData:        msg.LlmData,
+			UserData:       msg.UserData,
+			UsageData:      msg.UsageData,
+			CreatedAt:      msg.CreatedAt,
+			DisplayData:    msg.DisplayData,
+			EndOfTurn:      endOfTurnPtr,
+		}
+		apiMessages[i] = apiMsg
+	}
+	return apiMessages
+}
+
+func extractEndOfTurn(raw string) (bool, bool) {
+	var message llm.Message
+	if err := json.Unmarshal([]byte(raw), &message); err != nil {
+		return false, false
+	}
+	return message.EndOfTurn, true
+}
+
+// calculateContextWindowSize returns the context window usage from the most recent message.
+// Each API call's input tokens represent the full conversation history sent to the model,
+// so we only need the last message's tokens (not accumulated across all messages).
+// The total input includes regular input tokens plus cached tokens (both read and created).
+func calculateContextWindowSize(messages []APIMessage) uint64 {
+	// Find the last message with usage data
+	for i := len(messages) - 1; i >= 0; i-- {
+		msg := messages[i]
+		if msg.UsageData == nil {
+			continue
+		}
+		var usage llm.Usage
+		if err := json.Unmarshal([]byte(*msg.UsageData), &usage); err != nil {
+			continue
+		}
+		// Return total context window used: all input tokens + output tokens
+		// This represents the full context that would be sent for the next turn
+		return usage.ContextWindowUsed()
+	}
+	return 0
+}
+
+func agentWorking(messages []APIMessage) bool {
+	if len(messages) == 0 {
+		return false
+	}
+
+	last := messages[len(messages)-1]
+
+	// If the last message is an error, agent is not working
+	if last.Type == string(db.MessageTypeError) {
+		return false
+	}
+
+	if last.Type == string(db.MessageTypeAgent) {
+		if last.EndOfTurn == nil {
+			return true
+		}
+		return !*last.EndOfTurn
+	}
+
+	for i := len(messages) - 1; i >= 0; i-- {
+		msg := messages[i]
+		if msg.Type != string(db.MessageTypeAgent) {
+			continue
+		}
+		if msg.EndOfTurn == nil {
+			return true
+		}
+		if !*msg.EndOfTurn {
+			return true
+		}
+		// Agent ended turn, but newer non-agent messages exist, so agent is working again.
+		return true
+	}
+
+	// No agent message found yet but conversation has activity, assume agent is working.
+	return true
+}
+
+// isEndOfTurn checks if a database message represents end of turn
+func isEndOfTurn(msg *generated.Message) bool {
+	if msg == nil {
+		return false
+	}
+	// Error messages end the turn
+	if msg.Type == string(db.MessageTypeError) {
+		return true
+	}
+	// Only agent messages can have end_of_turn
+	if msg.Type != string(db.MessageTypeAgent) {
+		return false
+	}
+	if msg.LlmData == nil {
+		return false
+	}
+	endOfTurn, ok := extractEndOfTurn(*msg.LlmData)
+	if !ok {
+		return false
+	}
+	return endOfTurn
+}
+
+// calculateContextWindowSizeFromMsg calculates context window usage from a single message.
+// Returns 0 if the message has no usage data (e.g., user messages), in which case
+// the client should keep its previous context window value.
+func calculateContextWindowSizeFromMsg(msg *generated.Message) uint64 {
+	if msg == nil || msg.UsageData == nil {
+		return 0
+	}
+	var usage llm.Usage
+	if err := json.Unmarshal([]byte(*msg.UsageData), &usage); err != nil {
+		return 0
+	}
+	return usage.ContextWindowUsed()
+}
+
+// Server manages the HTTP API and active conversations
+type Server struct {
+	db                  *db.DB
+	llmManager          LLMProvider
+	toolSetConfig       claudetool.ToolSetConfig
+	activeConversations map[string]*ConversationManager
+	mu                  sync.Mutex
+	logger              *slog.Logger
+	predictableOnly     bool
+	terminalURL         string
+	defaultModel        string
+	links               []Link
+	requireHeader       string
+	conversationGroup   singleflight.Group[string, *ConversationManager]
+}
+
+// NewServer creates a new server instance
+func NewServer(database *db.DB, llmManager LLMProvider, toolSetConfig claudetool.ToolSetConfig, logger *slog.Logger, predictableOnly bool, terminalURL, defaultModel, requireHeader string, links []Link) *Server {
+	return &Server{
+		db:                  database,
+		llmManager:          llmManager,
+		toolSetConfig:       toolSetConfig,
+		activeConversations: make(map[string]*ConversationManager),
+		logger:              logger,
+		predictableOnly:     predictableOnly,
+		terminalURL:         terminalURL,
+		defaultModel:        defaultModel,
+		requireHeader:       requireHeader,
+		links:               links,
+	}
+}
+
+// RegisterRoutes registers HTTP routes on the given mux
+func (s *Server) RegisterRoutes(mux *http.ServeMux) {
+	// API routes
+	mux.HandleFunc("/api/conversations", s.handleConversations)
+	mux.HandleFunc("/api/conversations/archived", s.handleArchivedConversations)
+	mux.HandleFunc("/api/conversations/new", s.handleNewConversation)
+	mux.HandleFunc("/api/conversation/", s.handleConversation)
+	mux.HandleFunc("/api/conversation-by-slug/", s.handleConversationBySlug)
+	mux.HandleFunc("/api/validate-cwd", s.handleValidateCwd)
+	mux.HandleFunc("/api/list-directory", s.handleListDirectory)
+	mux.HandleFunc("/api/git/diffs", s.handleGitDiffs)
+	mux.HandleFunc("/api/git/diffs/", s.handleGitDiffFiles)
+	mux.HandleFunc("/api/git/file-diff/", s.handleGitFileDiff)
+	mux.HandleFunc("/api/upload", s.handleUpload)
+
+	// Generic read route restricted to safe paths
+	mux.HandleFunc("/api/read", s.handleRead)
+	mux.HandleFunc("/api/write-file", s.handleWriteFile)
+
+	// Version endpoint
+	mux.HandleFunc("/version", s.handleVersion)
+
+	// Debug routes
+	mux.HandleFunc("/debug/llm", s.handleDebugLLM)
+
+	// Serve embedded UI assets with conservative caching
+	mux.Handle("/", s.staticHandler(ui.Assets()))
+}
+
+// handleValidateCwd validates that a path exists and is a directory
+func (s *Server) handleValidateCwd(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	path := r.URL.Query().Get("path")
+	if path == "" {
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]interface{}{
+			"valid": false,
+			"error": "path is required",
+		})
+		return
+	}
+
+	info, err := os.Stat(path)
+	if err != nil {
+		w.Header().Set("Content-Type", "application/json")
+		if os.IsNotExist(err) {
+			json.NewEncoder(w).Encode(map[string]interface{}{
+				"valid": false,
+				"error": "directory does not exist",
+			})
+		} else {
+			json.NewEncoder(w).Encode(map[string]interface{}{
+				"valid": false,
+				"error": err.Error(),
+			})
+		}
+		return
+	}
+
+	if !info.IsDir() {
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]interface{}{
+			"valid": false,
+			"error": "path is not a directory",
+		})
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]interface{}{
+		"valid": true,
+	})
+}
+
+// DirectoryEntry represents a single directory entry for the directory picker
+type DirectoryEntry struct {
+	Name  string `json:"name"`
+	IsDir bool   `json:"is_dir"`
+}
+
+// ListDirectoryResponse is the response from the list-directory endpoint
+type ListDirectoryResponse struct {
+	Path    string           `json:"path"`
+	Parent  string           `json:"parent"`
+	Entries []DirectoryEntry `json:"entries"`
+}
+
+// handleListDirectory lists the contents of a directory for the directory picker
+func (s *Server) handleListDirectory(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	path := r.URL.Query().Get("path")
+	if path == "" {
+		// Default to home directory or root
+		homeDir, err := os.UserHomeDir()
+		if err != nil {
+			path = "/"
+		} else {
+			path = homeDir
+		}
+	}
+
+	// Clean and resolve the path
+	path = filepath.Clean(path)
+
+	// Verify path exists and is a directory
+	info, err := os.Stat(path)
+	if err != nil {
+		w.Header().Set("Content-Type", "application/json")
+		if os.IsNotExist(err) {
+			json.NewEncoder(w).Encode(map[string]interface{}{
+				"error": "directory does not exist",
+			})
+		} else if os.IsPermission(err) {
+			json.NewEncoder(w).Encode(map[string]interface{}{
+				"error": "permission denied",
+			})
+		} else {
+			json.NewEncoder(w).Encode(map[string]interface{}{
+				"error": err.Error(),
+			})
+		}
+		return
+	}
+
+	if !info.IsDir() {
+		w.Header().Set("Content-Type", "application/json")
+		json.NewEncoder(w).Encode(map[string]interface{}{
+			"error": "path is not a directory",
+		})
+		return
+	}
+
+	// Read directory contents
+	dirEntries, err := os.ReadDir(path)
+	if err != nil {
+		w.Header().Set("Content-Type", "application/json")
+		if os.IsPermission(err) {
+			json.NewEncoder(w).Encode(map[string]interface{}{
+				"error": "permission denied",
+			})
+		} else {
+			json.NewEncoder(w).Encode(map[string]interface{}{
+				"error": err.Error(),
+			})
+		}
+		return
+	}
+
+	// Build response with only directories (for directory picker)
+	var entries []DirectoryEntry
+	for _, entry := range dirEntries {
+		// Skip hidden files/directories (starting with .)
+		if strings.HasPrefix(entry.Name(), ".") {
+			continue
+		}
+		// Only include directories
+		if entry.IsDir() {
+			entries = append(entries, DirectoryEntry{
+				Name:  entry.Name(),
+				IsDir: true,
+			})
+		}
+	}
+
+	// Calculate parent directory
+	parent := filepath.Dir(path)
+	if parent == path {
+		// At root, no parent
+		parent = ""
+	}
+
+	response := ListDirectoryResponse{
+		Path:    path,
+		Parent:  parent,
+		Entries: entries,
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(response)
+}
+
+// getOrCreateConversationManager gets an existing conversation manager or creates a new one.
+func (s *Server) getOrCreateConversationManager(ctx context.Context, conversationID string) (*ConversationManager, error) {
+	manager, err, _ := s.conversationGroup.Do(conversationID, func() (*ConversationManager, error) {
+		s.mu.Lock()
+		defer s.mu.Unlock()
+		if manager, exists := s.activeConversations[conversationID]; exists {
+			manager.Touch()
+			return manager, nil
+		}
+
+		recordMessage := func(ctx context.Context, message llm.Message, usage llm.Usage) error {
+			return s.recordMessage(ctx, conversationID, message, usage)
+		}
+
+		manager := NewConversationManager(conversationID, s.db, s.logger, s.toolSetConfig, recordMessage)
+		if err := manager.Hydrate(ctx); err != nil {
+			return nil, err
+		}
+
+		s.activeConversations[conversationID] = manager
+		return manager, nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return manager, nil
+}
+
+// ExtractDisplayData extracts display data from message content for storage
+func ExtractDisplayData(message llm.Message) interface{} {
+	// Build a map of tool_use_id to tool_name for lookups
+	toolNameMap := make(map[string]string)
+	for _, content := range message.Content {
+		if content.Type == llm.ContentTypeToolUse {
+			toolNameMap[content.ID] = content.ToolName
+		}
+	}
+
+	var displayData []any
+	for _, content := range message.Content {
+		if content.Type == llm.ContentTypeToolResult && content.Display != nil {
+			// Include tool name if we can find it
+			toolName := toolNameMap[content.ToolUseID]
+			displayData = append(displayData, map[string]any{
+				"tool_use_id": content.ToolUseID,
+				"tool_name":   toolName,
+				"display":     content.Display,
+			})
+		}
+	}
+
+	if len(displayData) > 0 {
+		return displayData
+	}
+	return nil
+}
+
+// recordMessage records a new message to the database and also notifies subscribers
+func (s *Server) recordMessage(ctx context.Context, conversationID string, message llm.Message, usage llm.Usage) error {
+	// Log message based on role
+	if message.Role == llm.MessageRoleUser {
+		s.logger.Info("User message", "conversation_id", conversationID, "content_items", len(message.Content))
+	} else if message.Role == llm.MessageRoleAssistant {
+		s.logger.Info("Agent message", "conversation_id", conversationID, "content_items", len(message.Content), "end_of_turn", message.EndOfTurn)
+	}
+
+	// Convert LLM message to database format
+	messageType, err := s.getMessageType(message)
+	if err != nil {
+		return fmt.Errorf("failed to determine message type: %w", err)
+	}
+
+	// Extract display data from content items
+	displayDataToStore := ExtractDisplayData(message)
+
+	// Create message
+	createdMsg, err := s.db.CreateMessage(ctx, db.CreateMessageParams{
+		ConversationID: conversationID,
+		Type:           messageType,
+		LLMData:        message,
+		UserData:       nil,
+		UsageData:      usage,
+		DisplayData:    displayDataToStore,
+	})
+	if err != nil {
+		return fmt.Errorf("failed to create message: %w", err)
+	}
+
+	// Update conversation's last updated timestamp for correct ordering
+	if err := s.db.QueriesTx(ctx, func(q *generated.Queries) error {
+		return q.UpdateConversationTimestamp(ctx, conversationID)
+	}); err != nil {
+		s.logger.Warn("Failed to update conversation timestamp", "conversationID", conversationID, "error", err)
+	}
+
+	// Touch active manager activity time if present
+	s.mu.Lock()
+	mgr, ok := s.activeConversations[conversationID]
+	if ok {
+		mgr.Touch()
+	}
+	s.mu.Unlock()
+
+	// Notify subscribers with only the new message - use WithoutCancel because
+	// the HTTP request context may be cancelled after the handler returns, but
+	// we still want the notification to complete so SSE clients see the message immediately
+	go s.notifySubscribersNewMessage(context.WithoutCancel(ctx), conversationID, createdMsg)
+
+	return nil
+}
+
+// getMessageType determines the message type from an LLM message
+func (s *Server) getMessageType(message llm.Message) (db.MessageType, error) {
+	switch message.Role {
+	case llm.MessageRoleUser:
+		return db.MessageTypeUser, nil
+	case llm.MessageRoleAssistant:
+		// Check if this is an error message by looking at content
+		for _, content := range message.Content {
+			if content.Type == llm.ContentTypeText && strings.HasPrefix(content.Text, "LLM request failed:") {
+				return db.MessageTypeError, nil
+			}
+		}
+		return db.MessageTypeAgent, nil
+	default:
+		// For tool messages, check if it's a tool call or tool result
+		for _, content := range message.Content {
+			if content.Type == llm.ContentTypeToolUse {
+				return db.MessageTypeTool, nil
+			}
+			if content.Type == llm.ContentTypeToolResult {
+				return db.MessageTypeTool, nil
+			}
+		}
+		return db.MessageTypeAgent, nil
+	}
+}
+
+// convertToLLMMessage converts a database message to an LLM message
+func convertToLLMMessage(msg generated.Message) (llm.Message, error) {
+	var llmMsg llm.Message
+	if msg.LlmData == nil {
+		return llm.Message{}, fmt.Errorf("message has no LLM data")
+	}
+	if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+		return llm.Message{}, fmt.Errorf("failed to unmarshal LLM data: %w", err)
+	}
+	return llmMsg, nil
+}
+
+// notifySubscribers sends conversation metadata updates (e.g., slug changes) to subscribers.
+// This is used when only the conversation data changes, not the messages.
+func (s *Server) notifySubscribers(ctx context.Context, conversationID string) {
+	s.mu.Lock()
+	manager, exists := s.activeConversations[conversationID]
+	s.mu.Unlock()
+
+	if !exists {
+		return
+	}
+
+	// Get conversation data only (no messages needed for metadata-only updates)
+	var conversation generated.Conversation
+	err := s.db.Queries(ctx, func(q *generated.Queries) error {
+		var err error
+		conversation, err = q.GetConversation(ctx, conversationID)
+		return err
+	})
+	if err != nil {
+		s.logger.Error("Failed to get conversation data for notification", "conversationID", conversationID, "error", err)
+		return
+	}
+
+	// For conversation-only updates, we need to get the latest sequence ID
+	// to properly notify subscribers, but we send an empty message list
+	var latestSequenceID int64
+	err = s.db.Queries(ctx, func(q *generated.Queries) error {
+		messages, err := q.ListMessages(ctx, conversationID)
+		if err != nil {
+			return err
+		}
+		if len(messages) > 0 {
+			latestSequenceID = messages[len(messages)-1].SequenceID
+		}
+		return nil
+	})
+	if err != nil {
+		s.logger.Error("Failed to get latest sequence ID", "conversationID", conversationID, "error", err)
+		return
+	}
+
+	// Publish conversation update with no new messages
+	streamData := StreamResponse{
+		Messages:     nil, // No new messages, just conversation update
+		Conversation: conversation,
+	}
+	manager.subpub.Publish(latestSequenceID, streamData)
+}
+
+// notifySubscribersNewMessage sends a single new message to all subscribers.
+// This is more efficient than re-sending all messages on each update.
+func (s *Server) notifySubscribersNewMessage(ctx context.Context, conversationID string, newMsg *generated.Message) {
+	s.mu.Lock()
+	manager, exists := s.activeConversations[conversationID]
+	s.mu.Unlock()
+
+	if !exists {
+		return
+	}
+
+	// Get conversation data for the response
+	var conversation generated.Conversation
+	err := s.db.Queries(ctx, func(q *generated.Queries) error {
+		var err error
+		conversation, err = q.GetConversation(ctx, conversationID)
+		return err
+	})
+	if err != nil {
+		s.logger.Error("Failed to get conversation data for notification", "conversationID", conversationID, "error", err)
+		return
+	}
+
+	// Convert the single new message to API format
+	apiMessages := toAPIMessages([]generated.Message{*newMsg})
+
+	// Publish only the new message
+	streamData := StreamResponse{
+		Messages:     apiMessages,
+		Conversation: conversation,
+		AgentWorking: !isEndOfTurn(newMsg),
+		// ContextWindowSize: 0 for messages without usage data (user/tool messages).
+		// With omitempty, 0 is omitted from JSON, so the UI keeps its cached value.
+		// Only agent messages have usage data, so context window updates when they arrive.
+		ContextWindowSize: calculateContextWindowSizeFromMsg(newMsg),
+	}
+	manager.subpub.Publish(newMsg.SequenceID, streamData)
+}
+
+// Cleanup removes inactive conversation managers
+func (s *Server) Cleanup() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	now := time.Now()
+	for id, manager := range s.activeConversations {
+		// Remove managers that have been inactive for more than 30 minutes
+		manager.mu.Lock()
+		lastActivity := manager.lastActivity
+		manager.mu.Unlock()
+		if now.Sub(lastActivity) > 30*time.Minute {
+			manager.stopLoop()
+			delete(s.activeConversations, id)
+			s.logger.Debug("Cleaned up inactive conversation", "conversationID", id)
+		}
+	}
+}
+
+// Start starts the HTTP server and handles the complete lifecycle
+func (s *Server) Start(port string) error {
+	listener, err := net.Listen("tcp", ":"+port)
+	if err != nil {
+		s.logger.Error("Failed to create listener", "error", err, "port_info", getPortOwnerInfo(port))
+		return err
+	}
+	return s.StartWithListener(listener)
+}
+
+// StartWithListener starts the HTTP server using the provided listener.
+// This is useful for systemd socket activation where the listener is created externally.
+func (s *Server) StartWithListener(listener net.Listener) error {
+	// Set up HTTP server with routes and middleware
+	mux := http.NewServeMux()
+	s.RegisterRoutes(mux)
+
+	// Add middleware (applied in reverse order: last added = first executed)
+	handler := LoggerMiddleware(s.logger)(mux)
+	handler = CSRFMiddleware()(handler)
+	if s.requireHeader != "" {
+		handler = RequireHeaderMiddleware(s.requireHeader)(handler)
+	}
+
+	httpServer := &http.Server{
+		Handler: handler,
+	}
+
+	// Start cleanup routine
+	go func() {
+		ticker := time.NewTicker(5 * time.Minute)
+		defer ticker.Stop()
+		for range ticker.C {
+			s.Cleanup()
+		}
+	}()
+
+	// Get actual port from listener
+	actualPort := listener.Addr().(*net.TCPAddr).Port
+
+	// Start server in goroutine
+	serverErrCh := make(chan error, 1)
+	go func() {
+		s.logger.Info("Server starting", "port", actualPort, "url", fmt.Sprintf("http://localhost:%d", actualPort))
+		if err := httpServer.Serve(listener); err != nil && err != http.ErrServerClosed {
+			serverErrCh <- err
+		}
+	}()
+
+	// Wait for shutdown signal or server error
+	quit := make(chan os.Signal, 1)
+	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+
+	select {
+	case err := <-serverErrCh:
+		s.logger.Error("Server failed", "error", err)
+		return err
+	case <-quit:
+		s.logger.Info("Shutting down server")
+	}
+
+	// Graceful shutdown
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	if err := httpServer.Shutdown(ctx); err != nil {
+		s.logger.Error("Server forced to shutdown", "error", err)
+		return err
+	}
+
+	s.logger.Info("Server exited")
+	return nil
+}
+
+// getPortOwnerInfo tries to identify what process is using a port.
+// Returns a human-readable string with the PID and process name, or an error message.
+func getPortOwnerInfo(port string) string {
+	// Use lsof to find the process using the port
+	cmd := exec.Command("lsof", "-i", ":"+port, "-sTCP:LISTEN", "-n", "-P")
+	output, err := cmd.Output()
+	if err != nil {
+		return fmt.Sprintf("(unable to determine: %v)", err)
+	}
+
+	lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+	if len(lines) < 2 {
+		return "(no process found)"
+	}
+
+	// Parse lsof output: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
+	// Skip the header line
+	for _, line := range lines[1:] {
+		fields := strings.Fields(line)
+		if len(fields) >= 2 {
+			command := fields[0]
+			pid := fields[1]
+			return fmt.Sprintf("pid=%s process=%s", pid, command)
+		}
+	}
+
+	return "(could not parse lsof output)"
+}

server/sse_immediacy_test.go πŸ”—

@@ -0,0 +1,395 @@
+package server
+
+import (
+	"bufio"
+	"context"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/loop"
+)
+
+// flusherRecorder wraps httptest.ResponseRecorder to implement http.Flusher
+// and provide immediate access to written data
+type flusherRecorder struct {
+	*httptest.ResponseRecorder
+	mu      sync.Mutex
+	chunks  []string
+	flushed chan struct{}
+}
+
+func newFlusherRecorder() *flusherRecorder {
+	return &flusherRecorder{
+		ResponseRecorder: httptest.NewRecorder(),
+		flushed:          make(chan struct{}, 100),
+	}
+}
+
+func (f *flusherRecorder) Flush() {
+	f.mu.Lock()
+	body := f.Body.String()
+	f.chunks = append(f.chunks, body)
+	f.mu.Unlock()
+
+	select {
+	case f.flushed <- struct{}{}:
+	default:
+	}
+}
+
+func (f *flusherRecorder) getChunks() []string {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	result := make([]string, len(f.chunks))
+	copy(result, f.chunks)
+	return result
+}
+
+// TestSSEUserMessageAppearsImmediately tests that when a user sends a message,
+// the message appears in the SSE stream immediately, before the LLM responds.
+func TestSSEUserMessageAppearsImmediately(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+	server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+	// Create conversation
+	conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+	if err != nil {
+		t.Fatalf("failed to create conversation: %v", err)
+	}
+	conversationID := conversation.ConversationID
+
+	// Set up a context we can cancel to stop the SSE handler
+	sseCtx, sseCancel := context.WithCancel(context.Background())
+	defer sseCancel()
+
+	// Start the SSE stream handler in a goroutine
+	sseRecorder := newFlusherRecorder()
+	sseReq := httptest.NewRequest("GET", "/api/conversation/"+conversationID+"/stream", nil)
+	sseReq = sseReq.WithContext(sseCtx)
+
+	sseStarted := make(chan struct{})
+	sseDone := make(chan struct{})
+	go func() {
+		close(sseStarted)
+		server.handleStreamConversation(sseRecorder, sseReq, conversationID)
+		close(sseDone)
+	}()
+
+	// Wait for SSE handler to start and send initial state
+	<-sseStarted
+
+	// Wait for the initial SSE event (empty messages)
+	select {
+	case <-sseRecorder.flushed:
+		// Got initial state
+	case <-time.After(2 * time.Second):
+		t.Fatal("timed out waiting for initial SSE event")
+	}
+
+	// Now send a user message that triggers a SLOW LLM response (3 seconds delay)
+	chatReq := ChatRequest{
+		Message: "delay: 3",
+		Model:   "predictable",
+	}
+	chatBody, _ := json.Marshal(chatReq)
+
+	req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+
+	server.handleChatConversation(w, req, conversationID)
+	if w.Code != http.StatusAccepted {
+		t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+	}
+
+	// The user message should appear in the SSE stream IMMEDIATELY (within 500ms)
+	// NOT after the 3 second LLM delay
+	deadline := time.Now().Add(500 * time.Millisecond)
+	userMessageFound := false
+
+	for time.Now().Before(deadline) {
+		select {
+		case <-sseRecorder.flushed:
+			// Check if user message is now in the stream
+			body := sseRecorder.Body.String()
+			if containsUserMessage(body, "delay: 3") {
+				userMessageFound = true
+			}
+		case <-time.After(50 * time.Millisecond):
+			// Also check current body
+			body := sseRecorder.Body.String()
+			if containsUserMessage(body, "delay: 3") {
+				userMessageFound = true
+			}
+		}
+		if userMessageFound {
+			break
+		}
+	}
+
+	if !userMessageFound {
+		t.Errorf("BUG: user message did not appear in SSE stream within 500ms (LLM has 3s delay)")
+		t.Log("This likely means notifySubscribers is not being called immediately after recording the user message")
+		t.Logf("SSE body so far: %s", sseRecorder.Body.String())
+	} else {
+		t.Log("SUCCESS: user message appeared in SSE stream immediately")
+	}
+
+	// Clean up: cancel SSE context and wait for handler to finish
+	sseCancel()
+	select {
+	case <-sseDone:
+	case <-time.After(1 * time.Second):
+		// Handler may not exit immediately, that's OK
+	}
+}
+
+// containsUserMessage checks if the SSE body contains a user message with the given text
+func containsUserMessage(sseBody, messageText string) bool {
+	// SSE format is "data: {json}\n\n"
+	scanner := bufio.NewScanner(strings.NewReader(sseBody))
+	for scanner.Scan() {
+		line := scanner.Text()
+		if !strings.HasPrefix(line, "data: ") {
+			continue
+		}
+		jsonStr := strings.TrimPrefix(line, "data: ")
+
+		var streamResp StreamResponse
+		if err := json.Unmarshal([]byte(jsonStr), &streamResp); err != nil {
+			continue
+		}
+
+		for _, msg := range streamResp.Messages {
+			if msg.Type != string(db.MessageTypeUser) {
+				continue
+			}
+			if msg.LlmData == nil {
+				continue
+			}
+			var llmMsg llm.Message
+			if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+				continue
+			}
+			for _, content := range llmMsg.Content {
+				if content.Type == llm.ContentTypeText && strings.Contains(content.Text, messageText) {
+					return true
+				}
+			}
+		}
+	}
+	return false
+}
+
+// TestSSEUserMessageWithRealHTTPServer tests with a real HTTP server to properly
+// test HTTP context cancellation behavior
+func TestSSEUserMessageWithRealHTTPServer(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+	srv := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+	// Create conversation
+	conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+	if err != nil {
+		t.Fatalf("failed to create conversation: %v", err)
+	}
+	conversationID := conversation.ConversationID
+
+	// Set up real HTTP server
+	mux := http.NewServeMux()
+	srv.RegisterRoutes(mux)
+	httpServer := httptest.NewServer(mux)
+	defer httpServer.Close()
+
+	// Connect to SSE stream
+	sseResp, err := http.Get(httpServer.URL + "/api/conversation/" + conversationID + "/stream")
+	if err != nil {
+		t.Fatalf("failed to connect to SSE stream: %v", err)
+	}
+	defer sseResp.Body.Close()
+
+	// Start reading SSE events in background
+	sseEvents := make(chan string, 100)
+	go func() {
+		scanner := bufio.NewScanner(sseResp.Body)
+		for scanner.Scan() {
+			line := scanner.Text()
+			if strings.HasPrefix(line, "data: ") {
+				sseEvents <- line
+			}
+		}
+	}()
+
+	// Wait for initial SSE event
+	select {
+	case <-sseEvents:
+		// Got initial state
+	case <-time.After(2 * time.Second):
+		t.Fatal("timed out waiting for initial SSE event")
+	}
+
+	// Send user message with slow LLM response via real HTTP client
+	chatReq := ChatRequest{
+		Message: "delay: 5",
+		Model:   "predictable",
+	}
+	chatBody, _ := json.Marshal(chatReq)
+
+	resp, err := http.Post(
+		httpServer.URL+"/api/conversation/"+conversationID+"/chat",
+		"application/json",
+		strings.NewReader(string(chatBody)),
+	)
+	if err != nil {
+		t.Fatalf("failed to send chat message: %v", err)
+	}
+	resp.Body.Close()
+
+	if resp.StatusCode != http.StatusAccepted {
+		t.Fatalf("expected status 202, got %d", resp.StatusCode)
+	}
+
+	// User message should appear in SSE stream within 500ms (before 5s LLM delay)
+	deadline := time.Now().Add(500 * time.Millisecond)
+	userMessageFound := false
+
+	for time.Now().Before(deadline) && !userMessageFound {
+		select {
+		case eventLine := <-sseEvents:
+			jsonStr := strings.TrimPrefix(eventLine, "data: ")
+			var streamResp StreamResponse
+			if err := json.Unmarshal([]byte(jsonStr), &streamResp); err != nil {
+				continue
+			}
+			for _, msg := range streamResp.Messages {
+				if msg.Type == string(db.MessageTypeUser) && msg.LlmData != nil {
+					var llmMsg llm.Message
+					if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err == nil {
+						for _, content := range llmMsg.Content {
+							if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "delay: 5") {
+								userMessageFound = true
+								break
+							}
+						}
+					}
+				}
+			}
+		case <-time.After(50 * time.Millisecond):
+			// Keep waiting
+		}
+	}
+
+	if !userMessageFound {
+		t.Error("BUG: user message did not appear in SSE stream within 500ms with real HTTP server")
+		t.Log("This confirms the context cancellation bug in notifySubscribers")
+	} else {
+		t.Log("SUCCESS: user message appeared in SSE stream immediately with real HTTP server")
+	}
+}
+
+// TestSSEUserMessageWithExistingConnection is a simpler version that tests
+// message recording and notification without the SSE complexity
+func TestSSEUserMessageWithExistingConnection(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+	server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+	// Create conversation and get a manager (simulating an established SSE connection)
+	conversation, err := database.CreateConversation(context.Background(), nil, true, nil)
+	if err != nil {
+		t.Fatalf("failed to create conversation: %v", err)
+	}
+	conversationID := conversation.ConversationID
+
+	// Get the conversation manager to set up subscription
+	manager, err := server.getOrCreateConversationManager(context.Background(), conversationID)
+	if err != nil {
+		t.Fatalf("failed to get conversation manager: %v", err)
+	}
+
+	// Subscribe to updates
+	subCtx, subCancel := context.WithCancel(context.Background())
+	defer subCancel()
+	next := manager.subpub.Subscribe(subCtx, -1)
+
+	// Channel to receive updates
+	updates := make(chan StreamResponse, 10)
+	go func() {
+		for {
+			data, ok := next()
+			if !ok {
+				return
+			}
+			updates <- data
+		}
+	}()
+
+	// Now send a user message with slow LLM response
+	chatReq := ChatRequest{
+		Message: "delay: 5",
+		Model:   "predictable",
+	}
+	chatBody, _ := json.Marshal(chatReq)
+
+	req := httptest.NewRequest("POST", "/api/conversation/"+conversationID+"/chat", strings.NewReader(string(chatBody)))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+
+	server.handleChatConversation(w, req, conversationID)
+	if w.Code != http.StatusAccepted {
+		t.Fatalf("expected status 202, got %d: %s", w.Code, w.Body.String())
+	}
+
+	// We should receive an update with the user message within 500ms
+	// (well before the 5 second LLM delay)
+	select {
+	case update := <-updates:
+		// Check that the update contains the user message
+		foundUserMsg := false
+		for _, msg := range update.Messages {
+			if msg.Type == string(db.MessageTypeUser) && msg.LlmData != nil {
+				var llmMsg llm.Message
+				if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err == nil {
+					for _, content := range llmMsg.Content {
+						if content.Type == llm.ContentTypeText && strings.Contains(content.Text, "delay: 5") {
+							foundUserMsg = true
+							break
+						}
+					}
+				}
+			}
+		}
+		if !foundUserMsg {
+			t.Error("received update but it didn't contain the user message")
+			t.Logf("update had %d messages", len(update.Messages))
+		} else {
+			t.Log("SUCCESS: received user message via subpub immediately")
+		}
+	case <-time.After(500 * time.Millisecond):
+		t.Error("BUG: did not receive subpub update with user message within 500ms")
+		t.Log("This means notifySubscribers is failing or not being called after user message is recorded")
+	}
+}

server/system_prompt.go πŸ”—

@@ -0,0 +1,287 @@
+package server
+
+import (
+	_ "embed"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"text/template"
+)
+
+//go:embed system_prompt.txt
+var systemPromptTemplate string
+
+// SystemPromptData contains all the data needed to render the system prompt template
+type SystemPromptData struct {
+	WorkingDirectory string
+	GitInfo          *GitInfo
+	Codebase         *CodebaseInfo
+	IsExeDev         bool
+	IsSudoAvailable  bool
+	Hostname         string // For exe.dev, the public hostname (e.g., "vmname.exe.xyz")
+	ShelleyDBPath    string // Path to the shelley database
+}
+
+// DBPath is the path to the shelley database, set at startup
+var DBPath string
+
+type GitInfo struct {
+	Root string
+}
+
+type CodebaseInfo struct {
+	InjectFiles        []string
+	InjectFileContents map[string]string
+	GuidanceFiles      []string
+}
+
+// GenerateSystemPrompt generates the system prompt using the embedded template.
+// If workingDir is empty, it uses the current working directory.
+func GenerateSystemPrompt(workingDir string) (string, error) {
+	data, err := collectSystemData(workingDir)
+	if err != nil {
+		return "", fmt.Errorf("failed to collect system data: %w", err)
+	}
+
+	tmpl, err := template.New("system_prompt").Parse(systemPromptTemplate)
+	if err != nil {
+		return "", fmt.Errorf("failed to parse template: %w", err)
+	}
+
+	var buf strings.Builder
+	err = tmpl.Execute(&buf, data)
+	if err != nil {
+		return "", fmt.Errorf("failed to execute template: %w", err)
+	}
+
+	return buf.String(), nil
+}
+
+func collectSystemData(workingDir string) (*SystemPromptData, error) {
+	wd := workingDir
+	if wd == "" {
+		var err error
+		wd, err = os.Getwd()
+		if err != nil {
+			return nil, fmt.Errorf("failed to get working directory: %w", err)
+		}
+	}
+
+	data := &SystemPromptData{
+		WorkingDirectory: wd,
+	}
+
+	// Try to collect git info
+	gitInfo, err := collectGitInfo()
+	if err == nil {
+		data.GitInfo = gitInfo
+	}
+
+	// Collect codebase info
+	codebaseInfo, err := collectCodebaseInfo(wd, gitInfo)
+	if err == nil {
+		data.Codebase = codebaseInfo
+	}
+
+	// Check if running on exe.dev
+	data.IsExeDev = isExeDev()
+
+	// Check sudo availability
+	data.IsSudoAvailable = isSudoAvailable()
+
+	// Get hostname for exe.dev
+	if data.IsExeDev {
+		if hostname, err := os.Hostname(); err == nil {
+			// If hostname doesn't contain dots, add .exe.xyz suffix
+			if !strings.Contains(hostname, ".") {
+				hostname = hostname + ".exe.xyz"
+			}
+			data.Hostname = hostname
+		}
+	}
+
+	// Set shelley database path if it was configured
+	if DBPath != "" {
+		// Convert to absolute path if relative
+		if !filepath.IsAbs(DBPath) {
+			if absPath, err := filepath.Abs(DBPath); err == nil {
+				data.ShelleyDBPath = absPath
+			} else {
+				data.ShelleyDBPath = DBPath
+			}
+		} else {
+			data.ShelleyDBPath = DBPath
+		}
+	}
+
+	return data, nil
+}
+
+func collectGitInfo() (*GitInfo, error) {
+	// Find git root
+	rootCmd := exec.Command("git", "rev-parse", "--show-toplevel")
+	rootOutput, err := rootCmd.Output()
+	if err != nil {
+		return nil, err
+	}
+	root := strings.TrimSpace(string(rootOutput))
+
+	return &GitInfo{
+		Root: root,
+	}, nil
+}
+
+func collectCodebaseInfo(wd string, gitInfo *GitInfo) (*CodebaseInfo, error) {
+	info := &CodebaseInfo{
+		InjectFiles:        []string{},
+		InjectFileContents: make(map[string]string),
+		GuidanceFiles:      []string{},
+	}
+
+	// Track seen files to avoid duplicates on case-insensitive file systems
+	seenFiles := make(map[string]bool)
+
+	// Check for user-level agent instructions in ~/.config/shelley/AGENTS.md and ~/.shelley/AGENTS.md
+	if home, err := os.UserHomeDir(); err == nil {
+		// Prefer ~/.config/shelley/AGENTS.md (XDG convention)
+		configAgentsFile := filepath.Join(home, ".config", "shelley", "AGENTS.md")
+		if content, err := os.ReadFile(configAgentsFile); err == nil && len(content) > 0 {
+			info.InjectFiles = append(info.InjectFiles, configAgentsFile)
+			info.InjectFileContents[configAgentsFile] = string(content)
+			seenFiles[strings.ToLower(configAgentsFile)] = true
+		}
+		// Also check legacy ~/.shelley/AGENTS.md location
+		shelleyAgentsFile := filepath.Join(home, ".shelley", "AGENTS.md")
+		if content, err := os.ReadFile(shelleyAgentsFile); err == nil && len(content) > 0 {
+			lowerPath := strings.ToLower(shelleyAgentsFile)
+			if !seenFiles[lowerPath] {
+				info.InjectFiles = append(info.InjectFiles, shelleyAgentsFile)
+				info.InjectFileContents[shelleyAgentsFile] = string(content)
+				seenFiles[lowerPath] = true
+			}
+		}
+	}
+
+	// Determine the root directory to search
+	searchRoot := wd
+	if gitInfo != nil {
+		searchRoot = gitInfo.Root
+	}
+
+	// Find root-level guidance files (case-insensitive)
+	rootGuidanceFiles := findGuidanceFilesInDir(searchRoot)
+	for _, file := range rootGuidanceFiles {
+		lowerPath := strings.ToLower(file)
+		if seenFiles[lowerPath] {
+			continue
+		}
+		seenFiles[lowerPath] = true
+
+		content, err := os.ReadFile(file)
+		if err == nil && len(content) > 0 {
+			info.InjectFiles = append(info.InjectFiles, file)
+			info.InjectFileContents[file] = string(content)
+		}
+	}
+
+	// If working directory is different from root, also check working directory
+	if wd != searchRoot {
+		wdGuidanceFiles := findGuidanceFilesInDir(wd)
+		for _, file := range wdGuidanceFiles {
+			lowerPath := strings.ToLower(file)
+			if seenFiles[lowerPath] {
+				continue
+			}
+			seenFiles[lowerPath] = true
+
+			content, err := os.ReadFile(file)
+			if err == nil && len(content) > 0 {
+				info.InjectFiles = append(info.InjectFiles, file)
+				info.InjectFileContents[file] = string(content)
+			}
+		}
+	}
+
+	// Find all guidance files recursively for the directory listing
+	allGuidanceFiles := findAllGuidanceFiles(searchRoot)
+	info.GuidanceFiles = allGuidanceFiles
+
+	return info, nil
+}
+
+func findGuidanceFilesInDir(dir string) []string {
+	// Read directory entries to handle case-insensitive file systems
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return nil
+	}
+
+	guidanceNames := map[string]bool{
+		"agent.md":    true,
+		"claude.md":   true,
+		"dear_llm.md": true,
+		"readme.md":   true,
+	}
+
+	var found []string
+	seen := make(map[string]bool)
+
+	for _, entry := range entries {
+		if entry.IsDir() {
+			continue
+		}
+		lowerName := strings.ToLower(entry.Name())
+		if guidanceNames[lowerName] && !seen[lowerName] {
+			seen[lowerName] = true
+			found = append(found, filepath.Join(dir, entry.Name()))
+		}
+	}
+	return found
+}
+
+func findAllGuidanceFiles(root string) []string {
+	guidanceNames := map[string]bool{
+		"agent.md":    true,
+		"claude.md":   true,
+		"dear_llm.md": true,
+	}
+
+	var found []string
+	seen := make(map[string]bool)
+
+	filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return nil // Continue on errors
+		}
+		if info.IsDir() {
+			// Skip hidden directories and common ignore patterns
+			if strings.HasPrefix(info.Name(), ".") || info.Name() == "node_modules" || info.Name() == "vendor" {
+				return filepath.SkipDir
+			}
+			return nil
+		}
+		lowerName := strings.ToLower(info.Name())
+		if guidanceNames[lowerName] {
+			lowerPath := strings.ToLower(path)
+			if !seen[lowerPath] {
+				seen[lowerPath] = true
+				found = append(found, path)
+			}
+		}
+		return nil
+	})
+	return found
+}
+
+func isExeDev() bool {
+	_, err := os.Stat("/exe.dev")
+	return err == nil
+}
+
+func isSudoAvailable() bool {
+	cmd := exec.Command("sudo", "-n", "id")
+	_, err := cmd.CombinedOutput()
+	return err == nil
+}

server/system_prompt.txt πŸ”—

@@ -0,0 +1,78 @@
+You are Shelley, a coding agent and assistant. You are an experienced software engineer and architect. You communicate with brevity.
+
+You have access to a variety of tools to get your job done. Be persistent and creative.
+
+Working directory: {{.WorkingDirectory}}
+
+{{if .GitInfo}}
+Git repository root: {{.GitInfo.Root}}
+
+If you are making code changes, make commits with good commit messages before returning to the user.
+{{else}}Not in a git repository.
+{{end}}
+{{if .IsExeDev}}
+<exe_dev>
+You are running on a VM in the exe.dev hosting service. If you run an HTTP service on localhost on ports 3000-9999, the user can see that on https://{{.Hostname}}:<port>/.
+Port 8000 is a good default choice. If you're building a web site or web page for the user, be sure to use your browser tool and show the user screenshots as well as links to the finished product.
+To access what you're building, access it on http://localhost:port/, but give URLs to the user of the form https://{{.Hostname}}:port/
+
+For exe.dev documentation, retrieve https://exe.dev/docs/all.md
+
+{{if .IsSudoAvailable}}<sudo_access>available</sudo_access>{{else}}<sudo_access>not_available</sudo_access>{{end}}
+
+<systemd>
+To run a service persistently, install a systemd unit file. Example for a service binary at /home/exedev/srv:
+
+  sudo cp srv.service /etc/systemd/system/srv.service
+  sudo systemctl daemon-reload
+  sudo systemctl enable srv.service
+  sudo systemctl start srv
+
+Manage with: systemctl status srv, systemctl restart srv, journalctl -u srv -f
+</systemd>
+
+<project_templates>
+If the user wants to create a new Go web application or service, you can use the "go" project template as a starting point. Run:
+  mkdir -p /path/to/project && shelley unpack-template go /path/to/project
+This provides a complete Go web server with HTTP handlers, SQLite database, migrations, and systemd service configuration. After unpacking, initialize a git repository with `git init` and make an initial commit.
+</project_templates>
+</exe_dev>
+{{end}}
+{{if .Codebase}}
+<customization>
+Guidance files (dear_llm.md, agent.md, claude.md) contain project information and direct user instructions.
+Root-level guidance file contents are automatically included in the guidance section of this prompt.
+Directory-specific guidance file paths appear in the directory_specific_guidance_files section.
+Before modifying any file, you MUST proactively read and follow all guidance files in its directory and all parent directories.
+When guidance files conflict, more-deeply-nested files take precedence.
+Direct user instructions from the current conversation always take highest precedence.
+</customization>
+{{if .Codebase.InjectFiles}}
+<guidance>
+{{range .Codebase.InjectFiles}}<root_guidance file="{{.}}">
+{{index $.Codebase.InjectFileContents .}}
+</root_guidance>
+{{end}}</guidance>
+{{end}}
+{{if .Codebase.GuidanceFiles}}
+<directory_specific_guidance_files>
+{{range .Codebase.GuidanceFiles}}{{.}}
+{{end}}</directory_specific_guidance_files>
+{{end}}
+{{end}}
+{{if .ShelleyDBPath}}
+<previous_conversations>
+Your conversation history is stored in a SQLite database at: {{.ShelleyDBPath}}
+
+If the user wants to refer to a previous conversation, you can read it using sqlite3:
+
+# List recent conversations:
+sqlite3 "{{.ShelleyDBPath}}" "SELECT conversation_id, slug, datetime(created_at, 'localtime') as created, datetime(updated_at, 'localtime') as updated FROM conversations ORDER BY updated_at DESC LIMIT 20;"
+
+# Get messages from a specific conversation (replace CONVERSATION_ID):
+sqlite3 "{{.ShelleyDBPath}}" "SELECT type, CASE WHEN type='user' THEN json_extract(user_data, '$.text') ELSE substr(llm_data, 1, 500) END as content FROM messages WHERE conversation_id='CONVERSATION_ID' ORDER BY sequence_id;"
+
+# Search conversations by slug:
+sqlite3 "{{.ShelleyDBPath}}" "SELECT conversation_id, slug FROM conversations WHERE slug LIKE '%SEARCH_TERM%';"
+</previous_conversations>
+{{end}}

server/testharness_test.go πŸ”—

@@ -0,0 +1,251 @@
+package server
+
+import (
+	"context"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/db/generated"
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/loop"
+)
+
+// TestHarness provides a DSL-like interface for testing conversations.
+type TestHarness struct {
+	t              *testing.T
+	db             *db.DB
+	server         *Server
+	cleanup        func()
+	llm            *loop.PredictableService
+	convID         string
+	timeout        time.Duration
+	responsesCount int // Number of agent responses seen so far
+}
+
+// NewTestHarness creates a new test harness with a predictable LLM and bash tool.
+func NewTestHarness(t *testing.T) *TestHarness {
+	t.Helper()
+
+	database, cleanup := setupTestDB(t)
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+
+	toolSetConfig := claudetool.ToolSetConfig{EnableBrowser: false}
+	server := NewServer(database, llmManager, toolSetConfig, logger, true, "", "predictable", "", nil)
+
+	return &TestHarness{
+		t:       t,
+		db:      database,
+		server:  server,
+		cleanup: cleanup,
+		llm:     predictableService,
+		timeout: 5 * time.Second,
+	}
+}
+
+// Close cleans up the test harness resources.
+func (h *TestHarness) Close() {
+	h.cleanup()
+}
+
+// NewConversation starts a new conversation with the given message and options.
+func (h *TestHarness) NewConversation(msg, cwd string) *TestHarness {
+	h.t.Helper()
+
+	chatReq := ChatRequest{
+		Message: msg,
+		Model:   "predictable",
+		Cwd:     cwd,
+	}
+	chatBody, _ := json.Marshal(chatReq)
+
+	req := httptest.NewRequest("POST", "/api/conversations/new", strings.NewReader(string(chatBody)))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+
+	h.server.handleNewConversation(w, req)
+	if w.Code != http.StatusCreated {
+		h.t.Fatalf("NewConversation: expected status 201, got %d: %s", w.Code, w.Body.String())
+	}
+
+	var resp struct {
+		ConversationID string `json:"conversation_id"`
+	}
+	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+		h.t.Fatalf("NewConversation: failed to parse response: %v", err)
+	}
+	h.convID = resp.ConversationID
+	return h
+}
+
+// Chat sends a message to the current conversation.
+func (h *TestHarness) Chat(msg string) *TestHarness {
+	h.t.Helper()
+
+	if h.convID == "" {
+		h.t.Fatal("Chat: no conversation started, call NewConversation first")
+	}
+
+	chatReq := ChatRequest{
+		Message: msg,
+		Model:   "predictable",
+	}
+	chatBody, _ := json.Marshal(chatReq)
+
+	req := httptest.NewRequest("POST", "/api/conversation/"+h.convID+"/chat", strings.NewReader(string(chatBody)))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+
+	h.server.handleChatConversation(w, req, h.convID)
+	if w.Code != http.StatusAccepted {
+		h.t.Fatalf("Chat: expected status 202, got %d: %s", w.Code, w.Body.String())
+	}
+	return h
+}
+
+// WaitToolResult waits for a tool result and returns its text content.
+func (h *TestHarness) WaitToolResult() string {
+	h.t.Helper()
+
+	if h.convID == "" {
+		h.t.Fatal("WaitToolResult: no conversation started")
+	}
+
+	deadline := time.Now().Add(h.timeout)
+	for time.Now().Before(deadline) {
+		var messages []generated.Message
+		err := h.db.Queries(context.Background(), func(q *generated.Queries) error {
+			var qerr error
+			messages, qerr = q.ListMessages(context.Background(), h.convID)
+			return qerr
+		})
+		if err != nil {
+			h.t.Fatalf("WaitToolResult: failed to get messages: %v", err)
+		}
+
+		for _, msg := range messages {
+			if msg.Type != string(db.MessageTypeUser) || msg.LlmData == nil {
+				continue
+			}
+
+			var llmMsg llm.Message
+			if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+				continue
+			}
+
+			for _, content := range llmMsg.Content {
+				if content.Type == llm.ContentTypeToolResult {
+					for _, result := range content.ToolResult {
+						if result.Type == llm.ContentTypeText && result.Text != "" {
+							return result.Text
+						}
+					}
+				}
+			}
+		}
+
+		time.Sleep(100 * time.Millisecond)
+	}
+
+	h.t.Fatalf("WaitToolResult: timed out waiting for tool result")
+	return ""
+}
+
+// WaitResponse waits for the assistant's text response (end of turn).
+// It waits for a NEW response that hasn't been seen before.
+func (h *TestHarness) WaitResponse() string {
+	h.t.Helper()
+
+	if h.convID == "" {
+		h.t.Fatal("WaitResponse: no conversation started")
+	}
+
+	targetCount := h.responsesCount + 1
+
+	deadline := time.Now().Add(h.timeout)
+	for time.Now().Before(deadline) {
+		var messages []generated.Message
+		err := h.db.Queries(context.Background(), func(q *generated.Queries) error {
+			var qerr error
+			messages, qerr = q.ListMessages(context.Background(), h.convID)
+			return qerr
+		})
+		if err != nil {
+			h.t.Fatalf("WaitResponse: failed to get messages: %v", err)
+		}
+
+		// Count assistant messages with end_of_turn
+		count := 0
+		var lastText string
+		for _, msg := range messages {
+			if msg.Type != string(db.MessageTypeAgent) || msg.LlmData == nil {
+				continue
+			}
+
+			var llmMsg llm.Message
+			if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+				continue
+			}
+
+			if llmMsg.EndOfTurn {
+				count++
+				for _, content := range llmMsg.Content {
+					if content.Type == llm.ContentTypeText {
+						lastText = content.Text
+						break
+					}
+				}
+			}
+		}
+
+		if count >= targetCount {
+			h.responsesCount = count
+			return lastText
+		}
+
+		time.Sleep(100 * time.Millisecond)
+	}
+
+	h.t.Fatalf("WaitResponse: timed out waiting for response (seen %d, need %d)", h.responsesCount, targetCount)
+	return ""
+}
+
+// ConversationID returns the current conversation ID.
+func (h *TestHarness) ConversationID() string {
+	return h.convID
+}
+
+// GetContextWindowSize retrieves the current context window size from the server.
+func (h *TestHarness) GetContextWindowSize() uint64 {
+	h.t.Helper()
+
+	if h.convID == "" {
+		h.t.Fatal("GetContextWindowSize: no conversation started")
+	}
+
+	// Use handleGetConversation (GET /conversation/<id>) instead of stream endpoint
+	req := httptest.NewRequest("GET", "/api/conversation/"+h.convID, nil)
+	w := httptest.NewRecorder()
+
+	h.server.handleGetConversation(w, req, h.convID)
+	if w.Code != http.StatusOK {
+		h.t.Fatalf("GetContextWindowSize: expected status 200, got %d: %s", w.Code, w.Body.String())
+	}
+
+	var resp StreamResponse
+	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+		h.t.Fatalf("GetContextWindowSize: failed to parse response: %v", err)
+	}
+
+	return resp.ContextWindowSize
+}

server/upload_test.go πŸ”—

@@ -0,0 +1,264 @@
+package server
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"log/slog"
+	"mime/multipart"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/claudetool/browse"
+	"shelley.exe.dev/loop"
+)
+
+func TestUploadEndpoint(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.Default()
+	server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+	// Create a multipart form with a file
+	body := &bytes.Buffer{}
+	writer := multipart.NewWriter(body)
+
+	// Create a test file
+	part, err := writer.CreateFormFile("file", "test.png")
+	if err != nil {
+		t.Fatalf("failed to create form file: %v", err)
+	}
+
+	// Write some fake PNG content (just the magic header bytes)
+	pngData := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
+	if _, err := part.Write(pngData); err != nil {
+		t.Fatalf("failed to write file content: %v", err)
+	}
+	writer.Close()
+
+	req := httptest.NewRequest("POST", "/api/upload", body)
+	req.Header.Set("Content-Type", writer.FormDataContentType())
+	w := httptest.NewRecorder()
+
+	server.handleUpload(w, req)
+
+	if w.Code != http.StatusOK {
+		t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+	}
+
+	var response map[string]string
+	if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
+		t.Fatalf("failed to parse response: %v", err)
+	}
+
+	path, ok := response["path"]
+	if !ok {
+		t.Fatal("response missing 'path' field")
+	}
+
+	// Verify the path is in the screenshot directory
+	if !strings.HasPrefix(path, browse.ScreenshotDir) {
+		t.Errorf("expected path to start with %s, got %s", browse.ScreenshotDir, path)
+	}
+
+	// Verify the file has the correct extension
+	if !strings.HasSuffix(path, ".png") {
+		t.Errorf("expected path to end with .png, got %s", path)
+	}
+
+	// Verify the file exists and contains our data
+	data, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("failed to read uploaded file: %v", err)
+	}
+
+	if !bytes.Equal(data, pngData) {
+		t.Errorf("uploaded file content mismatch")
+	}
+
+	// Clean up uploaded file
+	os.Remove(path)
+}
+
+func TestUploadEndpointMethodNotAllowed(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.Default()
+	server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+	req := httptest.NewRequest("GET", "/api/upload", nil)
+	w := httptest.NewRecorder()
+
+	server.handleUpload(w, req)
+
+	if w.Code != http.StatusMethodNotAllowed {
+		t.Fatalf("expected status 405, got %d", w.Code)
+	}
+}
+
+func TestUploadEndpointNoFile(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.Default()
+	server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+	// Create an empty multipart form
+	body := &bytes.Buffer{}
+	writer := multipart.NewWriter(body)
+	writer.Close()
+
+	req := httptest.NewRequest("POST", "/api/upload", body)
+	req.Header.Set("Content-Type", writer.FormDataContentType())
+	w := httptest.NewRecorder()
+
+	server.handleUpload(w, req)
+
+	if w.Code != http.StatusBadRequest {
+		t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
+	}
+}
+
+func TestUploadedFileCanBeReadViaReadEndpoint(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.Default()
+	server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+	// First, upload a file
+	body := &bytes.Buffer{}
+	writer := multipart.NewWriter(body)
+
+	part, err := writer.CreateFormFile("file", "test.jpg")
+	if err != nil {
+		t.Fatalf("failed to create form file: %v", err)
+	}
+
+	// Write some fake JPEG content
+	jpgData := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46}
+	if _, err := part.Write(jpgData); err != nil {
+		t.Fatalf("failed to write file content: %v", err)
+	}
+	writer.Close()
+
+	uploadReq := httptest.NewRequest("POST", "/api/upload", body)
+	uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
+	uploadW := httptest.NewRecorder()
+
+	server.handleUpload(uploadW, uploadReq)
+
+	if uploadW.Code != http.StatusOK {
+		t.Fatalf("upload failed: %s", uploadW.Body.String())
+	}
+
+	var uploadResponse map[string]string
+	if err := json.Unmarshal(uploadW.Body.Bytes(), &uploadResponse); err != nil {
+		t.Fatalf("failed to parse upload response: %v", err)
+	}
+
+	path := uploadResponse["path"]
+
+	// Now try to read the file via the read endpoint
+	readReq := httptest.NewRequest("GET", "/api/read?path="+path, nil)
+	readW := httptest.NewRecorder()
+
+	server.handleRead(readW, readReq)
+
+	if readW.Code != http.StatusOK {
+		t.Fatalf("read failed with status %d: %s", readW.Code, readW.Body.String())
+	}
+
+	// Verify content type
+	contentType := readW.Header().Get("Content-Type")
+	if contentType != "image/jpeg" {
+		t.Errorf("expected Content-Type image/jpeg, got %s", contentType)
+	}
+
+	// Verify content
+	readData, err := io.ReadAll(readW.Body)
+	if err != nil {
+		t.Fatalf("failed to read response body: %v", err)
+	}
+
+	if !bytes.Equal(readData, jpgData) {
+		t.Errorf("read content mismatch")
+	}
+
+	// Clean up
+	os.Remove(path)
+}
+
+func TestUploadPreservesFileExtension(t *testing.T) {
+	database, cleanup := setupTestDB(t)
+	defer cleanup()
+
+	predictableService := loop.NewPredictableService()
+	llmManager := &testLLMManager{service: predictableService}
+	logger := slog.Default()
+	server := NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "predictable", "", nil)
+
+	testCases := []struct {
+		filename string
+		wantExt  string
+	}{
+		{"photo.png", ".png"},
+		{"image.jpeg", ".jpeg"},
+		{"screenshot.gif", ".gif"},
+		{"document.pdf", ".pdf"},
+		{"noextension", ""},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.filename, func(t *testing.T) {
+			body := &bytes.Buffer{}
+			writer := multipart.NewWriter(body)
+
+			part, err := writer.CreateFormFile("file", tc.filename)
+			if err != nil {
+				t.Fatalf("failed to create form file: %v", err)
+			}
+			part.Write([]byte("test content"))
+			writer.Close()
+
+			req := httptest.NewRequest("POST", "/api/upload", body)
+			req.Header.Set("Content-Type", writer.FormDataContentType())
+			w := httptest.NewRecorder()
+
+			server.handleUpload(w, req)
+
+			if w.Code != http.StatusOK {
+				t.Fatalf("expected status 200, got %d", w.Code)
+			}
+
+			var response map[string]string
+			if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
+				t.Fatalf("failed to parse response: %v", err)
+			}
+
+			path := response["path"]
+			ext := filepath.Ext(path)
+			if ext != tc.wantExt {
+				t.Errorf("expected extension %q, got %q", tc.wantExt, ext)
+			}
+
+			// Clean up
+			os.Remove(path)
+		})
+	}
+}

slug/slug.go πŸ”—

@@ -0,0 +1,167 @@
+package slug
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"regexp"
+	"strings"
+	"time"
+
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/llm"
+)
+
+// LLMServiceProvider defines the interface for getting LLM services
+type LLMServiceProvider interface {
+	GetService(modelID string) (llm.Service, error)
+}
+
+// GenerateSlug generates a slug for a conversation and updates the database
+// If conversationModelID is provided, it will try to use that model first before falling back to the default list
+func GenerateSlug(ctx context.Context, llmProvider LLMServiceProvider, database *db.DB, logger *slog.Logger, conversationID, userMessage, conversationModelID string) (string, error) {
+	baseSlug, err := generateSlugText(ctx, llmProvider, logger, userMessage, conversationModelID)
+	if err != nil {
+		return "", err
+	}
+
+	// Try to update with the base slug first, then with numeric suffixes if needed
+	slug := baseSlug
+	for attempt := 0; attempt < 100; attempt++ {
+		_, err = database.UpdateConversationSlug(ctx, conversationID, slug)
+		if err == nil {
+			// Success!
+			logger.Info("Generated slug for conversation", "conversationID", conversationID, "slug", slug)
+			return slug, nil
+		}
+
+		// Check if this is a unique constraint violation
+		if strings.Contains(strings.ToLower(err.Error()), "unique constraint failed") ||
+			strings.Contains(strings.ToLower(err.Error()), "unique constraint") ||
+			strings.Contains(strings.ToLower(err.Error()), "duplicate") {
+			// Try with a numeric suffix
+			slug = fmt.Sprintf("%s-%d", baseSlug, attempt+1)
+			continue
+		}
+
+		// Some other error occurred
+		return "", fmt.Errorf("failed to update conversation slug: %w", err)
+	}
+
+	// If we've tried 100 times and still failed, give up
+	return "", fmt.Errorf("failed to generate unique slug after 100 attempts")
+}
+
+// generateSlugText generates a human-readable slug for a conversation based on the user message
+// If conversationModelID is "predictable", it will be used instead of the default preferred models
+func generateSlugText(ctx context.Context, llmProvider LLMServiceProvider, logger *slog.Logger, userMessage, conversationModelID string) (string, error) {
+	// Try different models in order of preference
+	var llmService llm.Service
+	var err error
+
+	// Preferred models in order of preference
+	preferredModels := []string{"qwen3-coder-fireworks", "gpt5-mini", "gpt-5-thinking-mini", "claude-sonnet-4.5", "predictable"}
+
+	// If conversation is using predictable model, use it for slug generation too
+	if conversationModelID == "predictable" {
+		llmService, err = llmProvider.GetService("predictable")
+		if err == nil {
+			logger.Debug("Using predictable model for slug generation")
+		} else {
+			logger.Debug("Predictable model not available for slug generation", "error", err)
+		}
+	}
+
+	// If we didn't get the predictable service, try the preferred models
+	if llmService == nil {
+		for _, model := range preferredModels {
+			llmService, err = llmProvider.GetService(model)
+			if err == nil {
+				logger.Debug("Using preferred model for slug generation", "model", model)
+				break
+			}
+			logger.Debug("Model not available for slug generation", "model", model, "error", err)
+		}
+	}
+
+	if llmService == nil {
+		return "", fmt.Errorf("no suitable model available for slug generation")
+	}
+
+	// Create a focused prompt for slug generation
+	slugPrompt := fmt.Sprintf(`Generate a short, descriptive slug (2-6 words, lowercase, hyphen-separated) for a conversation that starts with this user message:
+
+%s
+
+The slug should:
+- Be concise and descriptive
+- Use only lowercase letters, numbers, and hyphens
+- Capture the main topic or intent
+- Be suitable as a filename or URL path
+
+Respond with only the slug, nothing else.`, userMessage)
+
+	message := llm.Message{
+		Role: llm.MessageRoleUser,
+		Content: []llm.Content{
+			{Type: llm.ContentTypeText, Text: slugPrompt},
+		},
+	}
+
+	request := &llm.Request{
+		Messages: []llm.Message{message},
+	}
+
+	// Make LLM request with timeout
+	ctxWithTimeout, cancel := context.WithTimeout(ctx, 10*time.Second)
+	defer cancel()
+
+	response, err := llmService.Do(ctxWithTimeout, request)
+	if err != nil {
+		return "", fmt.Errorf("failed to generate slug: %w", err)
+	}
+
+	// Extract text from response
+	if len(response.Content) == 0 {
+		return "", fmt.Errorf("empty response from LLM")
+	}
+
+	slug := strings.TrimSpace(response.Content[0].Text)
+
+	// Clean and validate the slug
+	slug = Sanitize(slug)
+	if slug == "" {
+		return "", fmt.Errorf("generated slug is empty after sanitization")
+	}
+
+	// Note: We don't check for uniqueness here since we're generating for a new conversation
+	// and the database will handle any conflicts
+
+	return slug, nil
+}
+
+// Sanitize cleans a string to be a valid slug
+func Sanitize(input string) string {
+	// Convert to lowercase
+	slug := strings.ToLower(input)
+
+	// Replace spaces and underscores with hyphens
+	slug = regexp.MustCompile(`[\s_]+`).ReplaceAllString(slug, "-")
+
+	// Remove non-alphanumeric characters except hyphens
+	slug = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(slug, "")
+
+	// Remove multiple consecutive hyphens
+	slug = regexp.MustCompile(`-+`).ReplaceAllString(slug, "-")
+
+	// Remove leading/trailing hyphens
+	slug = strings.Trim(slug, "-")
+
+	// Limit length
+	if len(slug) > 60 {
+		slug = slug[:60]
+		slug = strings.Trim(slug, "-")
+	}
+
+	return slug
+}

slug/slug_test.go πŸ”—

@@ -0,0 +1,178 @@
+package slug
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"os"
+	"testing"
+
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/llm"
+)
+
+func TestSanitize(t *testing.T) {
+	tests := []struct {
+		input    string
+		expected string
+	}{
+		{"Simple Test", "simple-test"},
+		{"Create a Python Script", "create-a-python-script"},
+		{"Multiple   Spaces", "multiple-spaces"},
+		{"Special@#$%Characters", "specialcharacters"},
+		{"Under_Score_Test", "under-score-test"},
+		{"--multiple-hyphens--", "multiple-hyphens"},
+		{"CamelCase Example", "camelcase-example"},
+		{"123 Numbers Test 456", "123-numbers-test-456"},
+		{"   leading and trailing   ", "leading-and-trailing"},
+		{"", ""},
+		{"Very Long Slug That Might Need To Be Truncated Because It Is Too Long For Normal Use", "very-long-slug-that-might-need-to-be-truncated-because-it-is"},
+	}
+
+	for _, test := range tests {
+		result := Sanitize(test.input)
+		if result != test.expected {
+			t.Errorf("Sanitize(%q) = %q, expected %q", test.input, result, test.expected)
+		}
+	}
+}
+
+// TestGenerateUniqueSlug tests that slug generation adds numeric suffixes when there are conflicts
+func TestGenerateSlug_UniquenessSuffix(t *testing.T) {
+	// This test verifies the numeric suffix logic without needing a real database or LLM
+	// We'll test the error handling and retry logic by mocking the behavior
+
+	// Test the sanitization works as expected first
+	baseSlug := Sanitize("Test Message")
+	expected := "test-message"
+	if baseSlug != expected {
+		t.Errorf("Sanitize failed: got %q, expected %q", baseSlug, expected)
+	}
+
+	// Test that numeric suffixes would be correctly formatted
+	// This mimics what the GenerateSlug function does internally
+	tests := []struct {
+		baseSlug string
+		attempt  int
+		expected string
+	}{
+		{"test-message", 0, "test-message-1"},
+		{"test-message", 1, "test-message-2"},
+		{"test-message", 2, "test-message-3"},
+		{"help-python", 9, "help-python-10"},
+	}
+
+	for _, test := range tests {
+		result := fmt.Sprintf("%s-%d", test.baseSlug, test.attempt+1)
+		if result != test.expected {
+			t.Errorf("Suffix generation failed: got %q, expected %q", result, test.expected)
+		}
+	}
+}
+
+// MockLLMService provides a mock LLM service for testing
+type MockLLMService struct {
+	ResponseText string
+}
+
+func (m *MockLLMService) Do(ctx context.Context, req *llm.Request) (*llm.Response, error) {
+	return &llm.Response{
+		Content: []llm.Content{
+			{Type: llm.ContentTypeText, Text: m.ResponseText},
+		},
+	}, nil
+}
+
+func (m *MockLLMService) TokenContextWindow() int {
+	return 8192 // Mock token limit
+}
+
+// MockLLMProvider provides a mock LLM provider for testing
+type MockLLMProvider struct {
+	Service *MockLLMService
+}
+
+func (m *MockLLMProvider) GetService(modelID string) (llm.Service, error) {
+	return m.Service, nil
+}
+
+// TestGenerateSlug_DatabaseIntegration tests slug generation with actual database conflicts
+func TestGenerateSlug_DatabaseIntegration(t *testing.T) {
+	// Create temporary database
+	tempDB := t.TempDir() + "/slug_test.db"
+	database, err := db.New(db.Config{DSN: tempDB})
+	if err != nil {
+		t.Fatalf("Failed to create test database: %v", err)
+	}
+	defer database.Close()
+
+	// Run migrations
+	ctx := context.Background()
+	if err := database.Migrate(ctx); err != nil {
+		t.Fatalf("Failed to migrate database: %v", err)
+	}
+
+	// Create mock LLM provider that always returns the same slug
+	mockLLM := &MockLLMProvider{
+		Service: &MockLLMService{
+			ResponseText: "test-slug", // Always return the same slug to force conflicts
+		},
+	}
+
+	// Create logger (silent for tests)
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+		Level: slog.LevelWarn, // Only show warnings and errors
+	}))
+
+	// Create first conversation to establish the base slug
+	conv1, err := database.CreateConversation(ctx, nil, true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create first conversation: %v", err)
+	}
+
+	// Generate first slug - should succeed with "test-slug"
+	slug1, err := GenerateSlug(ctx, mockLLM, database, logger, conv1.ConversationID, "Test message", "")
+	if err != nil {
+		t.Fatalf("Failed to generate first slug: %v", err)
+	}
+	if slug1 != "test-slug" {
+		t.Errorf("Expected first slug to be 'test-slug', got %q", slug1)
+	}
+
+	// Create second conversation
+	conv2, err := database.CreateConversation(ctx, nil, true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create second conversation: %v", err)
+	}
+
+	// Generate second slug - should get "test-slug-1" due to conflict
+	slug2, err := GenerateSlug(ctx, mockLLM, database, logger, conv2.ConversationID, "Test message", "")
+	if err != nil {
+		t.Fatalf("Failed to generate second slug: %v", err)
+	}
+	if slug2 != "test-slug-1" {
+		t.Errorf("Expected second slug to be 'test-slug-1', got %q", slug2)
+	}
+
+	// Create third conversation
+	conv3, err := database.CreateConversation(ctx, nil, true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create third conversation: %v", err)
+	}
+
+	// Generate third slug - should get "test-slug-2" due to conflict
+	slug3, err := GenerateSlug(ctx, mockLLM, database, logger, conv3.ConversationID, "Test message", "")
+	if err != nil {
+		t.Fatalf("Failed to generate third slug: %v", err)
+	}
+	if slug3 != "test-slug-2" {
+		t.Errorf("Expected third slug to be 'test-slug-2', got %q", slug3)
+	}
+
+	// Verify all slugs are different
+	if slug1 == slug2 || slug1 == slug3 || slug2 == slug3 {
+		t.Errorf("All slugs should be unique: slug1=%q, slug2=%q, slug3=%q", slug1, slug2, slug3)
+	}
+
+	t.Logf("Successfully generated unique slugs: %q, %q, %q", slug1, slug2, slug3)
+}

sqlc.yaml πŸ”—

@@ -0,0 +1,13 @@
+version: "2"
+sql:
+  - engine: "sqlite"
+    queries: "db/query/"
+    schema: "db/schema/"
+    gen:
+      go:
+        package: "generated"
+        out: "db/generated/"
+        emit_json_tags: true
+        emit_empty_slices: true
+        emit_pointers_for_null_types: true
+        json_tags_case_style: "snake"

subpub/subpub.go πŸ”—

@@ -0,0 +1,108 @@
+package subpub
+
+import (
+	"context"
+	"sync"
+)
+
+type SubPub[K any] struct {
+	mu          sync.Mutex
+	subscribers []*subscriber[K]
+}
+
+type subscriber[K any] struct {
+	idx    int64
+	ch     chan K
+	ctx    context.Context
+	cancel context.CancelFunc
+}
+
+func New[K any]() *SubPub[K] {
+	return &SubPub[K]{
+		subscribers: make([]*subscriber[K], 0),
+	}
+}
+
+// Subscribe registers an interest in messages after the given index, subject to the
+// expiration/cancellation of the provided context. The returned function blocks
+// until a new message, and can return false as the second arguent if the subscription
+// is done for.
+func (sp *SubPub[K]) Subscribe(ctx context.Context, idx int64) func() (K, bool) {
+	// Create a child context so we can cancel the subscription independently
+	subCtx, cancel := context.WithCancel(ctx)
+
+	// Buffered channel to avoid blocking publishers
+	ch := make(chan K, 10)
+	sub := &subscriber[K]{
+		idx:    idx,
+		ch:     ch,
+		ctx:    subCtx,
+		cancel: cancel,
+	}
+
+	sp.mu.Lock()
+	sp.subscribers = append(sp.subscribers, sub)
+	sp.mu.Unlock()
+
+	// Return a function that blocks until the next message
+	return func() (K, bool) {
+		select {
+		case msg, ok := <-ch:
+			if !ok {
+				var zero K
+				return zero, false
+			}
+			return msg, true
+		case <-subCtx.Done():
+			// Context cancelled, but drain any buffered messages first
+			select {
+			case msg, ok := <-ch:
+				if ok {
+					return msg, true
+				}
+			default:
+			}
+			var zero K
+			return zero, false
+		}
+	}
+}
+
+// Publish sends a message to all subscribers waiting for messages after the given index.
+// Subscribers that are "behind" should get a disconnection message.
+func (sp *SubPub[K]) Publish(idx int64, message K) {
+	sp.mu.Lock()
+	defer sp.mu.Unlock()
+
+	// Notify subscribers and filter out disconnected ones
+	remaining := sp.subscribers[:0]
+	for _, sub := range sp.subscribers {
+		// Check if context is still valid
+		select {
+		case <-sub.ctx.Done():
+			// Context cancelled, close channel and don't keep subscriber
+			close(sub.ch)
+			continue
+		default:
+		}
+
+		// Only send to subscribers waiting for messages after an index < idx
+		if sub.idx < idx {
+			// Try to send the message
+			select {
+			case sub.ch <- message:
+				// Success, update subscriber's index and keep them
+				sub.idx = idx
+				remaining = append(remaining, sub)
+			default:
+				// Channel full, subscriber is behind - disconnect them
+				close(sub.ch)
+				sub.cancel()
+			}
+		} else {
+			// This subscriber is not interested yet (already has this index or beyond)
+			remaining = append(remaining, sub)
+		}
+	}
+	sp.subscribers = remaining
+}

subpub/subpub_test.go πŸ”—

@@ -0,0 +1,262 @@
+package subpub
+
+import (
+	"context"
+	"fmt"
+	"testing"
+	"testing/synctest"
+	"time"
+)
+
+func TestSubPubBasic(t *testing.T) {
+	synctest.Test(t, func(t *testing.T) {
+		sp := New[string]()
+		ctx := context.Background()
+
+		// Subscribe waiting for messages after index 0
+		next := sp.Subscribe(ctx, 0)
+
+		// Publish a message at index 1
+		go func() {
+			sp.Publish(1, "hello")
+		}()
+
+		// Should receive the message
+		msg, ok := next()
+		if !ok {
+			t.Fatal("Expected to receive message, got closed channel")
+		}
+		if msg != "hello" {
+			t.Errorf("Expected 'hello', got %q", msg)
+		}
+	})
+}
+
+func TestSubPubMultipleSubscribers(t *testing.T) {
+	synctest.Test(t, func(t *testing.T) {
+		sp := New[string]()
+		ctx := context.Background()
+
+		// Create multiple subscribers
+		next1 := sp.Subscribe(ctx, 0)
+		next2 := sp.Subscribe(ctx, 0)
+		next3 := sp.Subscribe(ctx, 0)
+
+		// Publish a message
+		go func() {
+			sp.Publish(1, "broadcast")
+		}()
+
+		// All subscribers should receive it
+		for i, next := range []func() (string, bool){next1, next2, next3} {
+			msg, ok := next()
+			if !ok {
+				t.Fatalf("Subscriber %d: expected to receive message, got closed channel", i+1)
+			}
+			if msg != "broadcast" {
+				t.Errorf("Subscriber %d: expected 'broadcast', got %q", i+1, msg)
+			}
+		}
+	})
+}
+
+func TestSubPubSubscriberAlreadyHasMessage(t *testing.T) {
+	synctest.Test(t, func(t *testing.T) {
+		sp := New[int]()
+		ctx := context.Background()
+
+		// Subscriber already has index 5, waiting for index > 5
+		next := sp.Subscribe(ctx, 5)
+
+		// Publish at index 3 (subscriber already has this)
+		sp.Publish(3, 100)
+
+		// Publish at index 6 (subscriber should get this)
+		go func() {
+			sp.Publish(6, 200)
+		}()
+
+		msg, ok := next()
+		if !ok {
+			t.Fatal("Expected to receive message, got closed channel")
+		}
+		if msg != 200 {
+			t.Errorf("Expected 200, got %d", msg)
+		}
+	})
+}
+
+func TestSubPubContextCancellation(t *testing.T) {
+	synctest.Test(t, func(t *testing.T) {
+		sp := New[string]()
+		ctx, cancel := context.WithCancel(context.Background())
+
+		next := sp.Subscribe(ctx, 0)
+
+		// Cancel the context
+		cancel()
+
+		// Should return false when context is cancelled
+		_, ok := next()
+		if ok {
+			t.Error("Expected closed channel after context cancellation")
+		}
+	})
+}
+
+func TestSubPubSubscriberBehind(t *testing.T) {
+	// Don't use synctest for this test as it involves checking buffer overflow behavior
+	sp := New[string]()
+	ctx := context.Background()
+
+	// Subscriber waiting for messages after index 0
+	next := sp.Subscribe(ctx, 0)
+
+	// Fill up the channel buffer (10 messages) quickly before subscriber reads
+	for i := 1; i <= 10; i++ {
+		sp.Publish(int64(i), fmt.Sprintf("message%d", i))
+	}
+
+	// Try to send one more - subscriber should be disconnected because buffer is full
+	sp.Publish(11, "overflow")
+
+	// Try to receive - should work for buffered messages
+	received := 0
+	var messages []string
+	for {
+		msg, ok := next()
+		if !ok {
+			break
+		}
+		messages = append(messages, msg)
+		received++
+		if received > 11 {
+			t.Fatal("Received more messages than expected")
+		}
+	}
+
+	// Should have received exactly 10 messages before being disconnected
+	if received != 10 {
+		t.Errorf("Expected to receive 10 buffered messages, got %d: %v", received, messages)
+	}
+}
+
+func TestSubPubSequentialMessages(t *testing.T) {
+	// Don't use synctest for this test as mutex blocking doesn't work well with it
+	sp := New[int]()
+	ctx := context.Background()
+
+	next := sp.Subscribe(ctx, 0)
+
+	// Publish multiple messages in order
+	for i := 1; i <= 5; i++ {
+		sp.Publish(int64(i), i*10)
+	}
+
+	// Receive all messages
+	received := []int{}
+	for i := 1; i <= 5; i++ {
+		msg, ok := next()
+		if !ok {
+			t.Fatalf("Expected to receive 5 messages, got closed channel after %d messages", i-1)
+		}
+		received = append(received, msg)
+	}
+
+	// Check we got all expected values in order
+	expected := []int{10, 20, 30, 40, 50}
+	for i, val := range received {
+		if val != expected[i] {
+			t.Errorf("Message %d: expected %d, got %d", i, expected[i], val)
+		}
+	}
+}
+
+func TestSubPubLateSubscriber(t *testing.T) {
+	synctest.Test(t, func(t *testing.T) {
+		sp := New[string]()
+		ctx := context.Background()
+
+		// Publish some messages before anyone subscribes
+		sp.Publish(1, "early1")
+		sp.Publish(2, "early2")
+
+		// Late subscriber joins, interested in messages after index 2
+		next := sp.Subscribe(ctx, 2)
+
+		// Publish a new message
+		go func() {
+			sp.Publish(3, "late")
+		}()
+
+		// Should only receive the new message
+		msg, ok := next()
+		if !ok {
+			t.Fatal("Expected to receive message, got closed channel")
+		}
+		if msg != "late" {
+			t.Errorf("Expected 'late', got %q", msg)
+		}
+	})
+}
+
+func TestSubPubWithTimeout(t *testing.T) {
+	sp := New[string]()
+	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+	defer cancel()
+
+	next := sp.Subscribe(ctx, 0)
+
+	// Don't publish anything, just wait for timeout
+	_, ok := next()
+	if ok {
+		t.Error("Expected timeout to close the subscription")
+	}
+}
+
+func TestSubPubMultiplePublishes(t *testing.T) {
+	synctest.Test(t, func(t *testing.T) {
+		sp := New[string]()
+		ctx := context.Background()
+
+		// Start two subscribers at different positions
+		next1 := sp.Subscribe(ctx, 0)
+		next2 := sp.Subscribe(ctx, 1)
+
+		// Publish at index 2 - only next1 should receive (next2 already has idx 1)
+		go func() {
+			sp.Publish(2, "msg2")
+		}()
+
+		msg, ok := next1()
+		if !ok {
+			t.Fatal("Subscriber 1: expected to receive message, got closed channel")
+		}
+		if msg != "msg2" {
+			t.Errorf("Subscriber 1: expected 'msg2', got %q", msg)
+		}
+
+		msg, ok = next2()
+		if !ok {
+			t.Fatal("Subscriber 2: expected to receive message, got closed channel")
+		}
+		if msg != "msg2" {
+			t.Errorf("Subscriber 2: expected 'msg2', got %q", msg)
+		}
+
+		// Now both are at index 2, publish at index 3
+		go func() {
+			sp.Publish(3, "msg3")
+		}()
+
+		for i, next := range []func() (string, bool){next1, next2} {
+			msg, ok := next()
+			if !ok {
+				t.Fatalf("Subscriber %d: expected to receive msg3, got closed channel", i+1)
+			}
+			if msg != "msg3" {
+				t.Errorf("Subscriber %d: expected 'msg3', got %q", i+1, msg)
+			}
+		}
+	})
+}

templates/go/AGENT.md πŸ”—

@@ -0,0 +1,5 @@
+# Agent Instructions
+
+This is a Go web application template for exe.dev.
+
+See README.md for details on the structure and components.

templates/go/Makefile πŸ”—

@@ -0,0 +1,10 @@
+.PHONY: build clean stop start restart test
+
+build:
+	go build -o srv ./cmd/srv
+
+clean:
+	rm -f srv
+
+test:
+	go test ./...

templates/go/README.md πŸ”—

@@ -0,0 +1,57 @@
+# Go Shelley Template
+
+This is a starter template for building Go web applications on exe.dev. It demonstrates end-to-end usage including HTTP handlers, authentication, database integration, and deployment.
+
+Use this as a foundation to build your own service.
+
+## Building and Running
+
+Build with `make build`, then run `./srv`. The server listens on port 8000 by default.
+
+## Running as a systemd service
+
+To run the server as a systemd service:
+
+```bash
+# Install the service file
+sudo cp srv.service /etc/systemd/system/srv.service
+
+# Reload systemd and enable the service
+sudo systemctl daemon-reload
+sudo systemctl enable srv.service
+
+# Start the service
+sudo systemctl start srv
+
+# Check status
+systemctl status srv
+
+# View logs
+journalctl -u srv -f
+```
+
+To restart after code changes:
+
+```bash
+make build
+sudo systemctl restart srv
+```
+
+## Authorization
+
+exe.dev provides authorization headers and login/logout links
+that this template uses.
+
+When proxied through exed, requests will include `X-ExeDev-UserID` and
+`X-ExeDev-Email` if the user is authenticated via exe.dev.
+
+## Database
+
+This template uses sqlite (`db.sqlite3`). SQL queries are managed with sqlc.
+
+## Code layout
+
+- `cmd/srv`: main package (binary entrypoint)
+- `srv`: HTTP server logic (handlers)
+- `srv/templates`: Go HTML templates
+- `db`: SQLite open + migrations (001-base.sql)

templates/go/cmd/srv/main.go πŸ”—

@@ -0,0 +1,30 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+	"os"
+
+	"srv.exe.dev/srv"
+)
+
+var flagListenAddr = flag.String("listen", ":8000", "address to listen on")
+
+func main() {
+	if err := run(); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+	}
+}
+
+func run() error {
+	flag.Parse()
+	hostname, err := os.Hostname()
+	if err != nil {
+		hostname = "unknown"
+	}
+	server, err := srv.New("db.sqlite3", hostname)
+	if err != nil {
+		return fmt.Errorf("create server: %w", err)
+	}
+	return server.Serve(*flagListenAddr)
+}

templates/go/db/db.go πŸ”—

@@ -0,0 +1,115 @@
+package db
+
+import (
+	"database/sql"
+	"embed"
+	"errors"
+	"fmt"
+	"log/slog"
+	"regexp"
+	"sort"
+	"strconv"
+
+	_ "modernc.org/sqlite"
+)
+
+//go:generate go tool github.com/sqlc-dev/sqlc/cmd/sqlc generate
+
+//go:embed migrations/*.sql
+var migrationFS embed.FS
+
+// Open opens an sqlite database and prepares pragmas suitable for a small web app.
+func Open(path string) (*sql.DB, error) {
+	db, err := sql.Open("sqlite", path)
+	if err != nil {
+		return nil, err
+	}
+	// Light pragmas similar
+	if _, err := db.Exec("PRAGMA foreign_keys=ON;"); err != nil {
+		_ = db.Close()
+		return nil, fmt.Errorf("enable foreign keys: %w", err)
+	}
+	if _, err := db.Exec("PRAGMA journal_mode=wal;"); err != nil {
+		_ = db.Close()
+		return nil, fmt.Errorf("set WAL: %w", err)
+	}
+	if _, err := db.Exec("PRAGMA busy_timeout=1000;"); err != nil {
+		_ = db.Close()
+		return nil, fmt.Errorf("set busy_timeout: %w", err)
+	}
+	return db, nil
+}
+
+// RunMigrations executes database migrations in numeric order (NNN-*.sql),
+// similar in spirit to exed's exedb.RunMigrations.
+func RunMigrations(db *sql.DB) error {
+	entries, err := migrationFS.ReadDir("migrations")
+	if err != nil {
+		return fmt.Errorf("read migrations dir: %w", err)
+	}
+	var migrations []string
+	pat := regexp.MustCompile(`^(\d{3})-.*\.sql$`)
+	for _, e := range entries {
+		if e.IsDir() {
+			continue
+		}
+		name := e.Name()
+		if pat.MatchString(name) {
+			migrations = append(migrations, name)
+		}
+	}
+	sort.Strings(migrations)
+
+	executed := make(map[int]bool)
+	var tableName string
+	err = db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'").Scan(&tableName)
+	switch {
+	case err == nil:
+		rows, err := db.Query("SELECT migration_number FROM migrations")
+		if err != nil {
+			return fmt.Errorf("query executed migrations: %w", err)
+		}
+		defer rows.Close()
+		for rows.Next() {
+			var n int
+			if err := rows.Scan(&n); err != nil {
+				return fmt.Errorf("scan migration number: %w", err)
+			}
+			executed[n] = true
+		}
+	case errors.Is(err, sql.ErrNoRows):
+		slog.Info("db: migrations table not found; running all migrations")
+	default:
+		return fmt.Errorf("check migrations table: %w", err)
+	}
+
+	for _, m := range migrations {
+		match := pat.FindStringSubmatch(m)
+		if len(match) != 2 {
+			return fmt.Errorf("invalid migration filename: %s", m)
+		}
+		n, err := strconv.Atoi(match[1])
+		if err != nil {
+			return fmt.Errorf("parse migration number %s: %w", m, err)
+		}
+		if executed[n] {
+			continue
+		}
+		if err := executeMigration(db, m); err != nil {
+			return fmt.Errorf("execute %s: %w", m, err)
+		}
+		slog.Info("db: applied migration", "file", m, "number", n)
+	}
+	return nil
+}
+
+func executeMigration(db *sql.DB, filename string) error {
+	content, err := migrationFS.ReadFile("migrations/" + filename)
+	if err != nil {
+		return fmt.Errorf("read %s: %w", filename, err)
+	}
+	if _, err := db.Exec(string(content)); err != nil {
+		return fmt.Errorf("exec %s: %w", filename, err)
+	}
+	return nil
+}

templates/go/db/dbgen/db.go πŸ”—

@@ -0,0 +1,31 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+//   sqlc v1.30.0
+
+package dbgen
+
+import (
+	"context"
+	"database/sql"
+)
+
+type DBTX interface {
+	ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
+	PrepareContext(context.Context, string) (*sql.Stmt, error)
+	QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
+	QueryRowContext(context.Context, string, ...interface{}) *sql.Row
+}
+
+func New(db DBTX) *Queries {
+	return &Queries{db: db}
+}
+
+type Queries struct {
+	db DBTX
+}
+
+func (q *Queries) WithTx(tx *sql.Tx) *Queries {
+	return &Queries{
+		db: tx,
+	}
+}

templates/go/db/dbgen/models.go πŸ”—

@@ -0,0 +1,22 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+//   sqlc v1.30.0
+
+package dbgen
+
+import (
+	"time"
+)
+
+type Migration struct {
+	MigrationNumber int64     `json:"migration_number"`
+	MigrationName   string    `json:"migration_name"`
+	ExecutedAt      time.Time `json:"executed_at"`
+}
+
+type Visitor struct {
+	ID        string    `json:"id"`
+	ViewCount int64     `json:"view_count"`
+	CreatedAt time.Time `json:"created_at"`
+	LastSeen  time.Time `json:"last_seen"`
+}

templates/go/db/dbgen/visitors.sql.go πŸ”—

@@ -0,0 +1,54 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+//   sqlc v1.30.0
+// source: visitors.sql
+
+package dbgen
+
+import (
+	"context"
+	"time"
+)
+
+const upsertVisitor = `-- name: UpsertVisitor :exec
+INSERT INTO
+  visitors (id, view_count, created_at, last_seen)
+VALUES
+  (?, 1, ?, ?) ON CONFLICT (id) DO
+UPDATE
+SET
+  view_count = view_count + 1,
+  last_seen = excluded.last_seen
+`
+
+type UpsertVisitorParams struct {
+	ID        string    `json:"id"`
+	CreatedAt time.Time `json:"created_at"`
+	LastSeen  time.Time `json:"last_seen"`
+}
+
+func (q *Queries) UpsertVisitor(ctx context.Context, arg UpsertVisitorParams) error {
+	_, err := q.db.ExecContext(ctx, upsertVisitor, arg.ID, arg.CreatedAt, arg.LastSeen)
+	return err
+}
+
+const visitorWithID = `-- name: VisitorWithID :one
+SELECT
+  id, view_count, created_at, last_seen
+FROM
+  visitors
+WHERE
+  id = ?
+`
+
+func (q *Queries) VisitorWithID(ctx context.Context, id string) (Visitor, error) {
+	row := q.db.QueryRowContext(ctx, visitorWithID, id)
+	var i Visitor
+	err := row.Scan(
+		&i.ID,
+		&i.ViewCount,
+		&i.CreatedAt,
+		&i.LastSeen,
+	)
+	return i, err
+}

templates/go/db/migrations/001-base.sql πŸ”—

@@ -0,0 +1,22 @@
+-- Base schema
+--
+-- Migrations tracking table
+CREATE TABLE IF NOT EXISTS migrations (
+    migration_number INTEGER PRIMARY KEY,
+    migration_name TEXT NOT NULL,
+    executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Visitors table
+CREATE TABLE IF NOT EXISTS visitors (
+    id TEXT PRIMARY KEY,
+    view_count INTEGER NOT NULL,
+    created_at TIMESTAMP NOT NULL,
+    last_seen TIMESTAMP NOT NULL
+);
+
+-- Record execution of this migration
+INSERT
+OR IGNORE INTO migrations (migration_number, migration_name)
+VALUES
+    (001, '001-base');

templates/go/db/queries/visitors.sql πŸ”—

@@ -0,0 +1,17 @@
+-- name: UpsertVisitor :exec
+INSERT INTO
+  visitors (id, view_count, created_at, last_seen)
+VALUES
+  (?, 1, ?, ?) ON CONFLICT (id) DO
+UPDATE
+SET
+  view_count = view_count + 1,
+  last_seen = excluded.last_seen;
+
+-- name: VisitorWithID :one
+SELECT
+  *
+FROM
+  visitors
+WHERE
+  id = ?;

templates/go/db/sqlc.yaml πŸ”—

@@ -0,0 +1,14 @@
+version: "2"
+sql:
+  - engine: "sqlite"
+    queries: "queries/"
+    schema: "migrations/"
+    gen:
+      go:
+        package: "dbgen"
+        out: "dbgen/"
+        emit_json_tags: true
+        emit_empty_slices: true
+        emit_pointers_for_null_types: true
+        json_tags_case_style: "snake"
+        sql_package: "database/sql"

templates/go/go.mod πŸ”—

@@ -0,0 +1,60 @@
+module srv.exe.dev
+
+go 1.25.5
+
+require modernc.org/sqlite v1.39.0
+
+require (
+	cel.dev/expr v0.24.0 // indirect
+	filippo.io/edwards25519 v1.1.0 // indirect
+	github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
+	github.com/cubicdaiya/gonp v1.0.4 // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/fatih/structtag v1.2.0 // indirect
+	github.com/go-sql-driver/mysql v1.9.3 // indirect
+	github.com/google/cel-go v0.26.1 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/jackc/pgpassfile v1.0.0 // indirect
+	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+	github.com/jackc/pgx/v5 v5.7.5 // indirect
+	github.com/jackc/puddle/v2 v2.2.2 // indirect
+	github.com/jinzhu/inflection v1.0.0 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/ncruces/go-strftime v0.1.9 // indirect
+	github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect
+	github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect
+	github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect
+	github.com/pingcap/log v1.1.0 // indirect
+	github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	github.com/riza-io/grpc-go v0.2.0 // indirect
+	github.com/spf13/cobra v1.9.1 // indirect
+	github.com/spf13/pflag v1.0.7 // indirect
+	github.com/sqlc-dev/sqlc v1.30.0 // indirect
+	github.com/stoewer/go-strcase v1.2.0 // indirect
+	github.com/tetratelabs/wazero v1.9.0 // indirect
+	github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect
+	github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect
+	go.uber.org/atomic v1.11.0 // indirect
+	go.uber.org/multierr v1.11.0 // indirect
+	go.uber.org/zap v1.27.0 // indirect
+	golang.org/x/crypto v0.39.0 // indirect
+	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
+	golang.org/x/net v0.41.0 // indirect
+	golang.org/x/sync v0.16.0 // indirect
+	golang.org/x/sys v0.34.0 // indirect
+	golang.org/x/text v0.26.0 // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
+	google.golang.org/grpc v1.75.0 // indirect
+	google.golang.org/protobuf v1.36.8 // indirect
+	gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+	modernc.org/libc v1.66.3 // indirect
+	modernc.org/mathutil v1.7.1 // indirect
+	modernc.org/memory v1.11.0 // indirect
+)
+
+tool github.com/sqlc-dev/sqlc/cmd/sqlc

templates/go/go.sum πŸ”—

@@ -0,0 +1,209 @@
+cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
+cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
+github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
+github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws=
+github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
+github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
+github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
+github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
+github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls=
+github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50=
+github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
+github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb h1:3pSi4EDG6hg0orE1ndHkXvX6Qdq2cZn8gAPir8ymKZk=
+github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg=
+github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE=
+github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4=
+github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8=
+github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4=
+github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0=
+github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ=
+github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
+github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/sqlc-dev/sqlc v1.30.0 h1:H4HrNwPc0hntxGWzAbhlfplPRN4bQpXFx+CaEMcKz6c=
+github.com/sqlc-dev/sqlc v1.30.0/go.mod h1:QnEN+npugyhUg1A+1kkYM3jc2OMOFsNlZ1eh8mdhad0=
+github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
+github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
+github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
+github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo=
+github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM=
+github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4=
+github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
+go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
+go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
+go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
+go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
+go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
+go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
+go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
+go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
+go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
+go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
+go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
+golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
+golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
+golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
+golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
+golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
+golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
+golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU=
+google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
+google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
+google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
+modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
+modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
+modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
+modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
+modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
+modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

templates/go/srv.service πŸ”—

@@ -0,0 +1,18 @@
+[Unit]
+Description=Go web server
+
+[Service]
+Type=simple
+User=exedev
+Group=exedev
+WorkingDirectory=/home/exedev
+ExecStart=/home/exedev/srv
+Restart=always
+RestartSec=5
+Environment=HOME=/home/exedev
+Environment=USER=exedev
+StandardOutput=journal
+StandardError=journal
+
+[Install]
+WantedBy=multi-user.target

templates/go/srv/server.go πŸ”—

@@ -0,0 +1,187 @@
+package srv
+
+import (
+	"database/sql"
+	"fmt"
+	"html/template"
+	"log/slog"
+	"net"
+	"net/http"
+	"net/url"
+	"path/filepath"
+	"runtime"
+	"sort"
+	"strings"
+	"time"
+
+	"srv.exe.dev/db"
+	"srv.exe.dev/db/dbgen"
+)
+
+type Server struct {
+	DB           *sql.DB
+	Hostname     string
+	TemplatesDir string
+	StaticDir    string
+}
+
+type pageData struct {
+	Hostname   string
+	Now        string
+	UserEmail  string
+	VisitCount int64
+	LoginURL   string
+	LogoutURL  string
+	Headers    []headerEntry
+}
+
+type headerEntry struct {
+	Name       string
+	Values     []string
+	AddedByExe bool
+}
+
+func New(dbPath, hostname string) (*Server, error) {
+	_, thisFile, _, _ := runtime.Caller(0)
+	baseDir := filepath.Dir(thisFile)
+	srv := &Server{
+		Hostname:     hostname,
+		TemplatesDir: filepath.Join(baseDir, "templates"),
+		StaticDir:    filepath.Join(baseDir, "static"),
+	}
+	if err := srv.setUpDatabase(dbPath); err != nil {
+		return nil, err
+	}
+	return srv, nil
+}
+
+func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) {
+	// Identity from proxy headers (if present)
+	// UserID is stable; email is useful.
+	userID := strings.TrimSpace(r.Header.Get("X-ExeDev-UserID"))
+	userEmail := strings.TrimSpace(r.Header.Get("X-ExeDev-Email"))
+	now := time.Now()
+
+	var count int64
+	if userID != "" && s.DB != nil {
+		q := dbgen.New(s.DB)
+		shouldRecordView := r.Method == http.MethodGet
+		if shouldRecordView {
+			// Best effort
+			err := q.UpsertVisitor(r.Context(), dbgen.UpsertVisitorParams{
+				ID:        userID,
+				CreatedAt: now,
+				LastSeen:  now,
+			})
+			if err != nil {
+				slog.Warn("upsert visitor", "error", err, "user_id", userID)
+			}
+		}
+		if v, err := q.VisitorWithID(r.Context(), userID); err == nil {
+			count = v.ViewCount
+		}
+	}
+
+	data := pageData{
+		Hostname:   s.Hostname,
+		Now:        now.Format(time.RFC3339),
+		UserEmail:  userEmail,
+		VisitCount: count,
+		LoginURL:   loginURLForRequest(r),
+		LogoutURL:  "/__exe.dev/logout",
+		Headers:    buildHeaderEntries(r),
+	}
+
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	if err := s.renderTemplate(w, "welcome.html", data); err != nil {
+		slog.Warn("render template", "url", r.URL.Path, "error", err)
+	}
+}
+
+func loginURLForRequest(r *http.Request) string {
+	path := r.URL.RequestURI()
+	v := url.Values{}
+	v.Set("redirect", path)
+	return "/__exe.dev/login?" + v.Encode()
+}
+
+func (s *Server) renderTemplate(w http.ResponseWriter, name string, data any) error {
+	path := filepath.Join(s.TemplatesDir, name)
+	tmpl, err := template.ParseFiles(path)
+	if err != nil {
+		return fmt.Errorf("parse template %q: %w", name, err)
+	}
+	if err := tmpl.Execute(w, data); err != nil {
+		return fmt.Errorf("execute template %q: %w", name, err)
+	}
+	return nil
+}
+
+func mainDomainFromHost(h string) string {
+	host, port, err := net.SplitHostPort(h)
+	if err != nil {
+		host = strings.TrimSpace(h)
+	}
+	if port != "" {
+		port = ":" + port
+	}
+	// Check for exe.cloud-based domains (dev mode)
+	if strings.HasSuffix(host, ".exe.cloud") || host == "exe.cloud" {
+		return "exe.cloud" + port
+	}
+	// Check for exe.dev-based domains (production)
+	if strings.HasSuffix(host, ".exe.dev") || host == "exe.dev" {
+		return "exe.dev"
+	}
+	// Return as-is for custom domains
+	return host
+}
+
+// SetupDatabase initializes the database connection and runs migrations
+func (s *Server) setUpDatabase(dbPath string) error {
+	wdb, err := db.Open(dbPath)
+	if err != nil {
+		return fmt.Errorf("failed to open db: %w", err)
+	}
+	s.DB = wdb
+	if err := db.RunMigrations(wdb); err != nil {
+		return fmt.Errorf("failed to run migrations: %w", err)
+	}
+	return nil
+}
+
+// Serve starts the HTTP server with the configured routes
+func (s *Server) Serve(addr string) error {
+	mux := http.NewServeMux()
+	mux.HandleFunc("GET /{$}", s.HandleRoot)
+	mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.StaticDir))))
+	slog.Info("starting server", "addr", addr)
+	return http.ListenAndServe(addr, mux)
+}
+
+func buildHeaderEntries(r *http.Request) []headerEntry {
+	if r == nil {
+		return nil
+	}
+
+	headers := make([]headerEntry, 0, len(r.Header)+1)
+	for name, values := range r.Header {
+		lower := strings.ToLower(name)
+		headers = append(headers, headerEntry{
+			Name:       name,
+			Values:     values,
+			AddedByExe: strings.HasPrefix(lower, "x-exedev-") || strings.HasPrefix(lower, "x-forwarded-"),
+		})
+	}
+	if r.Host != "" {
+		headers = append(headers, headerEntry{
+			Name:   "Host",
+			Values: []string{r.Host},
+		})
+	}
+
+	sort.Slice(headers, func(i, j int) bool {
+		return strings.ToLower(headers[i].Name) < strings.ToLower(headers[j].Name)
+	})
+	return headers
+}

templates/go/srv/server_test.go πŸ”—

@@ -0,0 +1,117 @@
+package srv
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestServerSetupAndHandlers(t *testing.T) {
+	tempDB := filepath.Join(t.TempDir(), "test_server.sqlite3")
+	t.Cleanup(func() { os.Remove(tempDB) })
+
+	server, err := New(tempDB, "test-hostname")
+	if err != nil {
+		t.Fatalf("failed to create server: %v", err)
+	}
+
+	// Test root endpoint without auth
+	t.Run("root endpoint unauthenticated", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "/", nil)
+		w := httptest.NewRecorder()
+
+		server.HandleRoot(w, req)
+
+		if w.Code != http.StatusOK {
+			t.Errorf("expected status 200, got %d", w.Code)
+		}
+
+		body := w.Body.String()
+		if !strings.Contains(body, "test-hostname") {
+			t.Errorf("expected page to show hostname, got body: %s", body)
+		}
+		if !strings.Contains(body, "Go Template Project") {
+			t.Errorf("expected page to contain headline, got body: %s", body)
+		}
+		if strings.Contains(body, "Signed in as") {
+			t.Errorf("expected page to not be logged in, got body: %s", body)
+		}
+		if !strings.Contains(body, "Not signed in") {
+			t.Errorf("expected page to show 'Not signed in', got body: %s", body)
+		}
+	})
+
+	// Test root endpoint with auth headers
+	t.Run("root endpoint authenticated", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "/", nil)
+		req.Header.Set("X-ExeDev-UserID", "user123")
+		req.Header.Set("X-ExeDev-Email", "test@example.com")
+		w := httptest.NewRecorder()
+
+		server.HandleRoot(w, req)
+
+		if w.Code != http.StatusOK {
+			t.Errorf("expected status 200, got %d", w.Code)
+		}
+
+		body := w.Body.String()
+		if !strings.Contains(body, "Signed in as") {
+			t.Errorf("expected page to show logged in state, got body: %s", body)
+		}
+		if !strings.Contains(body, "test@example.com") {
+			t.Error("expected page to show user email")
+		}
+	})
+
+	// Test view counter functionality
+	t.Run("view counter increments", func(t *testing.T) {
+		// Make first request
+		req1 := httptest.NewRequest(http.MethodGet, "/", nil)
+		req1.Header.Set("X-ExeDev-UserID", "counter-test")
+		req1.RemoteAddr = "192.168.1.100:12345"
+		w1 := httptest.NewRecorder()
+		server.HandleRoot(w1, req1)
+
+		// Should show "1 times" or similar
+		body1 := w1.Body.String()
+		if !strings.Contains(body1, "1</strong> times") {
+			t.Error("expected first visit to show 1 time")
+		}
+
+		// Make second request with same user
+		req2 := httptest.NewRequest(http.MethodGet, "/", nil)
+		req2.Header.Set("X-ExeDev-UserID", "counter-test")
+		req2.RemoteAddr = "192.168.1.100:12345"
+		w2 := httptest.NewRecorder()
+		server.HandleRoot(w2, req2)
+
+		// Should show "2 times" or similar
+		body2 := w2.Body.String()
+		if !strings.Contains(body2, "2</strong> times") {
+			t.Error("expected second visit to show 2 times")
+		}
+	})
+}
+
+func TestUtilityFunctions(t *testing.T) {
+	t.Run("mainDomainFromHost function", func(t *testing.T) {
+		tests := []struct {
+			input    string
+			expected string
+		}{
+			{"example.exe.cloud:8080", "exe.cloud:8080"},
+			{"example.exe.dev", "exe.dev"},
+			{"example.exe.cloud", "exe.cloud"},
+		}
+
+		for _, test := range tests {
+			result := mainDomainFromHost(test.input)
+			if result != test.expected {
+				t.Errorf("mainDomainFromHost(%q) = %q, expected %q", test.input, result, test.expected)
+			}
+		}
+	})
+}

templates/go/srv/static/script.js πŸ”—

@@ -0,0 +1,16 @@
+// Handle SSH copy link
+document.querySelectorAll('.ssh-copy').forEach(function(link) {
+  link.addEventListener('click', function(e) {
+    e.preventDefault();
+    var text = this.getAttribute('data-copy');
+    navigator.clipboard.writeText(text).then(function() {
+      var feedback = document.getElementById('copiedFeedback');
+      feedback.classList.add('show');
+      setTimeout(function() {
+        feedback.classList.remove('show');
+      }, 2000);
+    }).catch(function(err) {
+      console.error('Failed to copy:', err);
+    });
+  });
+});

templates/go/srv/static/style.css πŸ”—

@@ -0,0 +1,501 @@
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+html {
+  overflow-x: hidden;
+}
+
+body {
+  background-color: #fffff8;
+  background-image: repeating-linear-gradient(
+    0deg,
+    rgba(0, 0, 0, 0.035) 0px,
+    rgba(0, 0, 0, 0.035) 2px,
+    transparent 2px,
+    transparent 4px
+  );
+  background-size: 4px 4px;
+  font-family: Georgia, 'Times New Roman', serif;
+  font-size: 18px;
+  overflow-x: hidden;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 100vh;
+  padding: 40px 20px;
+  text-align: center;
+}
+
+main {
+  max-width: 900px;
+  width: 100%;
+  overflow-x: hidden;
+  margin: 0 auto;
+}
+
+/* Header: tagline first */
+h1 {
+  font-size: clamp(30px, 6vw, 50px);
+  line-height: 1.2;
+  color: #000;
+  margin-bottom: 30px;
+  font-weight: normal;
+  letter-spacing: -0.01em;
+}
+
+/* Introduction paragraph */
+.intro {
+  max-width: 780px;
+  margin: 0 auto 30px;
+  line-height: 1.7;
+  color: #333;
+  text-align: left;
+  font-size: 16px;
+}
+
+.intro p {
+  margin-bottom: 16px;
+}
+
+.intro p:last-child {
+  margin-bottom: 0;
+}
+
+.intro code {
+  font-family: 'Courier New', Courier, monospace;
+  background: rgba(0, 0, 0, 0.05);
+  padding: 2px 6px;
+  border-radius: 3px;
+  font-size: 15px;
+}
+
+.next-steps {
+  max-width: 960px;
+  margin: 20px auto 40px;
+  text-align: left;
+}
+
+.next-steps h2 {
+  font-size: 20px;
+  margin-bottom: 16px;
+  font-weight: normal;
+}
+
+.step-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+  gap: 16px;
+}
+
+.step-card {
+  background: rgba(255, 255, 255, 0.6);
+  border: 1px solid rgba(0, 0, 0, 0.08);
+  border-radius: 10px;
+  padding: 18px;
+  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04);
+}
+
+.step-card h3 {
+  font-size: 16px;
+  margin-bottom: 10px;
+}
+
+.step-card p {
+  margin-bottom: 10px;
+  color: #555;
+  line-height: 1.4;
+}
+
+.step-card ul {
+  list-style: disc;
+  padding-left: 18px;
+  margin: 0;
+  color: #444;
+  font-size: 14px;
+}
+
+.step-card li {
+  margin-bottom: 6px;
+}
+
+.step-card li:last-child {
+  margin-bottom: 0;
+}
+
+.step-card code {
+  font-family: 'Courier New', Courier, monospace;
+  background: rgba(0, 0, 0, 0.05);
+  padding: 2px 6px;
+  border-radius: 4px;
+  font-size: 13px;
+}
+
+.step-card a {
+  color: #0066cc;
+  text-decoration: none;
+}
+
+.step-card a:hover {
+  text-decoration: underline;
+}
+
+.hostname {
+  font-size: clamp(28px, 5vw, 36px);
+  line-height: 1.2;
+  color: #000;
+  margin-bottom: 40px;
+  font-weight: normal;
+  letter-spacing: -0.01em;
+  font-family: 'Courier New', Courier, monospace;
+}
+
+/* Decorative rule */
+hr {
+  width: 200px;
+  height: 40px;
+  border: 0;
+  margin: 40px auto;
+  position: relative;
+  opacity: 0.4;
+}
+
+hr::before {
+  content: '';
+  position: absolute;
+  top: 50%;
+  left: 0;
+  right: 0;
+  height: 1px;
+  background: linear-gradient(90deg,
+    transparent 0%,
+    #333 20%,
+    #333 80%,
+    transparent 100%);
+}
+
+hr::after {
+  content: '❦';
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  font-size: 24px;
+  background: #fffff8;
+  padding: 0 10px;
+  color: #333;
+}
+
+/* Action links section */
+.actions {
+  list-style: none;
+  margin: 40px auto;
+  max-width: 800px;
+  display: flex;
+  gap: 12px;
+  justify-content: center;
+  flex-wrap: wrap;
+}
+
+.actions li {
+  flex: 1 1 0;
+  min-width: 150px;
+  max-width: 200px;
+}
+
+.actions a {
+  color: #0066cc;
+  text-decoration: none;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
+  padding: 20px 16px;
+  background: rgba(255, 255, 255, 0.6);
+  border: 1px solid rgba(0, 102, 204, 0.3);
+  border-radius: 8px;
+  transition: all 0.2s;
+  height: 100%;
+}
+
+.actions a:hover {
+  background: rgba(0, 102, 204, 0.1);
+  border-color: rgba(0, 102, 204, 0.5);
+  transform: translateY(-2px);
+  box-shadow: 0 4px 12px rgba(0, 102, 204, 0.15);
+}
+
+.actions a:active {
+  transform: translateY(0);
+}
+
+.action-icon {
+  font-size: 32px;
+  line-height: 1;
+}
+
+.action-label {
+  font-weight: bold;
+  color: #000;
+  font-size: 14px;
+  text-align: center;
+}
+
+.action-detail {
+  font-size: 12px;
+  color: #666;
+  font-family: 'Courier New', Courier, monospace;
+  text-align: center;
+  word-break: break-all;
+}
+
+/* User info section */
+.user-info {
+  margin: 40px auto;
+  padding: 0;
+  max-width: 600px;
+}
+
+.user-status {
+  font-size: 16px;
+  color: #666;
+  margin-bottom: 15px;
+}
+
+.user-status strong {
+  color: #000;
+}
+
+.auth-buttons {
+  display: flex;
+  gap: 12px;
+  justify-content: center;
+  flex-wrap: wrap;
+}
+
+.auth-buttons a {
+  font-family: 'Courier New', Courier, monospace;
+  font-size: 14px;
+  text-decoration: none;
+  color: #0066cc;
+  padding: 8px 16px;
+  border: 1px solid #0066cc;
+  border-radius: 6px;
+  transition: background 0.2s, color 0.2s;
+}
+
+.auth-buttons a:hover {
+  background: #0066cc;
+  color: #fff;
+}
+
+/* Headers section */
+.headers {
+  margin: 32px auto;
+  padding: 20px;
+  max-width: 900px;
+  background: rgba(255, 255, 255, 0.85);
+  border: 1px solid rgba(0, 0, 0, 0.08);
+  border-radius: 12px;
+  text-align: left;
+  box-shadow: 0 8px 22px rgba(0, 0, 0, 0.05);
+}
+
+.headers-title h2 {
+  font-size: 20px;
+  margin-bottom: 2px;
+  font-weight: normal;
+}
+
+.headers-notes {
+  color: #555;
+  font-size: 15px;
+  line-height: 1.5;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  margin-top: 8px;
+}
+
+.headers-notes p {
+  margin: 0;
+}
+
+.headers-notes ul {
+  margin: 0 0 4px 20px;
+  padding-left: 18px;
+  list-style: disc;
+}
+
+.headers-notes li {
+  margin-bottom: 4px;
+}
+
+.headers-notes code {
+  font-family: 'Courier New', Courier, monospace;
+  background: rgba(0, 0, 0, 0.05);
+  padding: 1px 5px;
+  border-radius: 4px;
+  font-size: 13px;
+}
+
+.headers-table {
+  margin-top: 12px;
+  border: 1px solid rgba(0, 0, 0, 0.1);
+  border-radius: 10px;
+  overflow: hidden;
+}
+
+.headers-row {
+  display: grid;
+  grid-template-columns: minmax(140px, 1fr) 2fr;
+  gap: 12px;
+  padding: 12px 16px;
+  background: rgba(255, 255, 255, 0.92);
+}
+
+.headers-row + .headers-row {
+  border-top: 1px solid rgba(0, 0, 0, 0.05);
+}
+
+.headers-head {
+  background: rgba(0, 0, 0, 0.04);
+  font-weight: bold;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  font-size: 12px;
+}
+
+.header-name code,
+.header-value code {
+  font-family: 'Courier New', Courier, monospace;
+  background: rgba(0, 0, 0, 0.05);
+  padding: 2px 6px;
+  border-radius: 4px;
+  font-size: 14px;
+  word-break: break-all;
+  display: inline-block;
+}
+
+.header-value {
+  color: #222;
+}
+
+.headers-row.exe-header {
+  background: linear-gradient(90deg, rgba(0, 102, 204, 0.12), rgba(0, 102, 204, 0.02));
+  border-left: 3px solid rgba(0, 102, 204, 0.45);
+}
+
+.header-empty {
+  color: #888;
+  font-style: italic;
+}
+
+/* Counter section */
+.counter {
+  margin: 20px auto;
+  font-size: 16px;
+  color: #666;
+}
+
+.counter strong {
+  color: #000;
+}
+
+/* Mobile responsive */
+@media (max-width: 600px) {
+  body {
+    padding: 20px 10px;
+  }
+
+  h1 {
+    margin-bottom: 20px;
+  }
+
+  .intro {
+    font-size: 14px;
+    margin-bottom: 20px;
+    padding: 0 10px;
+  }
+
+  .hostname {
+    margin-bottom: 30px;
+    font-size: clamp(20px, 5vw, 28px);
+  }
+
+  .actions {
+    margin: 30px auto;
+    flex-direction: column;
+    max-width: 100%;
+    padding: 0 10px;
+  }
+
+  .actions li {
+    flex: 1 1 auto;
+    min-width: 0;
+  }
+
+  .actions a {
+    padding: 16px 12px;
+  }
+
+  .action-icon {
+    font-size: 28px;
+  }
+
+  .action-label {
+    font-size: 13px;
+  }
+
+  .action-detail {
+    font-size: 11px;
+  }
+
+  .user-info {
+    padding: 0;
+  }
+
+  hr {
+    transform: scale(0.8);
+    margin: 30px auto;
+  }
+
+  .headers {
+    padding: 16px;
+  }
+
+  .headers-notes {
+    gap: 6px;
+  }
+
+  .headers-row {
+    grid-template-columns: 1fr;
+    gap: 6px;
+    padding: 10px 12px;
+  }
+}
+
+/* Copy feedback */
+.copied-feedback {
+  position: fixed;
+  top: 20px;
+  left: 50%;
+  transform: translateX(-50%);
+  background: rgba(100, 180, 255, 0.9);
+  color: #000;
+  padding: 12px 24px;
+  border-radius: 8px;
+  font-size: 14px;
+  opacity: 0;
+  transition: opacity 0.3s;
+  pointer-events: none;
+  z-index: 1000;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+.copied-feedback.show {
+  opacity: 1;
+}

templates/go/srv/templates/welcome.html πŸ”—

@@ -0,0 +1,149 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>{{.Hostname}}</title>
+    <link rel="stylesheet" href="/static/style.css" />
+  </head>
+  <body>
+    <main>
+      <h1>Go Template Project</h1>
+
+      <section class="intro">
+        <p>
+          This is a starter template for building Go web applications on exe.dev. Customize this page and the code to create your own service.
+        </p>
+        <p>
+          This VM has a persistent disk, sudo, HTTPS, and SSH all wired up.
+        </p>
+      </section>
+
+      <section class="next-steps">
+        <h2>What next?</h2>
+        <div class="step-grid">
+          <article class="step-card">
+            <h3>Customize this template</h3>
+            <p>Edit the code to build your application.</p>
+            <ul>
+              <li>Source code is in <code>~</code></li>
+              <li>Edit via SSH or <a href="vscode://vscode-remote/ssh-remote+{{.Hostname}}.exe.xyz/home/exedev?windowId=_blank">open in VS Code</a></li>
+              <li>Chat with our coding agent, <a href="https://{{.Hostname}}.exe.xyz:9999" target="_blank">Shelley</a></li>
+              <li><code>sudo systemctl restart srv</code> to pick up changes</li>
+            </ul>
+          </article>
+          <article class="step-card">
+            <h3>What's included</h3>
+            <p>This template provides the essentials.</p>
+            <ul>
+              <li>HTTP server with routing and templates</li>
+              <li>SQLite database with migrations</li>
+              <li>Authentication via exe.dev headers</li>
+              <li>Systemd service configuration</li>
+            </ul>
+          </article>
+        </div>
+      </section>
+
+      <div class="hostname">{{.Hostname}}</div>
+
+      <ul class="actions">
+        <li>
+          <a href="#" class="ssh-copy" data-copy="ssh {{.Hostname}}.exe.xyz">
+            <span class="action-icon">πŸ”‘</span>
+            <span class="action-label">SSH</span>
+            <span class="action-detail">ssh {{.Hostname}}.exe.xyz</span>
+          </a>
+        </li>
+        <li>
+          <a href="https://{{.Hostname}}.xterm.exe.xyz" target="_blank">
+            <span class="action-icon">πŸ’»</span>
+            <span class="action-label">Terminal</span>
+            <span class="action-detail">{{.Hostname}}.xterm.exe.xyz</span>
+          </a>
+        </li>
+        <li>
+          <a href="https://{{.Hostname}}.exe.xyz:9999" target="_blank">
+            <span class="action-icon">πŸ€–</span>
+            <span class="action-label">Shelley Agent</span>
+            <span class="action-detail">{{.Hostname}}.exe.xyz:9999</span>
+          </a>
+        </li>
+        <li>
+          <a href="https://exe.dev" target="_blank">
+            <span class="action-icon">🏠</span>
+            <span class="action-label">exe.dev</span>
+            <span class="action-detail">exe.dev</span>
+          </a>
+        </li>
+      </ul>
+
+      <hr />
+
+      <div class="user-info">
+        <p class="user-status">
+          {{if .UserEmail}}
+          Signed in as <strong>{{.UserEmail}}</strong>
+          {{else}}
+          Not signed in
+          {{end}}
+        </p>
+        <div class="auth-buttons">
+          {{if .UserEmail}}
+          <form method="POST" action="{{.LogoutURL}}" style="display:inline">
+            <button type="submit">logout</button>
+          </form>
+          {{else}}
+          <a href="{{.LoginURL}}">login</a>
+          {{end}}
+        </div>
+      </div>
+
+      {{if .Headers}}
+      <section class="headers">
+        <div class="headers-title">
+          <h2>HTTP headers from exe.dev</h2>
+          <div class="headers-notes">
+            <p>exe.dev adds extra headers to HTTP requests so that:</p>
+            <ul>
+              <li>you don't have to build auth</li>
+              <li>you know where the request came from</li>
+            </ul>
+            <p>These are all the HTTP headers we received from exe.dev for this request.</p>
+            <p>The <code>X-ExeDev-*</code> and <code>X-Forwarded-*</code> headers are added by exe.dev.</p>
+          </div>
+        </div>
+        <div class="headers-table">
+          <div class="headers-row headers-head">
+            <div>Header</div>
+            <div>Value</div>
+          </div>
+          {{range .Headers}}
+          <div class="headers-row{{if .AddedByExe}} exe-header{{end}}">
+            <div class="header-name"><code>{{.Name}}</code></div>
+            <div class="header-value">
+              {{if .Values}}
+                {{range $i, $value := .Values}}
+                  {{if $i}}<br />{{end}}
+                  <code>{{$value}}</code>
+                {{end}}
+              {{else}}
+                <span class="header-empty">β€”</span>
+              {{end}}
+            </div>
+          </div>
+          {{end}}
+        </div>
+      </section>
+      {{end}}
+
+      {{if .VisitCount}}
+      <p class="counter">You've viewed this page <strong>{{.VisitCount}}</strong> times.</p>
+      {{end}}
+    </main>
+
+    <div class="copied-feedback" id="copiedFeedback">Copied to clipboard!</div>
+
+    <script src="/static/script.js"></script>
+  </body>
+</html>

templates/templates.go πŸ”—

@@ -0,0 +1,111 @@
+// Package templates provides embedded project templates for shelley.
+package templates
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"embed"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+//go:embed *.tar.gz
+var FS embed.FS
+
+// List returns the names of all available templates.
+func List() ([]string, error) {
+	entries, err := FS.ReadDir(".")
+	if err != nil {
+		return nil, fmt.Errorf("read templates dir: %w", err)
+	}
+	var names []string
+	for _, e := range entries {
+		if e.IsDir() {
+			continue
+		}
+		name := e.Name()
+		if strings.HasSuffix(name, ".tar.gz") {
+			names = append(names, strings.TrimSuffix(name, ".tar.gz"))
+		}
+	}
+	return names, nil
+}
+
+// Unpack extracts the named template to the given directory.
+// The directory must exist and should be empty.
+func Unpack(templateName, destDir string) error {
+	tarPath := templateName + ".tar.gz"
+	f, err := FS.Open(tarPath)
+	if err != nil {
+		return fmt.Errorf("open template %q: %w", templateName, err)
+	}
+	defer f.Close()
+
+	gz, err := gzip.NewReader(f)
+	if err != nil {
+		return fmt.Errorf("gzip reader: %w", err)
+	}
+	defer gz.Close()
+
+	tr := tar.NewReader(gz)
+	for {
+		hdr, err := tr.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return fmt.Errorf("read tar: %w", err)
+		}
+
+		// Sanitize path to prevent directory traversal
+		cleanName := filepath.Clean(hdr.Name)
+		if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
+			return fmt.Errorf("invalid path in archive: %s", hdr.Name)
+		}
+
+		target := filepath.Join(destDir, cleanName)
+
+		switch hdr.Typeflag {
+		case tar.TypeDir:
+			if err := os.MkdirAll(target, 0o755); err != nil {
+				return fmt.Errorf("mkdir %s: %w", target, err)
+			}
+		case tar.TypeReg:
+			// Ensure parent directory exists
+			if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+				return fmt.Errorf("mkdir for %s: %w", target, err)
+			}
+			// Create the file
+			mode := os.FileMode(hdr.Mode)
+			if mode == 0 {
+				mode = 0o644
+			}
+			out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
+			if err != nil {
+				return fmt.Errorf("create %s: %w", target, err)
+			}
+			if _, err := io.Copy(out, tr); err != nil {
+				out.Close()
+				return fmt.Errorf("write %s: %w", target, err)
+			}
+			out.Close()
+		case tar.TypeSymlink:
+			// Validate symlink target
+			linkTarget := hdr.Linkname
+			if filepath.IsAbs(linkTarget) {
+				return fmt.Errorf("absolute symlink not allowed: %s -> %s", hdr.Name, linkTarget)
+			}
+			// Ensure parent directory exists
+			if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+				return fmt.Errorf("mkdir for symlink %s: %w", target, err)
+			}
+			if err := os.Symlink(linkTarget, target); err != nil {
+				return fmt.Errorf("symlink %s: %w", target, err)
+			}
+		}
+	}
+	return nil
+}

test/anthropic_test.go πŸ”—

@@ -0,0 +1,309 @@
+package test
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"io"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/server"
+)
+
+func TestWithAnthropicAPI(t *testing.T) {
+	// Skip if no API key
+	apiKey := os.Getenv("ANTHROPIC_API_KEY")
+	if apiKey == "" {
+		t.Skip("ANTHROPIC_API_KEY not set, skipping Anthropic API test")
+	}
+
+	// Create temporary database
+	tempDB := t.TempDir() + "/anthropic_test.db"
+	database, err := db.New(db.Config{DSN: tempDB})
+	if err != nil {
+		t.Fatalf("Failed to create test database: %v", err)
+	}
+	defer database.Close()
+
+	// Run migrations
+	if err := database.Migrate(context.Background()); err != nil {
+		t.Fatalf("Failed to migrate database: %v", err)
+	}
+
+	// Create LLM service manager
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+		Level: slog.LevelInfo, // Less verbose for real API test
+	}))
+	llmConfig := &server.LLMConfig{
+		AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
+		OpenAIAPIKey:    os.Getenv("OPENAI_API_KEY"),
+		GeminiAPIKey:    os.Getenv("GEMINI_API_KEY"),
+		FireworksAPIKey: os.Getenv("FIREWORKS_API_KEY"),
+		Logger:          logger,
+	}
+	llmManager := server.NewLLMServiceManager(llmConfig, nil)
+
+	// Set up tools config
+	toolSetConfig := claudetool.ToolSetConfig{
+		WorkingDir:    t.TempDir(),
+		LLMProvider:   llmManager,
+		EnableBrowser: false,
+	}
+
+	// Create server
+	svr := server.NewServer(database, llmManager, toolSetConfig, logger, false, "", "", "", nil)
+
+	// Set up HTTP server
+	mux := http.NewServeMux()
+	svr.RegisterRoutes(mux)
+	testServer := httptest.NewServer(mux)
+	defer testServer.Close()
+
+	t.Run("SimpleConversationWithClaude", func(t *testing.T) {
+		// Create a conversation
+		// Using database directly instead of service
+		slug := "claude-test"
+		conv, err := database.CreateConversation(context.Background(), &slug, true, nil)
+		if err != nil {
+			t.Fatalf("Failed to create conversation: %v", err)
+		}
+
+		// Send a simple message
+		chatReq := map[string]interface{}{
+			"message": "Hello! Please introduce yourself briefly and tell me what you can help me with. Keep your response under 50 words.",
+			"model":   "claude-haiku-4.5",
+		}
+		reqBody, _ := json.Marshal(chatReq)
+
+		resp, err := http.Post(
+			testServer.URL+"/api/conversation/"+conv.ConversationID+"/chat",
+			"application/json",
+			bytes.NewReader(reqBody),
+		)
+		if err != nil {
+			t.Fatalf("Failed to send chat message: %v", err)
+		}
+		defer resp.Body.Close()
+
+		if resp.StatusCode != http.StatusAccepted {
+			t.Fatalf("Expected status 202, got %d", resp.StatusCode)
+		}
+
+		// Wait for processing (Claude API can be slow)
+		time.Sleep(5 * time.Second)
+
+		// Check messages
+		msgResp, err := http.Get(testServer.URL + "/api/conversation/" + conv.ConversationID)
+		if err != nil {
+			t.Fatalf("Failed to get conversation: %v", err)
+		}
+		defer msgResp.Body.Close()
+
+		if msgResp.StatusCode != http.StatusOK {
+			t.Fatalf("Expected status 200, got %d", msgResp.StatusCode)
+		}
+
+		var payload server.StreamResponse
+		if err := json.NewDecoder(msgResp.Body).Decode(&payload); err != nil {
+			t.Fatalf("Failed to decode messages: %v", err)
+		}
+
+		// Should have system message, user message and assistant response
+		if len(payload.Messages) < 3 {
+			msgTypes := make([]string, len(payload.Messages))
+			for i, msg := range payload.Messages {
+				msgTypes[i] = msg.Type
+			}
+			t.Fatalf("Expected at least 3 messages (system + user + assistant), got %d: %v", len(payload.Messages), msgTypes)
+		}
+
+		// Check first message is system prompt
+		if payload.Messages[0].Type != "system" {
+			t.Fatalf("Expected first message to be system, got %s", payload.Messages[0].Type)
+		}
+
+		// Check user message is second
+		if payload.Messages[1].Type != "user" {
+			t.Fatalf("Expected second message to be user, got %s", payload.Messages[1].Type)
+		}
+
+		// Check assistant response
+		assistantFound := false
+		for _, msg := range payload.Messages {
+			if msg.Type == "agent" {
+				assistantFound = true
+				if msg.LlmData == nil {
+					t.Fatal("Assistant message has no LLM data")
+				}
+
+				// Parse and check the response content
+				var llmMsg llm.Message
+				if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
+					t.Fatalf("Failed to unmarshal LLM data: %v", err)
+				}
+
+				if len(llmMsg.Content) == 0 {
+					t.Fatal("Assistant response has no content")
+				}
+
+				responseText := llmMsg.Content[0].Text
+				if responseText == "" {
+					t.Fatal("Assistant response text is empty")
+				}
+
+				// Claude should mention being Claude or an AI assistant
+				lowerResponse := strings.ToLower(responseText)
+				if !strings.Contains(lowerResponse, "claude") && !strings.Contains(lowerResponse, "assistant") {
+					t.Logf("Response: %s", responseText)
+					// This is not a hard failure - Claude might respond differently
+				}
+
+				t.Logf("Claude responded: %s", responseText)
+				break
+			}
+		}
+
+		if !assistantFound {
+			t.Fatal("No assistant response found")
+		}
+	})
+
+	t.Run("ConversationWithToolUse", func(t *testing.T) {
+		// Create a conversation
+		// Using database directly instead of service
+		slug := "tool-test"
+		conv, err := database.CreateConversation(context.Background(), &slug, true, nil)
+		if err != nil {
+			t.Fatalf("Failed to create conversation: %v", err)
+		}
+
+		// Ask Claude to think about something
+		chatReq := map[string]interface{}{
+			"message": "Please use the think tool to plan how you would help someone learn to code. Keep it brief.",
+			"model":   "claude-haiku-4.5",
+		}
+		reqBody, _ := json.Marshal(chatReq)
+
+		resp, err := http.Post(
+			testServer.URL+"/api/conversation/"+conv.ConversationID+"/chat",
+			"application/json",
+			bytes.NewReader(reqBody),
+		)
+		if err != nil {
+			t.Fatalf("Failed to send chat message: %v", err)
+		}
+		defer resp.Body.Close()
+
+		if resp.StatusCode != http.StatusAccepted {
+			t.Fatalf("Expected status 202, got %d", resp.StatusCode)
+		}
+
+		// Wait for processing (tool use might take longer)
+		time.Sleep(8 * time.Second)
+
+		// Check messages
+		msgResp, err := http.Get(testServer.URL + "/api/conversation/" + conv.ConversationID)
+		if err != nil {
+			t.Fatalf("Failed to get conversation: %v", err)
+		}
+		defer msgResp.Body.Close()
+
+		var payload server.StreamResponse
+		if err := json.NewDecoder(msgResp.Body).Decode(&payload); err != nil {
+			t.Fatalf("Failed to decode messages: %v", err)
+		}
+
+		// Should have multiple messages due to tool use
+		if len(payload.Messages) < 3 {
+			t.Logf("Got %d messages, expected at least 3 for tool use interaction", len(payload.Messages))
+			// This might not always be the case depending on Claude's response
+		}
+
+		// Log all messages for debugging
+		for i, msg := range payload.Messages {
+			t.Logf("Message %d: Type=%s", i, msg.Type)
+			if msg.LlmData != nil {
+				var llmMsg llm.Message
+				if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err == nil {
+					if len(llmMsg.Content) > 0 && llmMsg.Content[0].Text != "" {
+						t.Logf("  Content: %s", llmMsg.Content[0].Text[:min(100, len(llmMsg.Content[0].Text))])
+					}
+				}
+			}
+		}
+	})
+
+	t.Run("StreamingEndpoint", func(t *testing.T) {
+		// Create a conversation with a message
+		// Using database directly instead of service
+		// Using database directly instead of service
+		slug := "stream-test"
+		conv, err := database.CreateConversation(context.Background(), &slug, true, nil)
+		if err != nil {
+			t.Fatalf("Failed to create conversation: %v", err)
+		}
+
+		// Add a test message
+		testMsg := llm.Message{
+			Role: llm.MessageRoleUser,
+			Content: []llm.Content{
+				{Type: llm.ContentTypeText, Text: "Hello streaming test"},
+			},
+		}
+		_, err = database.CreateMessage(context.Background(), db.CreateMessageParams{
+			ConversationID: conv.ConversationID,
+			Type:           db.MessageTypeUser,
+			LLMData:        testMsg,
+		})
+		if err != nil {
+			t.Fatalf("Failed to create message: %v", err)
+		}
+
+		// Test stream endpoint
+		resp, err := http.Get(testServer.URL + "/api/conversation/" + conv.ConversationID + "/stream")
+		if err != nil {
+			t.Fatalf("Failed to get stream: %v", err)
+		}
+		defer resp.Body.Close()
+
+		if resp.StatusCode != http.StatusOK {
+			t.Fatalf("Expected status 200, got %d", resp.StatusCode)
+		}
+
+		// Check headers
+		if resp.Header.Get("Content-Type") != "text/event-stream" {
+			t.Fatal("Expected text/event-stream content type")
+		}
+
+		// Read first chunk (should contain current messages)
+		buf := make([]byte, 2048)
+		n, err := resp.Body.Read(buf)
+		if err != nil && err != io.EOF {
+			t.Fatalf("Failed to read stream: %v", err)
+		}
+
+		data := string(buf[:n])
+		if !strings.Contains(data, "data: ") {
+			t.Fatal("Expected SSE data format")
+		}
+
+		t.Logf("Received stream data: %s", data[:min(200, len(data))])
+	})
+}
+
+// Helper function for min
+func min(a, b int) int {
+	if a < b {
+		return a
+	}
+	return b
+}

test/server_test.go πŸ”—

@@ -0,0 +1,1011 @@
+package test
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"shelley.exe.dev/claudetool"
+	"shelley.exe.dev/claudetool/browse"
+	"shelley.exe.dev/db"
+	"shelley.exe.dev/db/generated"
+	"shelley.exe.dev/llm"
+	"shelley.exe.dev/loop"
+	"shelley.exe.dev/server"
+	"shelley.exe.dev/slug"
+)
+
+func TestServerEndToEnd(t *testing.T) {
+	// Create temporary database
+	tempDB := t.TempDir() + "/test.db"
+	database, err := db.New(db.Config{DSN: tempDB})
+	if err != nil {
+		t.Fatalf("Failed to create test database: %v", err)
+	}
+	defer database.Close()
+
+	// Run migrations
+	if err := database.Migrate(context.Background()); err != nil {
+		t.Fatalf("Failed to migrate database: %v", err)
+	}
+
+	// Create logger first
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+		Level: slog.LevelDebug,
+	}))
+
+	// Create LLM service manager with predictable service
+	llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+	predictableService := loop.NewPredictableService()
+	// For testing, we'll override the manager's service selection
+	_ = predictableService // will need to mock this properly
+
+	// Set up tools
+	// Set up tools config
+	toolSetConfig := claudetool.ToolSetConfig{
+		WorkingDir:    t.TempDir(),
+		EnableBrowser: false,
+	}
+
+	// Create server
+	svr := server.NewServer(database, llmManager, toolSetConfig, logger, false, "", "", "", nil)
+
+	// Set up HTTP server
+	mux := http.NewServeMux()
+	svr.RegisterRoutes(mux)
+	testServer := httptest.NewServer(mux)
+	defer testServer.Close()
+
+	t.Run("CreateAndListConversations", func(t *testing.T) {
+		// Create a conversation
+		// Using database directly instead of service
+		slug := "test-conversation"
+		conv, err := database.CreateConversation(context.Background(), &slug, true, nil)
+		if err != nil {
+			t.Fatalf("Failed to create conversation: %v", err)
+		}
+
+		// List conversations
+		resp, err := http.Get(testServer.URL + "/api/conversations")
+		if err != nil {
+			t.Fatalf("Failed to get conversations: %v", err)
+		}
+		defer resp.Body.Close()
+
+		if resp.StatusCode != http.StatusOK {
+			t.Fatalf("Expected status 200, got %d", resp.StatusCode)
+		}
+
+		var conversations []generated.Conversation
+		if err := json.NewDecoder(resp.Body).Decode(&conversations); err != nil {
+			t.Fatalf("Failed to decode response: %v", err)
+		}
+
+		if len(conversations) != 1 {
+			t.Fatalf("Expected 1 conversation, got %d", len(conversations))
+		}
+
+		if conversations[0].ConversationID != conv.ConversationID {
+			t.Fatalf("Conversation ID mismatch")
+		}
+	})
+
+	t.Run("ChatEndToEnd", func(t *testing.T) {
+		// Create a conversation
+		// Using database directly instead of service
+		slug := "chat-test"
+		conv, err := database.CreateConversation(context.Background(), &slug, true, nil)
+		if err != nil {
+			t.Fatalf("Failed to create conversation: %v", err)
+		}
+
+		// Send a chat message using predictable model
+		chatReq := map[string]interface{}{"message": "Hello, can you help me?", "model": "predictable"}
+		reqBody, _ := json.Marshal(chatReq)
+
+		resp, err := http.Post(
+			testServer.URL+"/api/conversation/"+conv.ConversationID+"/chat",
+			"application/json",
+			bytes.NewReader(reqBody),
+		)
+		if err != nil {
+			t.Fatalf("Failed to send chat message: %v", err)
+		}
+		defer resp.Body.Close()
+
+		if resp.StatusCode != http.StatusAccepted {
+			t.Fatalf("Expected status 202, got %d", resp.StatusCode)
+		}
+
+		// Wait a bit for processing
+		time.Sleep(500 * time.Millisecond)
+
+		// Check messages
+		msgResp, err := http.Get(testServer.URL + "/api/conversation/" + conv.ConversationID)
+		if err != nil {
+			t.Fatalf("Failed to get conversation: %v", err)
+		}
+		defer msgResp.Body.Close()
+
+		if msgResp.StatusCode != http.StatusOK {
+			t.Fatalf("Expected status 200, got %d", msgResp.StatusCode)
+		}
+
+		var payload server.StreamResponse
+		if err := json.NewDecoder(msgResp.Body).Decode(&payload); err != nil {
+			t.Fatalf("Failed to decode messages: %v", err)
+		}
+
+		// Should have at least system and user messages
+		if len(payload.Messages) < 2 {
+			t.Fatalf("Expected at least 2 messages (system + user), got %d", len(payload.Messages))
+		}
+
+		// First message should be system prompt
+		if payload.Messages[0].Type != "system" {
+			t.Fatalf("Expected first message to be system, got %s", payload.Messages[0].Type)
+		}
+
+		// Second message should be from user
+		if payload.Messages[1].Type != "user" {
+			t.Fatalf("Expected second message to be user, got %s", payload.Messages[1].Type)
+		}
+	})
+
+	t.Run("StreamEndpoint", func(t *testing.T) {
+		// Create a conversation with some messages
+		// Using database directly instead of service
+		// Using database directly instead of service
+		slug := "stream-test"
+		conv, err := database.CreateConversation(context.Background(), &slug, true, nil)
+		if err != nil {
+			t.Fatalf("Failed to create conversation: %v", err)
+		}
+
+		// Add a test message
+		testMsg := llm.Message{
+			Role: llm.MessageRoleUser,
+			Content: []llm.Content{
+				{Type: llm.ContentTypeText, Text: "Test message"},
+			},
+		}
+		_, err = database.CreateMessage(context.Background(), db.CreateMessageParams{
+			ConversationID: conv.ConversationID,
+			Type:           db.MessageTypeUser,
+			LLMData:        testMsg,
+		})
+		if err != nil {
+			t.Fatalf("Failed to create message: %v", err)
+		}
+
+		// Test stream endpoint
+		resp, err := http.Get(testServer.URL + "/api/conversation/" + conv.ConversationID + "/stream")
+		if err != nil {
+			t.Fatalf("Failed to get stream: %v", err)
+		}
+		defer resp.Body.Close()
+
+		if resp.StatusCode != http.StatusOK {
+			t.Fatalf("Expected status 200, got %d", resp.StatusCode)
+		}
+
+		// Check headers
+		if resp.Header.Get("Content-Type") != "text/event-stream" {
+			t.Fatal("Expected text/event-stream content type")
+		}
+
+		// Read first event (should be current messages)
+		buf := make([]byte, 1024)
+		n, err := resp.Body.Read(buf)
+		if err != nil && err != io.EOF {
+			t.Fatalf("Failed to read stream: %v", err)
+		}
+
+		data := string(buf[:n])
+		if !strings.Contains(data, "data: ") {
+			t.Fatal("Expected SSE data format")
+		}
+	})
+
+	// Test that slug updates are reflected in the stream
+	t.Run("SlugUpdateStream", func(t *testing.T) {
+		// Create a context that won't be canceled unexpectedly
+		ctx := context.Background()
+
+		// Create a conversation without a slug
+		conv, err := database.CreateConversation(ctx, nil, true, nil)
+		if err != nil {
+			t.Fatalf("Failed to create conversation: %v", err)
+		}
+
+		// Verify initially no slug
+		if conv.Slug != nil {
+			t.Fatalf("Expected no initial slug, got: %v", *conv.Slug)
+		}
+
+		// Send a message which should trigger slug generation
+		chatRequest := server.ChatRequest{
+			Message: "Write a Python script to calculate fibonacci numbers",
+			Model:   "predictable",
+		}
+
+		chatBody, _ := json.Marshal(chatRequest)
+		chatResp, err := http.Post(
+			testServer.URL+"/api/conversation/"+conv.ConversationID+"/chat",
+			"application/json",
+			strings.NewReader(string(chatBody)),
+		)
+		if err != nil {
+			t.Fatalf("Failed to send chat message: %v", err)
+		}
+		defer chatResp.Body.Close()
+
+		// Check response status before continuing
+		if chatResp.StatusCode != http.StatusAccepted {
+			t.Fatalf("Expected status 202, got %d", chatResp.StatusCode)
+		}
+
+		// Wait longer for slug generation (it happens asynchronously)
+		// Poll every 100ms instead of 500ms for faster feedback
+		for i := 0; i < 100; i++ {
+			time.Sleep(100 * time.Millisecond)
+
+			// Check if slug was generated
+			updatedConv, err := database.GetConversationByID(ctx, conv.ConversationID)
+			if err != nil {
+				// Don't fail immediately on error - the conversation might be temporarily locked
+				// Only fail if we've exhausted all retries
+				if i == 99 {
+					t.Fatalf("Failed to get updated conversation after all retries: %v", err)
+				}
+				continue
+			}
+
+			if updatedConv.Slug != nil {
+				t.Logf("Slug generated successfully: %s", *updatedConv.Slug)
+				return
+			}
+		}
+
+		t.Fatal("Slug was not generated within timeout period")
+	})
+
+	t.Run("ErrorHandling", func(t *testing.T) {
+		// Test non-existent conversation
+		resp, err := http.Get(testServer.URL + "/api/conversation/nonexistent")
+		if err != nil {
+			t.Fatalf("Failed to make request: %v", err)
+		}
+		defer resp.Body.Close()
+
+		// Should handle gracefully (might be empty list or error depending on implementation)
+		if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound {
+			t.Fatalf("Unexpected status code: %d", resp.StatusCode)
+		}
+
+		// Test invalid chat request
+		invalidReq := map[string]string{"not_message": "test"}
+		reqBody, _ := json.Marshal(invalidReq)
+		chatResp, err := http.Post(
+			testServer.URL+"/api/conversation/test/chat",
+			"application/json",
+			bytes.NewReader(reqBody),
+		)
+		if err != nil {
+			t.Fatalf("Failed to send invalid chat: %v", err)
+		}
+		defer chatResp.Body.Close()
+
+		if chatResp.StatusCode != http.StatusBadRequest {
+			t.Fatalf("Expected status 400 for invalid request, got %d", chatResp.StatusCode)
+		}
+	})
+}
+
+func TestPredictableServiceWithTools(t *testing.T) {
+	// Test that the predictable service correctly handles tool calls
+	service := loop.NewPredictableService()
+
+	// First call should return greeting
+	resp1, err := service.Do(context.Background(), &llm.Request{
+		Messages: []llm.Message{
+			{Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Hello"}}},
+		},
+	})
+	if err != nil {
+		t.Fatalf("First call failed: %v", err)
+	}
+
+	if !strings.Contains(resp1.Content[0].Text, "Shelley") {
+		t.Fatal("Expected greeting to mention Shelley")
+	}
+
+	// Second call should return tool use
+	resp2, err := service.Do(context.Background(), &llm.Request{
+		Messages: []llm.Message{
+			{Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Create an example"}}},
+		},
+	})
+	if err != nil {
+		t.Fatalf("Second call failed: %v", err)
+	}
+
+	if resp2.StopReason != llm.StopReasonToolUse {
+		t.Fatal("Expected tool use stop reason")
+	}
+
+	if len(resp2.Content) < 2 {
+		t.Fatal("Expected both text and tool use content")
+	}
+
+	// Find tool use content
+	var toolUse *llm.Content
+	for i := range resp2.Content {
+		if resp2.Content[i].Type == llm.ContentTypeToolUse {
+			toolUse = &resp2.Content[i]
+			break
+		}
+	}
+
+	if toolUse == nil {
+		t.Fatal("Expected tool use content")
+	}
+
+	if toolUse.ToolName != "think" {
+		t.Fatalf("Expected think tool, got %s", toolUse.ToolName)
+	}
+}
+
+func TestConversationCleanup(t *testing.T) {
+	// Create temporary database
+	tempDB := t.TempDir() + "/cleanup_test.db"
+	database, err := db.New(db.Config{DSN: tempDB})
+	if err != nil {
+		t.Fatalf("Failed to create test database: %v", err)
+	}
+	defer database.Close()
+
+	// Run migrations
+	if err := database.Migrate(context.Background()); err != nil {
+		t.Fatalf("Failed to migrate database: %v", err)
+	}
+
+	// Create server with predictable service
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
+	llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+	svr := server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, false, "", "", "", nil)
+
+	// Create a conversation
+	// Using database directly instead of service
+	conv, err := database.CreateConversation(context.Background(), nil, true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create conversation: %v", err)
+	}
+
+	// Test cleanup indirectly by calling cleanup
+	svr.Cleanup()
+
+	// Test passes if no panic occurs
+	t.Log("Cleanup completed successfully for conversation:", conv.ConversationID)
+}
+
+func TestSlugGeneration(t *testing.T) {
+	// This test verifies that the slug generation logic is properly integrated
+	// but uses the direct API to avoid timing issues with background goroutines
+
+	// Create temporary database
+	tempDB := t.TempDir() + "/test.db"
+	database, err := db.New(db.Config{DSN: tempDB})
+	if err != nil {
+		t.Fatalf("Failed to create test database: %v", err)
+	}
+	defer database.Close()
+
+	// Run migrations
+	if err := database.Migrate(context.Background()); err != nil {
+		t.Fatalf("Failed to migrate database: %v", err)
+	}
+
+	// Create server
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+	llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+	_ = server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, false, "", "", "", nil)
+
+	// Test slug generation directly to avoid timing issues
+	// ctx := context.Background()
+	// testMessage := "help me create a Python web server"
+
+	// TODO: Fix slug generation test - method moved to slug package
+	// Generate slug directly
+	// slugResult, err := svr.GenerateSlugForConversation(ctx, testMessage)
+	// if err != nil {
+	//	t.Fatalf("Slug generation failed: %v", err)
+	// }
+	// if slugResult == "" {
+	//	t.Error("Generated slug is empty")
+	// } else {
+	//	t.Logf("Generated slug: %s", slugResult)
+	// }
+
+	// TODO: Fix slug tests
+	// Test that the slug is properly sanitized
+	// if !strings.Contains(slugResult, "python") || !strings.Contains(slugResult, "web") {
+	//	t.Logf("Note: Generated slug '%s' may not contain expected keywords, but this is acceptable for AI-generated content", slugResult)
+	// }
+
+	// // Verify slug uniqueness handling
+	// conv, err := database.CreateConversation(ctx, &slugResult, true)
+	// if err != nil {
+	//	t.Fatalf("Failed to create conversation with slug: %v", err)
+	// }
+
+	// TODO: Fix slug generation test
+	// Try to generate the same slug again - should get a unique variant
+	// slugResult2, err := svr.GenerateSlugForConversation(ctx, testMessage)
+	// if err != nil {
+	//	t.Fatalf("Second slug generation failed: %v", err)
+	// }
+
+	// // The second slug should be different (with -1, -2, etc.)
+	// if slugResult == slugResult2 {
+	//	t.Errorf("Expected different slugs for uniqueness, but got same: %s", slugResult)
+	// } else {
+	//	t.Logf("Unique slug generated: %s", slugResult2)
+	// }
+
+	// _ = conv // avoid unused variable warning
+}
+
+func TestSanitizeSlug(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected string
+	}{
+		{"basic text", "Hello World", "hello-world"},
+		{"with numbers", "Python3 Tutorial", "python3-tutorial"},
+		{"with special chars", "C++ Programming!", "c-programming"},
+		{"multiple spaces", "Very  Long   Title", "very-long-title"},
+		{"underscores", "test_function_name", "test-function-name"},
+		{"mixed case", "CamelCaseExample", "camelcaseexample"},
+		{"with hyphens", "pre-existing-hyphens", "pre-existing-hyphens"},
+		{"leading/trailing spaces", "  trimmed  ", "trimmed"},
+		{"leading/trailing hyphens", "-start-end-", "start-end"},
+		{"multiple consecutive hyphens", "test---slug", "test-slug"},
+		{"empty after sanitization", "!@#$%^&*()", ""},
+		{"very long", "this-is-a-very-long-slug-that-should-be-truncated-because-it-exceeds-the-maximum-length", "this-is-a-very-long-slug-that-should-be-truncated-because-it"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := slug.Sanitize(tt.input)
+			if result != tt.expected {
+				t.Errorf("SanitizeSlug(%q) = %q, want %q", tt.input, result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestSlugGenerationWithPredictableService(t *testing.T) {
+	// Create server with predictable service only
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+	llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+
+	// Create a temporary database
+	tempDB := t.TempDir() + "/test.db"
+	database, err := db.New(db.Config{DSN: tempDB})
+	if err != nil {
+		t.Fatalf("Failed to create test database: %v", err)
+	}
+	defer database.Close()
+
+	if err := database.Migrate(context.Background()); err != nil {
+		t.Fatalf("Failed to migrate database: %v", err)
+	}
+
+	_ = server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, false, "", "", "", nil)
+
+	// Test slug generation directly
+	// ctx := context.Background()
+	// testMessage := "help me write a python function"
+
+	// TODO: Fix slug generation test
+	// This should work with the predictable service falling back
+	// slugResult, err := svr.GenerateSlugForConversation(ctx, testMessage)
+	// if err != nil {
+	//	t.Fatalf("Slug generation failed: %v", err)
+	// }
+	// if slugResult == "" {
+	//	t.Error("Generated slug is empty")
+	// }
+	// t.Logf("Generated slug: %s", slugResult)
+
+	// TODO: Fix slug sanitization test
+	// Test slug sanitization which should always work
+	// slug := slug.Sanitize(testMessage)
+	// if slug != "help-me-write-a-python-function" {
+	//	t.Errorf("Expected 'help-me-write-a-python-function', got '%s'", slug)
+	// }
+}
+
+func TestSlugEndToEnd(t *testing.T) {
+	// Create temporary database
+	tempDB := t.TempDir() + "/test.db"
+	database, err := db.New(db.Config{DSN: tempDB})
+	if err != nil {
+		t.Fatalf("Failed to create test database: %v", err)
+	}
+	defer database.Close()
+
+	// Run migrations
+	if err := database.Migrate(context.Background()); err != nil {
+		t.Fatalf("Failed to migrate database: %v", err)
+	}
+
+	// Create a conversation with a specific slug
+	ctx := context.Background()
+	testSlug := "test-conversation-slug"
+	conv, err := database.CreateConversation(ctx, &testSlug, true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create conversation: %v", err)
+	}
+
+	// Test retrieving by slug
+	retrievedBySlug, err := database.GetConversationBySlug(ctx, testSlug)
+	if err != nil {
+		t.Fatalf("Failed to retrieve conversation by slug: %v", err)
+	}
+
+	if retrievedBySlug.ConversationID != conv.ConversationID {
+		t.Errorf("Expected conversation ID %s, got %s", conv.ConversationID, retrievedBySlug.ConversationID)
+	}
+
+	if retrievedBySlug.Slug == nil || *retrievedBySlug.Slug != testSlug {
+		t.Errorf("Expected slug %s, got %v", testSlug, retrievedBySlug.Slug)
+	}
+
+	// Test retrieving by ID still works
+	retrievedByID, err := database.GetConversationByID(ctx, conv.ConversationID)
+	if err != nil {
+		t.Fatalf("Failed to retrieve conversation by ID: %v", err)
+	}
+
+	if retrievedByID.ConversationID != conv.ConversationID {
+		t.Errorf("Expected conversation ID %s, got %s", conv.ConversationID, retrievedByID.ConversationID)
+	}
+
+	t.Logf("Successfully tested slug-based conversation retrieval: %s -> %s", testSlug, conv.ConversationID)
+}
+
+// Test that slug updates are reflected in the stream
+
+// Test that SSE only sends incremental message updates
+func TestSSEIncrementalUpdates(t *testing.T) {
+	// Create temporary database
+	tempDB := t.TempDir() + "/test.db"
+	database, err := db.New(db.Config{DSN: tempDB})
+	if err != nil {
+		t.Fatalf("Failed to create test database: %v", err)
+	}
+	defer database.Close()
+
+	// Run migrations
+	if err := database.Migrate(context.Background()); err != nil {
+		t.Fatalf("Failed to migrate database: %v", err)
+	}
+
+	// Create logger and LLM manager
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn}))
+	llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+
+	// Create server
+	serviceInstance := server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, false, "", "", "", nil)
+	mux := http.NewServeMux()
+	serviceInstance.RegisterRoutes(mux)
+	testServer := httptest.NewServer(mux)
+	defer testServer.Close()
+
+	// Create a conversation with initial message
+	slug := "test-sse"
+	conv, err := database.CreateConversation(context.Background(), &slug, true, nil)
+	if err != nil {
+		t.Fatalf("Failed to create conversation: %v", err)
+	}
+
+	// Add initial message
+	_, err = database.CreateMessage(context.Background(), db.CreateMessageParams{
+		ConversationID: conv.ConversationID,
+		Type:           db.MessageTypeUser,
+		LLMData:        &llm.Message{Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Hello"}}},
+		UserData:       map[string]string{"content": "Hello"},
+		UsageData:      llm.Usage{},
+	})
+	if err != nil {
+		t.Fatalf("Failed to create initial message: %v", err)
+	}
+
+	// Create first SSE client
+	client1, err := http.Get(testServer.URL + "/api/conversation/" + conv.ConversationID + "/stream")
+	if err != nil {
+		t.Fatalf("Failed to connect client1: %v", err)
+	}
+	defer client1.Body.Close()
+
+	// Read initial response from client1 (should contain the first message)
+	buf1 := make([]byte, 2048)
+	n1, err := client1.Body.Read(buf1)
+	if err != nil && err != io.EOF {
+		t.Fatalf("Failed to read from client1: %v", err)
+	}
+
+	response1 := string(buf1[:n1])
+	t.Logf("Client1 initial response: %s", response1)
+
+	// Verify client1 received the initial message
+	if !strings.Contains(response1, "Hello") {
+		t.Fatal("Client1 should have received initial message")
+	}
+
+	// Add a second message
+	_, err = database.CreateMessage(context.Background(), db.CreateMessageParams{
+		ConversationID: conv.ConversationID,
+		Type:           db.MessageTypeAgent,
+		LLMData:        &llm.Message{Role: llm.MessageRoleAssistant, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Hi there!"}}},
+		UserData:       map[string]string{"content": "Hi there!"},
+		UsageData:      llm.Usage{},
+	})
+	if err != nil {
+		t.Fatalf("Failed to create second message: %v", err)
+	}
+
+	// Create second SSE client after the new message is added
+	client2, err := http.Get(testServer.URL + "/api/conversation/" + conv.ConversationID + "/stream")
+	if err != nil {
+		t.Fatalf("Failed to connect client2: %v", err)
+	}
+	defer client2.Body.Close()
+
+	// Read response from client2 (should contain both messages since it's a new client)
+	buf2 := make([]byte, 2048)
+	n2, err := client2.Body.Read(buf2)
+	if err != nil && err != io.EOF {
+		t.Fatalf("Failed to read from client2: %v", err)
+	}
+
+	response2 := string(buf2[:n2])
+	t.Logf("Client2 initial response: %s", response2)
+
+	// Verify client2 received both messages (new client gets full state)
+	if !strings.Contains(response2, "Hello") {
+		t.Fatal("Client2 should have received first message")
+	}
+	if !strings.Contains(response2, "Hi there!") {
+		t.Fatal("Client2 should have received second message")
+	}
+
+	t.Log("SSE incremental updates test completed successfully")
+}
+
+// TestSystemPromptSentToLLM verifies that the system prompt is included in LLM requests
+func TestSystemPromptSentToLLM(t *testing.T) {
+	ctx := context.Background()
+
+	// Create database and server with predictable service
+	// Note: :memory: is not supported by our DB wrapper since it requires multiple connections.
+	// Use a temp file-backed database for tests.
+	tempDB := t.TempDir() + "/system_prompt_test.db"
+	database, err := db.New(db.Config{DSN: tempDB})
+	if err != nil {
+		t.Fatalf("Failed to create database: %v", err)
+	}
+	defer database.Close()
+
+	if err := database.Migrate(ctx); err != nil {
+		t.Fatalf("Failed to migrate database: %v", err)
+	}
+
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
+
+	// Create a predictable service we can inspect
+	predictableService := loop.NewPredictableService()
+
+	// Create a custom LLM manager that returns our inspectable predictable service
+	customLLMManager := &inspectableLLMManager{
+		predictableService: predictableService,
+		logger:             logger,
+	}
+
+	tools := claudetool.ToolSetConfig{}
+	svr := server.NewServer(database, customLLMManager, tools, logger, false, "", "", "", nil)
+
+	// Start server
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		mux := http.NewServeMux()
+		svr.RegisterRoutes(mux)
+		mux.ServeHTTP(w, r)
+	}))
+	defer ts.Close()
+
+	// Test 1: Create new conversation and send first message
+	t.Run("FirstMessage", func(t *testing.T) {
+		predictableService.ClearRequests()
+
+		// Send first message using /api/conversations/new
+		chatReq := map[string]interface{}{
+			"message": "Hello",
+			"model":   "predictable",
+		}
+		body, _ := json.Marshal(chatReq)
+		resp, err := http.Post(ts.URL+"/api/conversations/new", "application/json", bytes.NewBuffer(body))
+		if err != nil {
+			t.Fatalf("Failed to send message: %v", err)
+		}
+		defer resp.Body.Close()
+
+		if resp.StatusCode != http.StatusCreated {
+			body, _ := io.ReadAll(resp.Body)
+			t.Fatalf("Expected status 201, got %d: %s", resp.StatusCode, body)
+		}
+
+		// Poll for async processing completion
+		// We need to wait for a request WITH a system prompt, not just any request
+		var lastReq *llm.Request
+		for i := 0; i < 50; i++ {
+			lastReq = predictableService.GetLastRequest()
+			if lastReq != nil && len(lastReq.System) > 0 {
+				break
+			}
+			time.Sleep(100 * time.Millisecond)
+		}
+		if lastReq == nil {
+			t.Fatal("No request was sent to the LLM service after 5 seconds")
+		}
+
+		if len(lastReq.System) == 0 {
+			t.Fatal("System prompt was not included in the LLM request")
+		}
+
+		// Verify system prompt contains expected content
+		systemText := ""
+		for _, sys := range lastReq.System {
+			systemText += sys.Text
+		}
+		if !strings.Contains(systemText, "Shelley") {
+			t.Errorf("System prompt doesn't contain 'Shelley': %s", systemText)
+		}
+		if !strings.Contains(systemText, "coding agent") {
+			t.Errorf("System prompt doesn't contain 'coding agent': %s", systemText)
+		}
+
+		t.Logf("System prompt successfully sent (length: %d chars)", len(systemText))
+	})
+
+	// Test 2: Send second message in existing conversation
+	t.Run("SubsequentMessage", func(t *testing.T) {
+		predictableService.ClearRequests()
+
+		// Create conversation first
+		chatReq := map[string]interface{}{
+			"message": "Hello",
+			"model":   "predictable",
+		}
+		body, _ := json.Marshal(chatReq)
+		resp, err := http.Post(ts.URL+"/api/conversations/new", "application/json", bytes.NewBuffer(body))
+		if err != nil {
+			t.Fatalf("Failed to send first message: %v", err)
+		}
+		defer resp.Body.Close()
+
+		var createResp struct {
+			ConversationID string `json:"conversation_id"`
+		}
+		if resp.StatusCode != http.StatusCreated {
+			body, _ := io.ReadAll(resp.Body)
+			t.Fatalf("Expected status 201, got %d: %s", resp.StatusCode, body)
+		}
+		if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil {
+			body, _ := io.ReadAll(resp.Body)
+			t.Fatalf("Failed to decode response (status %d): %v, body: %s", resp.StatusCode, err, body)
+		}
+
+		conversationID := createResp.ConversationID
+
+		// Wait for first message to be processed
+		var firstReq *llm.Request
+		for i := 0; i < 50; i++ {
+			firstReq = predictableService.GetLastRequest()
+			if firstReq != nil {
+				break
+			}
+			time.Sleep(100 * time.Millisecond)
+		}
+		if firstReq == nil {
+			t.Fatal("First request was not sent to the LLM service after 5 seconds")
+		}
+
+		// Clear requests and send second message
+		predictableService.ClearRequests()
+
+		chatReq2 := map[string]interface{}{
+			"message": "what is the date",
+			"model":   "predictable",
+		}
+		body2, _ := json.Marshal(chatReq2)
+		resp2, err := http.Post(ts.URL+"/api/conversation/"+conversationID+"/chat", "application/json", bytes.NewBuffer(body2))
+		if err != nil {
+			t.Fatalf("Failed to send second message: %v", err)
+		}
+		defer resp2.Body.Close()
+
+		if resp2.StatusCode != http.StatusAccepted {
+			body, _ := io.ReadAll(resp2.Body)
+			t.Fatalf("Expected status 202, got %d: %s", resp2.StatusCode, body)
+		}
+
+		// Poll for second message to be processed
+		// We need to wait for a request WITH a system prompt, not just any request
+		var lastReq *llm.Request
+		for i := 0; i < 50; i++ {
+			lastReq = predictableService.GetLastRequest()
+			if lastReq != nil && len(lastReq.System) > 0 {
+				break
+			}
+			time.Sleep(100 * time.Millisecond)
+		}
+		if lastReq == nil {
+			t.Fatal("No request was sent to the LLM service after 5 seconds")
+		}
+
+		if len(lastReq.System) == 0 {
+			t.Fatal("System prompt was not included in subsequent LLM request")
+		}
+
+		// Verify system prompt contains expected content
+		systemText := ""
+		for _, sys := range lastReq.System {
+			systemText += sys.Text
+		}
+		if !strings.Contains(systemText, "Shelley") {
+			t.Errorf("System prompt doesn't contain 'Shelley' in subsequent request: %s", systemText)
+		}
+
+		t.Logf("System prompt successfully sent in subsequent message (length: %d chars)", len(systemText))
+	})
+}
+
+// inspectableLLMManager is a test helper that always returns the same predictable service
+type inspectableLLMManager struct {
+	predictableService *loop.PredictableService
+	logger             *slog.Logger
+}
+
+func (m *inspectableLLMManager) GetService(modelID string) (llm.Service, error) {
+	if modelID != "predictable" {
+		return nil, fmt.Errorf("unsupported model: %s", modelID)
+	}
+	return m.predictableService, nil
+}
+
+func (m *inspectableLLMManager) GetAvailableModels() []string {
+	return []string{"predictable"}
+}
+
+func (m *inspectableLLMManager) HasModel(modelID string) bool {
+	return modelID == "predictable"
+}
+
+func TestVersionEndpoint(t *testing.T) {
+	// Create temp DB-backed server
+	ctx := context.Background()
+	tempDB := t.TempDir() + "/version_test.db"
+	database, err := db.New(db.Config{DSN: tempDB})
+	if err != nil {
+		t.Fatalf("Failed to create database: %v", err)
+	}
+	defer database.Close()
+	if err := database.Migrate(ctx); err != nil {
+		t.Fatalf("Failed to migrate database: %v", err)
+	}
+
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
+	llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+	svr := server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "", "", nil)
+
+	mux := http.NewServeMux()
+	svr.RegisterRoutes(mux)
+	ts := httptest.NewServer(mux)
+	defer ts.Close()
+
+	// Request /version endpoint
+	resp, err := http.Get(ts.URL + "/version")
+	if err != nil {
+		t.Fatalf("GET /version failed: %v", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		b, _ := io.ReadAll(resp.Body)
+		t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(b))
+	}
+
+	if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
+		t.Fatalf("expected application/json, got %q", ct)
+	}
+
+	// Parse the response
+	var versionInfo struct {
+		Commit     string `json:"commit"`
+		CommitTime string `json:"commit_time"`
+		Modified   bool   `json:"modified"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
+		t.Fatalf("Failed to decode version info: %v", err)
+	}
+
+	t.Logf("Version info: %+v", versionInfo)
+}
+
+func TestScreenshotRouteServesImage(t *testing.T) {
+	// Create temp DB-backed server
+	ctx := context.Background()
+	tempDB := t.TempDir() + "/route_test.db"
+	database, err := db.New(db.Config{DSN: tempDB})
+	if err != nil {
+		t.Fatalf("Failed to create database: %v", err)
+	}
+	defer database.Close()
+	if err := database.Migrate(ctx); err != nil {
+		t.Fatalf("Failed to migrate database: %v", err)
+	}
+
+	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
+	llmManager := server.NewLLMServiceManager(&server.LLMConfig{Logger: logger}, nil)
+	svr := server.NewServer(database, llmManager, claudetool.ToolSetConfig{}, logger, true, "", "", "", nil)
+
+	mux := http.NewServeMux()
+	svr.RegisterRoutes(mux)
+	ts := httptest.NewServer(mux)
+	defer ts.Close()
+
+	// Create a fake screenshot file in the expected location
+	id := "testshot"
+	path := browse.GetScreenshotPath(id)
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		t.Fatalf("Failed to create screenshot dir: %v", err)
+	}
+	pngData := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic, minimal content
+	if err := os.WriteFile(path, pngData, 0o644); err != nil {
+		t.Fatalf("Failed to write screenshot: %v", err)
+	}
+	t.Cleanup(func() { _ = os.Remove(path) })
+
+	// Request the screenshot
+	resp, err := http.Get(ts.URL + "/api/read?path=" + url.QueryEscape(path))
+	if err != nil {
+		t.Fatalf("GET screenshot failed: %v", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		b, _ := io.ReadAll(resp.Body)
+		t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(b))
+	}
+	if ct := resp.Header.Get("Content-Type"); ct != "image/png" {
+		t.Fatalf("expected image/png, got %q", ct)
+	}
+	// Cache-Control should be set
+	if cc := resp.Header.Get("Cache-Control"); cc == "" {
+		t.Fatalf("expected Cache-Control header to be set")
+	}
+}

test_ci.sh πŸ”—

@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+echo "==> Running Shelley CI Tests"
+echo ""
+
+echo "==> Installing UI dependencies..."
+cd ui
+npm ci
+cd ..
+
+echo ""
+echo "==> Running TypeScript type check..."
+cd ui
+npm run type-check
+cd ..
+
+echo ""
+echo "==> Running ESLint..."
+cd ui
+npm run lint
+cd ..
+
+echo ""
+echo "==> Building UI..."
+cd ui
+npm run build
+cd ..
+
+echo ""
+echo "==> Running Go tests..."
+go test -v ./...
+
+echo ""
+echo "==> Running Playwright E2E tests..."
+cd ui
+npx playwright install --with-deps chromium
+npx playwright test
+cd ..
+
+echo ""
+echo "==> All Shelley tests passed! βœ“"

test_manual.sh πŸ”—

@@ -0,0 +1,72 @@
+#!/bin/bash
+
+# Manual test script for Shelley server
+# Usage: ./test_manual.sh [port]
+
+set -e
+
+PORT=${1:-8080}
+BASE_URL="http://localhost:$PORT"
+
+echo "=== Shelley Manual Test Script ==="
+echo "Testing server at $BASE_URL"
+echo
+
+# Function to make HTTP requests with better error handling
+make_request() {
+    local method=$1
+    local url=$2
+    local data=$3
+
+    echo "Making $method request to $url"
+    if [ -n "$data" ]; then
+        echo "Request body: $data"
+    fi
+
+    if [ -n "$data" ]; then
+        curl -s -X "$method" -H "Content-Type: application/json" -d "$data" "$url" || echo "Request failed"
+    else
+        curl -s -X "$method" "$url" || echo "Request failed"
+    fi
+
+    echo
+    echo "---"
+    echo
+}
+
+echo "1. Testing server health by listing conversations..."
+make_request "GET" "$BASE_URL/conversations"
+
+echo "2. Creating a test conversation..."
+echo "   Note: This test assumes a conversation exists. If not, create one via the database or modify the server to auto-create."
+echo
+
+echo "3. Testing with a sample conversation ID (replace with real ID)..."
+echo "   For a real test, first start the server, create a conversation via the database,"
+echo "   then use that conversation ID in the following requests."
+echo
+echo "   Example conversation creation (using sqlite3):"
+echo "   sqlite3 shelley.db \"INSERT INTO conversations (conversation_id, slug) VALUES ('test-123', 'manual-test');\""
+echo
+echo "   Then test chat:"
+echo "   curl -X POST -H 'Content-Type: application/json' -d '{\"message\": \"Hello, how are you?\"}' $BASE_URL/conversation/test-123/chat"
+echo
+echo "   And get messages:"
+echo "   curl $BASE_URL/conversation/test-123"
+echo
+echo "   And test streaming:"
+echo "   curl $BASE_URL/conversation/test-123/stream"
+echo
+
+echo "4. Instructions for testing with Anthropic API:"
+echo "   1. Set ANTHROPIC_API_KEY environment variable with a valid key"
+echo "   2. Start server: cd cmd/shelley && ./shelley --port=$PORT"
+echo "   3. Create a conversation and send messages as shown above"
+echo
+
+echo "5. Testing server responsiveness..."
+echo "   If server is running, this should return an empty conversations list:"
+make_request "GET" "$BASE_URL/conversations?limit=1"
+
+echo "=== Manual test complete ==="
+echo "For full testing with real conversations, use the commands shown above."

ui/e2e/README.md πŸ”—

@@ -0,0 +1,101 @@
+# Shelley E2E Tests with Playwright
+
+This directory contains end-to-end tests for the Shelley web interface using Playwright.
+
+## Features
+
+- **Mobile-focused testing**: Primary focus on mobile viewports (iPhone, Pixel)
+- **Predictable LLM**: Uses the predictable LLM model for deterministic testing
+- **Screenshot capture**: Automatic screenshot generation for visual inspection
+- **Tool testing**: Tests bash tool, think tool, and patch tool interactions
+- **Multi-browser support**: Tests across Chrome, Firefox, Safari, and mobile variants
+
+## Running Tests
+
+### Install Dependencies
+```bash
+cd ui/
+npm install
+npx playwright install
+```
+
+### Run All Tests
+```bash
+npm run test:e2e
+```
+
+### Run Specific Tests
+```bash
+# Run only mobile Chrome tests
+npm run test:e2e -- --project="Mobile Chrome"
+
+# Run specific test
+npm run test:e2e -- --grep "should load the main page"
+
+# Run with headed browser (visible)
+npm run test:e2e:headed
+
+# Open UI mode
+npm run test:e2e:ui
+```
+
+### Debug Failed Tests
+```bash
+# View HTML report
+npx playwright show-report
+
+# View screenshots
+ls -la test-results/*/
+```
+
+## Test Structure
+
+### Basic Interactions (`basic-interactions.spec.ts`)
+- Page loading
+- Starting conversations
+- Tool usage
+- Conversation history
+- Responsive design
+
+### Mobile-Focused Tests (`mobile-focused.spec.ts`)
+- Mobile layout verification
+- Touch interactions
+- Text input on mobile
+- Scrolling behavior
+- Mobile-specific UI patterns
+
+### Predictable Behavior (`predictable-behavior.spec.ts`)
+- Deterministic LLM responses
+- Tool interaction patterns
+- Error handling
+- Multi-turn conversations
+
+## Screenshot Inspection
+
+Screenshots are automatically saved in `test-results/` directory:
+- Failed tests: Screenshots at failure point
+- All tests: Screenshots at key interaction points
+- Mobile-optimized: Focus on mobile viewport sizes
+
+## Predictable LLM
+
+The tests use Shelley's predictable LLM model which provides:
+- Consistent responses for the same inputs
+- Deterministic tool usage
+- Predictable conversation flows
+- Special test commands (`echo`, `error`, `tool`)
+
+## Configuration
+
+Playwright configuration is in `playwright.config.ts`:
+- Auto-starts Shelley server with predictable model
+- Configures mobile-first viewports
+- Sets up screenshot and video capture
+- Handles test timeouts and retries
+
+## Tips
+
+1. **Mobile First**: Most tests are designed for mobile viewports
+2. **Screenshots**: Check `e2e/screenshots/` for visual debugging
+3. **Deterministic**: All tests should be repeatable and deterministic
+4. **Fast Feedback**: Tests are designed to fail fast with meaningful errors

ui/e2e/cancellation.spec.ts πŸ”—

@@ -0,0 +1,141 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Conversation Cancellation', () => {
+  test('should cancel long-running command and show cancelled state after reload', async ({ page }) => {
+    // Start the server and navigate to it
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+
+    // Wait for the message input
+    const input = page.getByTestId('message-input');
+    await expect(input).toBeVisible({ timeout: 30000 });
+
+    // Send a command that will take a long time (sleep 100 seconds)
+    await input.fill('bash: sleep 100');
+
+    const sendButton = page.getByTestId('send-button');
+    await expect(sendButton).toBeVisible();
+    await sendButton.click();
+
+    // Wait for the agent to start working (thinking indicator appears)
+    await expect(page.locator('[data-testid="agent-thinking"]')).toBeVisible({ timeout: 10000 });
+
+    // Wait a bit for the tool to actually start executing
+    await page.waitForTimeout(500);
+
+    // Verify the cancel button appears when agent is working
+    const cancelButton = page.locator('button:has-text("Cancel")');
+    await expect(cancelButton).toBeVisible();
+
+    // Click the cancel button
+    await cancelButton.click();
+
+    // Wait for cancellation to complete (button should show "Cancelling..." then disappear)
+    await expect(page.locator('button:has-text("Cancelling...")')).toBeVisible({ timeout: 2000 });
+    await expect(cancelButton).not.toBeVisible({ timeout: 5000 });
+
+    // Verify the thinking indicator is gone
+    await expect(page.locator('[data-testid="agent-thinking"]')).not.toBeVisible({ timeout: 5000 });
+
+    // Verify we see the cancelled tool result
+    await expect(page.locator('text=/cancelled/i')).toBeVisible({ timeout: 5000 });
+
+    // Verify we see the [Operation cancelled] message
+    await expect(page.locator('text=/\\[Operation cancelled\\]/i')).toBeVisible({ timeout: 5000 });
+
+    // Now reload the page to verify state is preserved
+    await page.reload();
+    await page.waitForLoadState('domcontentloaded');
+
+    // After reload, the agent should NOT be working
+    await expect(page.locator('[data-testid="agent-thinking"]')).not.toBeVisible({ timeout: 2000 });
+
+    // Cancel button should not be visible
+    await expect(page.locator('button:has-text("Cancel")')).not.toBeVisible();
+
+    // The cancelled messages should still be visible
+    await expect(page.locator('text=/cancelled/i')).toBeVisible();
+    await expect(page.locator('text=/\\[Operation cancelled\\]/i')).toBeVisible();
+
+    // Verify we can continue the conversation after cancellation
+    await input.fill('echo: test after cancel');
+    await input.press('Enter');
+
+    // Agent should start working again
+    await expect(page.locator('[data-testid="agent-thinking"]')).toBeVisible({ timeout: 5000 });
+
+    // Should get a response
+    await expect(page.locator('text=test after cancel')).toBeVisible({ timeout: 10000 });
+
+    // Agent should stop working
+    await expect(page.locator('[data-testid="agent-thinking"]')).not.toBeVisible({ timeout: 5000 });
+  });
+
+  test('should cancel without tool execution (text generation)', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+
+    const input = page.getByTestId('message-input');
+    await expect(input).toBeVisible({ timeout: 30000 });
+
+    // Send a command that triggers a delay in text generation
+    await input.fill('delay: 5');
+
+    const sendButton = page.getByTestId('send-button');
+    await sendButton.click();
+
+    // Wait for agent to start working
+    await expect(page.locator('[data-testid="agent-thinking"]')).toBeVisible({ timeout: 5000 });
+
+    // Wait a moment then cancel
+    await page.waitForTimeout(500);
+
+    const cancelButton = page.locator('button:has-text("Cancel")');
+    await expect(cancelButton).toBeVisible();
+    await cancelButton.click();
+
+    // Wait for cancellation
+    await expect(cancelButton).not.toBeVisible({ timeout: 5000 });
+    await expect(page.locator('[data-testid="agent-thinking"]')).not.toBeVisible({ timeout: 5000 });
+
+    // Reload and verify agent is not working
+    await page.reload();
+    await page.waitForLoadState('domcontentloaded');
+    await expect(page.locator('[data-testid="agent-thinking"]')).not.toBeVisible({ timeout: 2000 });
+  });
+
+  test('should show correct state without reload', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+
+    const input = page.getByTestId('message-input');
+    await expect(input).toBeVisible({ timeout: 30000 });
+
+    // Send a long-running command
+    await input.fill('bash: sleep 50');
+
+    const sendButton = page.getByTestId('send-button');
+    await sendButton.click();
+
+    // Wait for agent to start working
+    await expect(page.locator('[data-testid="agent-thinking"]')).toBeVisible({ timeout: 10000 });
+    await page.waitForTimeout(500);
+
+    // Cancel
+    const cancelButton = page.locator('button:has-text("Cancel")');
+    await cancelButton.click();
+
+    // Agent should stop working immediately (without reload)
+    await expect(page.locator('[data-testid="agent-thinking"]')).not.toBeVisible({ timeout: 5000 });
+    await expect(cancelButton).not.toBeVisible();
+
+    // Should be able to send another message immediately
+    await input.fill('echo: after cancel');
+
+    const sendButton2 = page.getByTestId('send-button');
+    await sendButton2.click();
+
+    // Wait for response - use .first() to handle multiple matches
+    await expect(page.locator('text=after cancel').first()).toBeVisible({ timeout: 10000 });
+  });
+});

ui/e2e/conversation.spec.ts πŸ”—

@@ -0,0 +1,464 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Shelley Conversation Tests', () => {
+  test('can send Hello and get greeting response', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    // Wait for the message input using improved selector
+    const messageInput = page.getByTestId('message-input');
+    await expect(messageInput).toBeVisible({ timeout: 30000 });
+    
+    // Send "Hello" and expect specific predictable response
+    await messageInput.fill('Hello');
+    
+    // Find and click the send button using improved selector
+    const sendButton = page.getByTestId('send-button');
+    await expect(sendButton).toBeVisible();
+    await sendButton.click();
+    
+    // Wait for the response from the predictable model
+    // The predictable model responds to "Hello" with "Hello! I'm Shelley, your AI assistant. How can I help you today?"
+    await page.waitForFunction(
+      () => {
+        const text = "Hello! I'm Shelley, your AI assistant. How can I help you today?";
+        return document.body.textContent?.includes(text) ?? false;
+      },
+      undefined,
+      { timeout: 30000 }
+    );
+    
+    // Verify both the user message and assistant response are visible
+    await expect(page.locator('text=Hello').first()).toBeVisible();
+    await expect(page.locator('text=Hello! I\'m Shelley, your AI assistant. How can I help you today?').first()).toBeVisible();
+  });
+  
+  test('can use echo command', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+    
+    // Send "echo: test message" and expect echo response
+    await messageInput.fill('echo: test message');
+    await sendButton.click();
+    
+    // The predictable model should echo back "test message"
+    await page.waitForFunction(
+      () => document.body.textContent?.includes('test message') ?? false,
+      undefined,
+      { timeout: 30000 }
+    );
+    
+    // Verify both input and output messages are visible
+    await expect(page.locator('text=echo: test message')).toBeVisible();
+  });
+  
+  test('responds differently to lowercase hello', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+
+    // Send "hello" (lowercase) and expect different response
+    await messageInput.fill('hello');
+    await sendButton.click();
+
+    // The predictable model responds to "hello" with "Well, hi there!"
+    await page.waitForFunction(
+      () => document.body.textContent?.includes('Well, hi there!') ?? false,
+      undefined,
+      { timeout: 30000 }
+    );
+
+    // Verify the hello message and response are both visible
+    await expect(page.getByText('Well, hi there!').first()).toBeVisible();
+  });
+
+  test('shows thinking indicator while awaiting response', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+
+    await messageInput.fill('hello');
+    await sendButton.click();
+
+    const thinkingIndicator = page.getByTestId('agent-thinking');
+    await expect(thinkingIndicator).toBeVisible({ timeout: 2000 });
+
+    await page.waitForFunction(
+      () => document.body.textContent?.includes('Well, hi there!') ?? false,
+      undefined,
+      { timeout: 30000 }
+    );
+
+    await expect(thinkingIndicator).toBeHidden({ timeout: 10000 });
+  });
+
+  test('shows thinking indicator on follow-up messages', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+
+    await messageInput.fill('hello');
+    await sendButton.click();
+
+    await page.waitForFunction(
+      () => document.body.textContent?.includes('Well, hi there!') ?? false,
+      undefined,
+      { timeout: 30000 }
+    );
+
+    await messageInput.fill('echo: follow up');
+    await sendButton.click();
+
+    const thinkingIndicator = page.getByTestId('agent-thinking');
+    await expect(thinkingIndicator).toBeVisible({ timeout: 2000 });
+
+    await page.waitForFunction(
+      () => document.body.textContent?.includes('follow up') ?? false,
+      undefined,
+      { timeout: 30000 }
+    );
+
+    await expect(thinkingIndicator).toBeHidden({ timeout: 10000 });
+  });
+  
+  test('can use bash tool', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+    
+    // Send a message that triggers tool use
+    await messageInput.fill('bash: echo "hello world"');
+    await sendButton.click();
+    
+    // The predictable model should use the bash tool and show the response
+    await page.waitForFunction(
+      () => {
+        const text = 'I\'ll run the command: echo "hello world"';
+        return document.body.textContent?.includes(text) ?? false;
+      },
+      undefined,
+      { timeout: 30000 }
+    );
+    
+    // Verify tool usage appears in the UI with coalesced tool call
+    await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 10000 });
+    // Check that the tool name "bash" is visible
+    await expect(page.locator('text=bash').first()).toBeVisible();
+  });
+  
+  test('gives default response for undefined messages', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+    
+    // Send an undefined message and expect default response
+    await messageInput.fill('this is an undefined message');
+    await sendButton.click();
+    
+    // The predictable model responds to undefined inputs with "edit predictable.go to add a response for that one..."
+    await page.waitForFunction(
+      () => {
+        const text = 'edit predictable.go to add a response for that one...';
+        return document.body.textContent?.includes(text) ?? false;
+      },
+      undefined,
+      { timeout: 30000 }
+    );
+    
+    // Verify the undefined message and default response are visible
+    await expect(page.locator('text=this is an undefined message')).toBeVisible();
+  });
+  
+  test('conversation persists and displays correctly', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+    
+    // Send first message
+    await messageInput.fill('Hello');
+    await sendButton.click();
+    
+    // Wait for first response
+    await page.waitForFunction(
+      () => {
+        const text = "Hello! I'm Shelley, your AI assistant. How can I help you today?";
+        return document.body.textContent?.includes(text) ?? false;
+      },
+      undefined,
+      { timeout: 30000 }
+    );
+    
+    // Send second message
+    await messageInput.fill('echo: second message');
+    await sendButton.click();
+    
+    // Wait for second response
+    await page.waitForFunction(
+      () => document.body.textContent?.includes('second message') ?? false,
+      undefined,
+      { timeout: 30000 }
+    );
+    
+    // Verify both responses are still visible (conversation persists)
+    await expect(page.locator('text=Hello! I\'m Shelley, your AI assistant. How can I help you today?').first()).toBeVisible();
+    await expect(page.locator('text=second message').first()).toBeVisible();
+  });
+  
+  test('can send message with Enter key', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    const messageInput = page.getByTestId('message-input');
+    await expect(messageInput).toBeVisible({ timeout: 30000 });
+    
+    // Type message and press Enter
+    await messageInput.fill('Hello');
+    await messageInput.press('Enter');
+    
+    // Verify response
+    await page.waitForFunction(
+      () => {
+        const text = "Hello! I'm Shelley, your AI assistant. How can I help you today?";
+        return document.body.textContent?.includes(text) ?? false;
+      },
+      undefined,
+      { timeout: 30000 }
+    );
+    
+    // Verify the Hello message and response are visible
+    await expect(page.locator('text=Hello! I\'m Shelley, your AI assistant. How can I help you today?').first()).toBeVisible();
+  });
+  
+  test('handles think tool correctly', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+    
+    // Send a message that triggers think tool
+    await messageInput.fill('think: I need to analyze this problem');
+    await sendButton.click();
+    
+    // The predictable model should use the think tool
+    await page.waitForFunction(
+      () => document.body.textContent?.includes('Let me think about this.') ?? false,
+      undefined,
+      { timeout: 30000 }
+    );
+    
+    // Verify think tool usage appears in the UI
+    await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 10000 });
+    await expect(page.locator('text=think').first()).toBeVisible();
+  });
+  
+  test('handles patch tool correctly', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+    
+    // Send a message that triggers patch tool
+    await messageInput.fill('patch: test.txt');
+    await sendButton.click();
+    
+    // The predictable model should use the patch tool
+    await page.waitForFunction(
+      () => document.body.textContent?.includes('I\'ll patch the file: test.txt') ?? false,
+      undefined,
+      { timeout: 30000 }
+    );
+    
+    // Verify patch tool usage appears in the UI
+    await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 10000 });
+    await expect(page.locator('text=patch').first()).toBeVisible();
+  });
+  
+  test('displays tool results with collapsible details', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+    
+    // Send a bash command that will show tool results
+    await messageInput.fill('bash: echo "testing tool results"');
+    await sendButton.click();
+    
+    // Wait for the tool call to appear
+    await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 30000 });
+
+    // Check for bash tool header (collapsible element)
+    const bashToolHeader = page.locator('.bash-tool-header');
+    await expect(bashToolHeader.first()).toBeVisible({ timeout: 10000 });
+  });
+  
+  test('handles multiple consecutive tool calls', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+    
+    // First tool call: bash
+    await messageInput.fill('bash: echo "first command"');
+    await sendButton.click();
+    
+    await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 30000 });
+    
+    // Second tool call: think
+    await messageInput.fill('think: analyzing the output');
+    await sendButton.click();
+    
+    // Wait for at least 2 tool calls
+    await page.waitForFunction(
+      () => document.querySelectorAll('[data-testid="tool-call-completed"]').length >= 2,
+      undefined,
+      { timeout: 30000 }
+    );
+    
+    // Third tool call: patch
+    await messageInput.fill('patch: example.txt');
+    await sendButton.click();
+    
+    // Wait for at least 3 tool calls
+    await page.waitForFunction(
+      () => document.querySelectorAll('[data-testid="tool-call-completed"]').length >= 3,
+      undefined,
+      { timeout: 30000 }
+    );
+    
+    // Verify all the specific messages we sent are visible
+    await expect(page.locator('text=bash: echo "first command"')).toBeVisible();
+    await expect(page.locator('text=think: analyzing the output')).toBeVisible();
+    await expect(page.locator('text=patch: example.txt')).toBeVisible();
+    
+    // Verify all tool types are visible
+    await expect(page.locator('text=bash').first()).toBeVisible();
+    await expect(page.locator('text=think').first()).toBeVisible();
+    await expect(page.locator('text=patch').first()).toBeVisible();
+  });
+});
+
+  test('coalesces tool calls - shows tool result with details', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+    
+    // Send a bash command to trigger tool use
+    await messageInput.fill('bash: echo "hello world"');
+    await sendButton.click();
+    
+    // Wait for the tool result to appear
+    await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 30000 });
+    
+    // Verify the bash tool header is visible
+    await expect(page.locator('.bash-tool-header').first()).toBeVisible();
+
+    // Verify bash tool shows command
+    await expect(page.locator('.bash-tool-command').first()).toBeVisible();
+  });
+  
+  test('coalesces tool calls - displays agent text and tool separately', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+    
+    // Send a bash command
+    await messageInput.fill('bash: pwd');
+    await sendButton.click();
+    
+    // Wait for tool result
+    await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 30000 });
+    
+    // Verify agent message is shown ("I'll run the command: pwd")
+    await expect(page.locator('text=I\'ll run the command: pwd').first()).toBeVisible();
+    
+    // Verify tool result is shown separately as coalesced tool call
+    await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible();
+    await expect(page.locator('text=bash').first()).toBeVisible();
+  });
+  
+  test('handles sequential tool calls', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+    
+    // First tool call
+    await messageInput.fill('bash: echo "first"');
+    await sendButton.click();
+    await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 30000 });
+    
+    // Second tool call
+    await messageInput.fill('bash: echo "second"');
+    await sendButton.click();
+    
+    // Wait for the second tool result
+    await page.waitForFunction(
+      () => document.querySelectorAll('[data-testid="tool-call-completed"]').length >= 2,
+      undefined,
+      { timeout: 30000 }
+    );
+    
+    // Verify both tool calls are displayed
+    const toolCalls = page.locator('[data-testid="tool-call-completed"]');
+    expect(await toolCalls.count()).toBeGreaterThanOrEqual(2);
+  });
+
+  test('displays LLM error message in UI', async ({ page }) => {
+    // Clear any existing data by navigating to root (which should show empty state)
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    // Wait for the empty state or message input
+    const messageInput = page.getByTestId('message-input');
+    await expect(messageInput).toBeVisible({ timeout: 30000 });
+    
+    const sendButton = page.getByTestId('send-button');
+    
+    // Send a message that triggers an error in the predictable LLM
+    await messageInput.fill('error: test error message');
+    await sendButton.click();
+    
+    // Wait for the error message to appear in the UI
+    await page.waitForFunction(
+      () => {
+        const text = 'LLM request failed: predictable error: test error message';
+        return document.body.textContent?.includes(text) ?? false;
+      },
+      undefined,
+      { timeout: 30000 }
+    );
+    
+    // Verify error message is visible with error styling
+    const errorMessage = page.locator('[role="alert"]');
+    await expect(errorMessage).toBeVisible({ timeout: 10000 });
+    
+    // Verify the error text is displayed
+    await expect(page.locator('text=LLM request failed: predictable error: test error message')).toBeVisible();
+    
+    // Verify error label is shown in the message header
+    await expect(page.locator('[role="alert"]').locator('text=Error')).toBeVisible();
+  });

ui/e2e/file-upload.spec.ts πŸ”—

@@ -0,0 +1,198 @@
+import { test, expect } from '@playwright/test';
+import * as path from 'path';
+import * as fs from 'fs';
+import * as os from 'os';
+
+test.describe('File Upload via Paste and Drag', () => {
+  let testImagePath: string;
+
+  test.beforeAll(async () => {
+    // Create a minimal valid PNG file for testing
+    const pngHeader = Buffer.from([
+      0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
+      0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk length and type
+      0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 dimensions
+      0x08, 0x02, 0x00, 0x00, 0x00, // 8-bit RGB
+      0x90, 0x77, 0x53, 0xde, // CRC
+      0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, // IDAT chunk
+      0x08, 0xd7, 0x63, 0xf8, 0xff, 0xff, 0x3f, 0x00,
+      0x05, 0xfe, 0x02, 0xfe,
+      0xa3, 0x6c, 0x9e, 0x15, // CRC
+      0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, // IEND chunk
+      0xae, 0x42, 0x60, 0x82, // CRC
+    ]);
+
+    testImagePath = path.join(os.tmpdir(), 'test-image.png');
+    fs.writeFileSync(testImagePath, pngHeader);
+  });
+
+  test.afterAll(async () => {
+    // Clean up test image
+    if (testImagePath && fs.existsSync(testImagePath)) {
+      fs.unlinkSync(testImagePath);
+    }
+  });
+
+  test('shows drop overlay when dragging file over input container', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+
+    const inputContainer = page.locator('.message-input-container');
+    await expect(inputContainer).toBeVisible();
+
+    // Start a drag operation
+    // Unfortunately we can't actually simulate file drag in Playwright directly,
+    // but we can test that the drag-over class is applied correctly via JavaScript
+
+    // Inject a file drag event
+    await page.evaluate(() => {
+      const container = document.querySelector('.message-input-container');
+      if (container) {
+        const dragEnterEvent = new DragEvent('dragenter', {
+          bubbles: true,
+          cancelable: true,
+          dataTransfer: new DataTransfer()
+        });
+        container.dispatchEvent(dragEnterEvent);
+      }
+    });
+
+    // Check that the overlay appears
+    const overlay = page.locator('.drag-overlay');
+    await expect(overlay).toBeVisible();
+    await expect(overlay).toContainText('Drop files here');
+
+    // Dispatch drag leave to hide the overlay
+    await page.evaluate(() => {
+      const container = document.querySelector('.message-input-container');
+      if (container) {
+        const dragLeaveEvent = new DragEvent('dragleave', {
+          bubbles: true,
+          cancelable: true,
+          dataTransfer: new DataTransfer()
+        });
+        container.dispatchEvent(dragLeaveEvent);
+      }
+    });
+
+    // Overlay should be hidden now
+    await expect(overlay).toBeHidden();
+  });
+
+  test('upload endpoint accepts files and returns path', async ({ page, request }) => {
+    // Test the upload endpoint directly
+    const testContent = 'test file content';
+    const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2);
+
+    const body = [
+      `--${boundary}`,
+      'Content-Disposition: form-data; name="file"; filename="test.txt"',
+      'Content-Type: text/plain',
+      '',
+      testContent,
+      `--${boundary}--`,
+      ''
+    ].join('\r\n');
+
+    const response = await request.post('/api/upload', {
+      headers: {
+        'Content-Type': `multipart/form-data; boundary=${boundary}`
+      },
+      data: Buffer.from(body)
+    });
+
+    expect(response.status()).toBe(200);
+    const json = await response.json();
+    expect(json.path).toBeDefined();
+    expect(json.path).toContain('/tmp/shelley-screenshots/');
+    expect(json.path).toContain('.txt');
+  });
+
+  test('uploaded file can be read via /api/read endpoint', async ({ request }) => {
+    // First upload a file
+    const testContent = 'hello from test';
+    const boundary = '----TestBoundary';
+
+    const body = [
+      `--${boundary}`,
+      'Content-Disposition: form-data; name="file"; filename="readable.txt"',
+      'Content-Type: text/plain',
+      '',
+      testContent,
+      `--${boundary}--`,
+      ''
+    ].join('\r\n');
+
+    const uploadResponse = await request.post('/api/upload', {
+      headers: {
+        'Content-Type': `multipart/form-data; boundary=${boundary}`
+      },
+      data: Buffer.from(body)
+    });
+
+    expect(uploadResponse.status()).toBe(200);
+    const { path: filePath } = await uploadResponse.json();
+
+    // Now read the file via the read endpoint
+    const readResponse = await request.get(`/api/read?path=${encodeURIComponent(filePath)}`);
+    expect(readResponse.status()).toBe(200);
+
+    const content = await readResponse.text();
+    expect(content).toBe(testContent);
+  });
+
+  test('message input accepts text input normally', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+
+    const messageInput = page.getByTestId('message-input');
+    await messageInput.fill('Hello, this is a test message');
+
+    await expect(messageInput).toHaveValue('Hello, this is a test message');
+  });
+
+  test('simulated file drop shows loading placeholder then file path', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+
+    const messageInput = page.getByTestId('message-input');
+    await expect(messageInput).toBeVisible();
+
+    // Simulate file drop by calling the internal uploadFile function via eval
+    // We'll create a mock file and dispatch events
+    await page.evaluate(async () => {
+      const input = document.querySelector('[data-testid="message-input"]') as HTMLTextAreaElement;
+      if (!input) return;
+
+      // Create a simple file
+      const blob = new Blob(['test content'], { type: 'text/plain' });
+      const file = new File([blob], 'test-drop.txt', { type: 'text/plain' });
+
+      // Create a DataTransfer with the file
+      const dataTransfer = new DataTransfer();
+      dataTransfer.items.add(file);
+
+      // Create and dispatch drop event
+      const dropEvent = new DragEvent('drop', {
+        bubbles: true,
+        cancelable: true,
+        dataTransfer: dataTransfer
+      });
+
+      const container = document.querySelector('.message-input-container');
+      if (container) {
+        container.dispatchEvent(dropEvent);
+      }
+    });
+
+    // Wait for the upload to complete (should show loading then path)
+    await page.waitForTimeout(500);
+
+    // After upload, the input should contain a file path reference
+    const inputValue = await messageInput.inputValue();
+
+    // Either the file was uploaded successfully (contains path) or there was an error
+    // Both are acceptable as we're testing the UI flow
+    expect(inputValue).toBeTruthy();
+  });
+});

ui/e2e/scroll-behavior.spec.ts πŸ”—

@@ -0,0 +1,63 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Scroll behavior', () => {
+  test('shows scroll-to-bottom button when scrolled up, auto-scrolls when at bottom', async ({ page }) => {
+    // Navigate to app
+    await page.goto('http://localhost:9000');
+    
+    // Wait for the app to load
+    await page.waitForSelector('[data-testid="message-input"]');
+    
+    // Send multiple messages to create scrollable content
+    const input = page.locator('[data-testid="message-input"]');
+    const sendButton = page.locator('[data-testid="send-button"]');
+    
+    // Send a message that generates multiple tool calls to create enough content
+    await input.fill('tool bash ls');
+    await sendButton.click();
+    
+    // Wait for agent to finish
+    await page.waitForSelector('[data-testid="agent-thinking"]', { state: 'hidden', timeout: 10000 });
+    
+    // Send more messages to ensure we have scrollable content
+    for (let i = 0; i < 3; i++) {
+      await input.fill(`echo message ${i}`);
+      await sendButton.click();
+      await page.waitForSelector('[data-testid="agent-thinking"]', { state: 'hidden', timeout: 10000 });
+    }
+    
+    // Get the messages container
+    const messagesContainer = page.locator('.messages-container');
+    
+    // Scroll up to the top
+    await messagesContainer.evaluate((el) => {
+      el.scrollTop = 0;
+    });
+    
+    // Wait a moment for scroll event to be processed
+    await page.waitForTimeout(200);
+    
+    // Verify scroll-to-bottom button appears
+    const scrollButton = page.locator('.scroll-to-bottom-button');
+    await expect(scrollButton).toBeVisible();
+    
+    // Click the button
+    await scrollButton.click();
+    
+    // Wait for scroll animation
+    await page.waitForTimeout(500);
+    
+    // Button should disappear
+    await expect(scrollButton).not.toBeVisible();
+    
+    // Send another message - should auto-scroll since we're at bottom
+    await input.fill('echo final message');
+    await sendButton.click();
+    
+    // Wait for response
+    await page.waitForSelector('[data-testid="agent-thinking"]', { timeout: 5000 });
+    
+    // Button should not appear since we're following the conversation
+    await expect(scrollButton).not.toBeVisible();
+  });
+});

ui/e2e/smoke.spec.ts πŸ”—

@@ -0,0 +1,74 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Shelley Smoke Tests', () => {
+  test('page loads successfully', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    // Just verify the page loads with a title
+    const title = await page.title();
+    expect(title).toBe('Shelley');
+  });
+
+  test('can find message input with proper aria label', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    // Find the textarea using improved selectors
+    const messageInput = page.getByTestId('message-input');
+    await expect(messageInput).toBeVisible();
+    
+    // Verify it has proper aria labeling
+    await expect(messageInput).toHaveAttribute('aria-label', 'Message input');
+  });
+
+  test('can find send button with proper aria label', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    // Find the send button using improved selectors
+    const sendButton = page.getByTestId('send-button');
+    await expect(sendButton).toBeVisible();
+    
+    // Verify it has proper aria labeling
+    await expect(sendButton).toHaveAttribute('aria-label', 'Send message');
+  });
+  
+  test('message input is initially empty and focused', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    const messageInput = page.getByTestId('message-input');
+    await expect(messageInput).toBeVisible();
+    
+    // Verify input is empty initially
+    await expect(messageInput).toHaveValue('');
+    
+    // Verify placeholder text is present
+    await expect(messageInput).toHaveAttribute('placeholder', /Type your message/);
+  });
+  
+  test('send button is disabled when input is empty', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    const sendButton = page.getByTestId('send-button');
+    
+    // Button should be disabled initially
+    await expect(sendButton).toBeDisabled();
+  });
+  
+  test('send button becomes enabled when text is entered', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+    
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+    
+    // Enter some text
+    await messageInput.fill('test message');
+    
+    // Button should now be enabled
+    await expect(sendButton).toBeEnabled();
+  });
+});

ui/e2e/tool-components.spec.ts πŸ”—

@@ -0,0 +1,178 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Tool Component Verification', () => {
+  test('all tools use custom components, not GenericTool', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+
+    // Send the tool smorgasbord message to trigger all tool types
+    await messageInput.fill('tool smorgasbord');
+    await sendButton.click();
+
+    // Wait for the response text to appear
+    await page.waitForFunction(
+      () => document.body.textContent?.includes('Here\'s a sample of all the tools:') ?? false,
+      undefined,
+      { timeout: 30000 }
+    );
+
+    // Wait for all tool calls to complete
+    await page.waitForFunction(
+      () => document.querySelectorAll('[data-testid="tool-call-completed"]').length >= 9,
+      undefined,
+      { timeout: 30000 }
+    );
+
+    // Verify bash tool uses BashTool component (has bash-tool class)
+    const bashTool = page.locator('.bash-tool').first();
+    await expect(bashTool).toBeVisible();
+    await expect(bashTool.locator('.bash-tool-emoji')).toBeVisible();
+    await expect(bashTool.locator('.bash-tool-command')).toBeVisible();
+
+    // Verify think tool uses ThinkTool component (has tool class with think emoji)
+    const thinkTool = page.locator('.tool').filter({ hasText: 'I\'m thinking about the best approach' });
+    await expect(thinkTool.first()).toBeVisible();
+    await expect(thinkTool.locator('.tool-emoji').filter({ hasText: 'πŸ’­' }).first()).toBeVisible();
+
+    // Verify patch tool uses PatchTool component (has patch-tool class)
+    const patchTool = page.locator('.patch-tool').first();
+    await expect(patchTool).toBeVisible();
+    await expect(patchTool.locator('.patch-tool-emoji')).toBeVisible();
+
+    // Verify screenshot tool uses ScreenshotTool component (has screenshot-tool class)
+    const screenshotTool = page.locator('.screenshot-tool').first();
+    await expect(screenshotTool).toBeVisible();
+    await expect(screenshotTool.locator('.screenshot-tool-emoji').filter({ hasText: 'πŸ“·' })).toBeVisible();
+
+    // Verify keyword_search tool uses KeywordSearchTool component (has tool class with search emoji)
+    const keywordTool = page.locator('.tool').filter({ hasText: 'find all references' });
+    await expect(keywordTool.first()).toBeVisible();
+    await expect(keywordTool.locator('.tool-emoji').filter({ hasText: 'πŸ”' }).first()).toBeVisible();
+
+    // Verify browser_navigate tool uses BrowserNavigateTool component (has tool class with globe emoji and URL)
+    const navigateTool = page.locator('.tool').filter({ hasText: 'https://example.com' });
+    await expect(navigateTool.first()).toBeVisible();
+    await expect(navigateTool.locator('.tool-emoji').filter({ hasText: '🌐' }).first()).toBeVisible();
+
+    // Verify browser_eval tool uses BrowserEvalTool component (has tool class with lightning emoji)
+    const evalTool = page.locator('.tool').filter({ hasText: 'document.title' });
+    await expect(evalTool.first()).toBeVisible();
+    await expect(evalTool.locator('.tool-emoji').filter({ hasText: '⚑' }).first()).toBeVisible();
+
+    // Verify read_image tool uses ReadImageTool component (has screenshot-tool class with frame emoji)
+    const readImageTool = page.locator('.screenshot-tool').filter({ hasText: '/tmp/image.png' });
+    await expect(readImageTool.first()).toBeVisible();
+    await expect(readImageTool.locator('.screenshot-tool-emoji').filter({ hasText: 'πŸ–ΌοΈ' }).first()).toBeVisible();
+
+    // Verify browser_recent_console_logs tool uses BrowserConsoleLogsTool component (has tool class with clipboard emoji)
+    const consoleTool = page.locator('.tool').filter({ hasText: 'console logs' });
+    await expect(consoleTool.first()).toBeVisible();
+    await expect(consoleTool.locator('.tool-emoji').filter({ hasText: 'πŸ“‹' }).first()).toBeVisible();
+
+    // CRITICAL: Verify that GenericTool (gear emoji βš™οΈ) is NOT used for any of these tools
+    // We check that NO tool has the generic gear icon
+    const genericToolGearEmojis = page.locator('.tool-emoji').filter({ hasText: 'βš™οΈ' });
+    expect(await genericToolGearEmojis.count()).toBe(0);
+  });
+
+  test('bash tool shows command in header', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+
+    await messageInput.fill('bash: unique-test-command-xyz123');
+    await sendButton.click();
+
+    // Wait for and verify the specific bash tool we just created
+    await page.waitForFunction(
+      () => document.body.textContent?.includes('unique-test-command-xyz123') ?? false,
+      undefined,
+      { timeout: 30000 }
+    );
+
+    // Verify bash tool shows the command in the header (collapsed state)
+    const bashToolWithOurCommand = page.locator('.bash-tool').filter({ hasText: 'unique-test-command-xyz123' });
+    await expect(bashToolWithOurCommand).toBeVisible();
+    const commandElement = bashToolWithOurCommand.locator('.bash-tool-command');
+    await expect(commandElement).toBeVisible();
+    const commandText = await commandElement.textContent();
+    expect(commandText).toContain('unique-test-command-xyz123');
+  });
+
+  test('think tool shows thought prefix in header', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+
+    await messageInput.fill('think: This is a long thought that should be truncated in the header display');
+    await sendButton.click();
+
+    await expect(page.locator('[data-testid="tool-call-completed"]').first()).toBeVisible({ timeout: 30000 });
+
+    // Verify think tool shows truncated thoughts in the header
+    const thinkTool = page.locator('.tool').filter({ hasText: 'This is a long thought' }).first();
+    await expect(thinkTool.locator('.tool-command')).toBeVisible();
+    // The text should be truncated (50 chars max)
+    const headerText = await thinkTool.locator('.tool-command').textContent();
+    expect(headerText?.startsWith('This is a long thought')).toBe(true);
+  });
+
+  test('browser navigate tool shows URL in header', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+
+    await messageInput.fill('tool smorgasbord');
+    await sendButton.click();
+
+    await page.waitForFunction(
+      () => document.querySelectorAll('[data-testid="tool-call-completed"]').length >= 9,
+      undefined,
+      { timeout: 30000 }
+    );
+
+    // Verify browser_navigate tool shows URL in the header
+    const navigateTool = page.locator('.tool').filter({ hasText: 'https://example.com' }).first();
+    await expect(navigateTool.locator('.tool-command').filter({ hasText: 'https://example.com' })).toBeVisible();
+  });
+
+  test('emoji sizes are consistent across all tools', async ({ page }) => {
+    await page.goto('/');
+    await page.waitForLoadState('domcontentloaded');
+
+    const messageInput = page.getByTestId('message-input');
+    const sendButton = page.getByTestId('send-button');
+
+    await messageInput.fill('tool smorgasbord');
+    await sendButton.click();
+
+    await page.waitForFunction(
+      () => document.querySelectorAll('[data-testid="tool-call-completed"]').length >= 9,
+      undefined,
+      { timeout: 30000 }
+    );
+
+    // Get all tool emojis and check their computed font-size
+    const emojiSizes = await page.$$eval(
+      '.tool-emoji, .bash-tool-emoji, .patch-tool-emoji, .screenshot-tool-emoji',
+      (elements) => elements.map(el => window.getComputedStyle(el).fontSize)
+    );
+
+    // All emojis should be 1rem (16px by default)
+    // Check that all sizes are the same
+    const uniqueSizes = new Set(emojiSizes);
+    expect(uniqueSizes.size).toBe(1);
+
+    // Verify the size is 16px (1rem)
+    expect(emojiSizes[0]).toBe('16px');
+  });
+});

ui/embedfs.go πŸ”—

@@ -0,0 +1,102 @@
+package ui
+
+import (
+	"embed"
+	"encoding/json"
+	"fmt"
+	"io/fs"
+	"net/http"
+	"os"
+	"path/filepath"
+	"time"
+)
+
+// Dist contains the contents of the built UI under dist/.
+//
+//go:embed dist/*
+var Dist embed.FS
+
+var assets http.FileSystem
+
+func init() {
+	sub, err := fs.Sub(Dist, "dist")
+	if err != nil {
+		// If the build is misconfigured and dist/ is missing, fail fast.
+		panic(err)
+	}
+	assets = http.FS(sub)
+
+	// Check if UI sources are stale compared to the embedded build
+	checkStaleness()
+}
+
+// checkStaleness verifies that the embedded UI build is not stale.
+// If ui/src exists and has files modified after the build, we exit with an error.
+func checkStaleness() {
+	// Read build-info.json from embedded filesystem
+	buildInfoData, err := fs.ReadFile(Dist, "dist/build-info.json")
+	if err != nil {
+		// If build-info.json doesn't exist, the build is old or incomplete.
+		fmt.Fprintf(os.Stderr, "\nError: UI build is stale!\n")
+		fmt.Fprintf(os.Stderr, "\nPlease run 'make serve' instead of 'go run ./cmd/shelley serve'\n")
+		fmt.Fprintf(os.Stderr, "Or rebuild the UI first: cd ui && npm run build\n\n")
+		os.Exit(1)
+		return
+	}
+
+	var buildInfo struct {
+		Timestamp int64  `json:"timestamp"`
+		Date      string `json:"date"`
+		SrcDir    string `json:"srcDir"`
+	}
+	if err := json.Unmarshal(buildInfoData, &buildInfo); err != nil {
+		fmt.Fprintf(os.Stderr, "Warning: failed to parse build-info.json: %v\n", err)
+		return
+	}
+
+	buildTime := time.UnixMilli(buildInfo.Timestamp)
+
+	// Check if source directory exists (we might be in a deployed binary without source)
+	srcDir := buildInfo.SrcDir
+	if srcDir == "" {
+		// Build info doesn't have srcDir, can't check staleness
+		return
+	}
+	if _, err := os.Stat(srcDir); os.IsNotExist(err) {
+		// Source directory doesn't exist, assume we're in production/deployed
+		return
+	}
+
+	// Walk through ui/src and check if any files are newer than the build
+	var newerFiles []string
+	err = filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if !info.IsDir() && info.ModTime().After(buildTime) {
+			newerFiles = append(newerFiles, path)
+		}
+		return nil
+	})
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Warning: failed to check source file timestamps: %v\n", err)
+		return
+	}
+
+	if len(newerFiles) > 0 {
+		fmt.Fprintf(os.Stderr, "\nError: UI build is stale!\n")
+		fmt.Fprintf(os.Stderr, "Build timestamp: %s\n", buildInfo.Date)
+		fmt.Fprintf(os.Stderr, "\nThe following source files are newer than the build:\n")
+		for _, f := range newerFiles {
+			fmt.Fprintf(os.Stderr, "  - %s\n", f)
+		}
+		fmt.Fprintf(os.Stderr, "\nPlease run 'make serve' instead of 'go run ./cmd/shelley serve'\n")
+		fmt.Fprintf(os.Stderr, "Or rebuild the UI first: cd ui && npm run build\n\n")
+		os.Exit(1)
+	}
+}
+
+// Assets returns an http.FileSystem backed by the embedded UI assets.
+func Assets() http.FileSystem {
+	return assets
+}

ui/eslint.config.js πŸ”—

@@ -0,0 +1,29 @@
+// @ts-check
+
+import eslint from '@eslint/js';
+import tseslint from 'typescript-eslint';
+
+export default tseslint.config(
+  eslint.configs.recommended,
+  ...tseslint.configs.recommended,
+  {
+    ignores: ['dist/', 'node_modules/', '*.config.js'],
+  },
+  {
+    languageOptions: {
+      globals: {
+        // Browser globals
+        window: 'readonly',
+        document: 'readonly',
+        console: 'readonly',
+        setTimeout: 'readonly',
+        fetch: 'readonly',
+        EventSource: 'readonly',
+        HTMLDivElement: 'readonly',
+        HTMLTextAreaElement: 'readonly',
+        Event: 'readonly',
+        KeyboardEvent: 'readonly',
+      },
+    },
+  },
+);

ui/package-lock.json πŸ”—

@@ -0,0 +1,4293 @@
+{
+  "name": "shelley-ui",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "shelley-ui",
+      "version": "1.0.0",
+      "dependencies": {
+        "monaco-editor": "^0.44.0",
+        "react": "^18.2.0",
+        "react-dom": "^18.2.0"
+      },
+      "devDependencies": {
+        "@eslint/js": "^9.35.0",
+        "@playwright/test": "^1.40.0",
+        "@types/react": "^18.2.0",
+        "@types/react-dom": "^18.2.0",
+        "@typescript-eslint/eslint-plugin": "^8.43.0",
+        "@typescript-eslint/parser": "^8.43.0",
+        "esbuild": "^0.19.0",
+        "eslint": "^9.35.0",
+        "eslint-plugin-react": "^7.37.5",
+        "eslint-plugin-react-hooks": "^5.2.0",
+        "prettier": "^3.6.2",
+        "typescript": "^5.0.0",
+        "typescript-eslint": "^8.43.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz",
+      "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz",
+      "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz",
+      "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz",
+      "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz",
+      "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz",
+      "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz",
+      "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz",
+      "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz",
+      "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz",
+      "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz",
+      "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz",
+      "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz",
+      "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz",
+      "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz",
+      "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz",
+      "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz",
+      "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz",
+      "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz",
+      "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz",
+      "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz",
+      "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz",
+      "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz",
+      "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@eslint-community/eslint-utils": {
+      "version": "4.9.0",
+      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
+      "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "eslint-visitor-keys": "^3.4.3"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+      }
+    },
+    "node_modules/@eslint-community/regexpp": {
+      "version": "4.12.1",
+      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
+      "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+      }
+    },
+    "node_modules/@eslint/config-array": {
+      "version": "0.21.0",
+      "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
+      "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@eslint/object-schema": "^2.1.6",
+        "debug": "^4.3.1",
+        "minimatch": "^3.1.2"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/config-array/node_modules/brace-expansion": {
+      "version": "1.1.12",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/@eslint/config-array/node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/@eslint/config-helpers": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz",
+      "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@eslint/core": "^0.16.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/core": {
+      "version": "0.16.0",
+      "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz",
+      "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@types/json-schema": "^7.0.15"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/eslintrc": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
+      "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^10.0.1",
+        "globals": "^14.0.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^3.1.2",
+        "strip-json-comments": "^3.1.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
+      "version": "1.1.12",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/ignore": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+      "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/@eslint/js": {
+      "version": "9.37.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz",
+      "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://eslint.org/donate"
+      }
+    },
+    "node_modules/@eslint/object-schema": {
+      "version": "2.1.6",
+      "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
+      "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/plugin-kit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz",
+      "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@eslint/core": "^0.16.0",
+        "levn": "^0.4.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@humanfs/core": {
+      "version": "0.19.1",
+      "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+      "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/@humanfs/node": {
+      "version": "0.16.7",
+      "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+      "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@humanfs/core": "^0.19.1",
+        "@humanwhocodes/retry": "^0.4.0"
+      },
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/@humanwhocodes/module-importer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.22"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@humanwhocodes/retry": {
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+      "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.18"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@playwright/test": {
+      "version": "1.56.0",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz",
+      "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright": "1.56.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/json-schema": {
+      "version": "7.0.15",
+      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/prop-types": {
+      "version": "15.7.15",
+      "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+      "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/react": {
+      "version": "18.3.26",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
+      "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/prop-types": "*",
+        "csstype": "^3.0.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "18.3.7",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+      "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^18.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/eslint-plugin": {
+      "version": "8.46.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz",
+      "integrity": "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@eslint-community/regexpp": "^4.10.0",
+        "@typescript-eslint/scope-manager": "8.46.0",
+        "@typescript-eslint/type-utils": "8.46.0",
+        "@typescript-eslint/utils": "8.46.0",
+        "@typescript-eslint/visitor-keys": "8.46.0",
+        "graphemer": "^1.4.0",
+        "ignore": "^7.0.0",
+        "natural-compare": "^1.4.0",
+        "ts-api-utils": "^2.1.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "@typescript-eslint/parser": "^8.46.0",
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/parser": {
+      "version": "8.46.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.0.tgz",
+      "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/scope-manager": "8.46.0",
+        "@typescript-eslint/types": "8.46.0",
+        "@typescript-eslint/typescript-estree": "8.46.0",
+        "@typescript-eslint/visitor-keys": "8.46.0",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/project-service": {
+      "version": "8.46.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.0.tgz",
+      "integrity": "sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/tsconfig-utils": "^8.46.0",
+        "@typescript-eslint/types": "^8.46.0",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/scope-manager": {
+      "version": "8.46.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz",
+      "integrity": "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/types": "8.46.0",
+        "@typescript-eslint/visitor-keys": "8.46.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/tsconfig-utils": {
+      "version": "8.46.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz",
+      "integrity": "sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/type-utils": {
+      "version": "8.46.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.0.tgz",
+      "integrity": "sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/types": "8.46.0",
+        "@typescript-eslint/typescript-estree": "8.46.0",
+        "@typescript-eslint/utils": "8.46.0",
+        "debug": "^4.3.4",
+        "ts-api-utils": "^2.1.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/types": {
+      "version": "8.46.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.0.tgz",
+      "integrity": "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree": {
+      "version": "8.46.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz",
+      "integrity": "sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/project-service": "8.46.0",
+        "@typescript-eslint/tsconfig-utils": "8.46.0",
+        "@typescript-eslint/types": "8.46.0",
+        "@typescript-eslint/visitor-keys": "8.46.0",
+        "debug": "^4.3.4",
+        "fast-glob": "^3.3.2",
+        "is-glob": "^4.0.3",
+        "minimatch": "^9.0.4",
+        "semver": "^7.6.0",
+        "ts-api-utils": "^2.1.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/utils": {
+      "version": "8.46.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.0.tgz",
+      "integrity": "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.7.0",
+        "@typescript-eslint/scope-manager": "8.46.0",
+        "@typescript-eslint/types": "8.46.0",
+        "@typescript-eslint/typescript-estree": "8.46.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/visitor-keys": {
+      "version": "8.46.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz",
+      "integrity": "sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/types": "8.46.0",
+        "eslint-visitor-keys": "^4.2.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+      "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/acorn": {
+      "version": "8.15.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+      "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true,
+      "license": "Python-2.0"
+    },
+    "node_modules/array-buffer-byte-length": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+      "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "is-array-buffer": "^3.0.5"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/array-includes": {
+      "version": "3.1.9",
+      "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
+      "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.24.0",
+        "es-object-atoms": "^1.1.1",
+        "get-intrinsic": "^1.3.0",
+        "is-string": "^1.1.1",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/array.prototype.findlast": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
+      "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.7",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.2",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.0.0",
+        "es-shim-unscopables": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/array.prototype.flat": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
+      "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.5",
+        "es-shim-unscopables": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/array.prototype.flatmap": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz",
+      "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.5",
+        "es-shim-unscopables": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/array.prototype.tosorted": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz",
+      "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.7",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.3",
+        "es-errors": "^1.3.0",
+        "es-shim-unscopables": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/arraybuffer.prototype.slice": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
+      "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "array-buffer-byte-length": "^1.0.1",
+        "call-bind": "^1.0.8",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.5",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "is-array-buffer": "^3.0.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/async-function": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
+      "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/available-typed-arrays": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+      "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "possible-typed-array-names": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/brace-expansion": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fill-range": "^7.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/call-bind": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+      "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.0",
+        "es-define-property": "^1.0.0",
+        "get-intrinsic": "^1.2.4",
+        "set-function-length": "^1.2.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+      "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/data-view-buffer": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
+      "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "es-errors": "^1.3.0",
+        "is-data-view": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/data-view-byte-length": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
+      "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "es-errors": "^1.3.0",
+        "is-data-view": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/inspect-js"
+      }
+    },
+    "node_modules/data-view-byte-offset": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
+      "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "is-data-view": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/define-data-property": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+      "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-define-property": "^1.0.0",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/define-properties": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+      "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.0.1",
+        "has-property-descriptors": "^1.0.0",
+        "object-keys": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/doctrine": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+      "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "esutils": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-abstract": {
+      "version": "1.24.0",
+      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
+      "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "array-buffer-byte-length": "^1.0.2",
+        "arraybuffer.prototype.slice": "^1.0.4",
+        "available-typed-arrays": "^1.0.7",
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "data-view-buffer": "^1.0.2",
+        "data-view-byte-length": "^1.0.2",
+        "data-view-byte-offset": "^1.0.1",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "es-set-tostringtag": "^2.1.0",
+        "es-to-primitive": "^1.3.0",
+        "function.prototype.name": "^1.1.8",
+        "get-intrinsic": "^1.3.0",
+        "get-proto": "^1.0.1",
+        "get-symbol-description": "^1.1.0",
+        "globalthis": "^1.0.4",
+        "gopd": "^1.2.0",
+        "has-property-descriptors": "^1.0.2",
+        "has-proto": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "internal-slot": "^1.1.0",
+        "is-array-buffer": "^3.0.5",
+        "is-callable": "^1.2.7",
+        "is-data-view": "^1.0.2",
+        "is-negative-zero": "^2.0.3",
+        "is-regex": "^1.2.1",
+        "is-set": "^2.0.3",
+        "is-shared-array-buffer": "^1.0.4",
+        "is-string": "^1.1.1",
+        "is-typed-array": "^1.1.15",
+        "is-weakref": "^1.1.1",
+        "math-intrinsics": "^1.1.0",
+        "object-inspect": "^1.13.4",
+        "object-keys": "^1.1.1",
+        "object.assign": "^4.1.7",
+        "own-keys": "^1.0.1",
+        "regexp.prototype.flags": "^1.5.4",
+        "safe-array-concat": "^1.1.3",
+        "safe-push-apply": "^1.0.0",
+        "safe-regex-test": "^1.1.0",
+        "set-proto": "^1.0.0",
+        "stop-iteration-iterator": "^1.1.0",
+        "string.prototype.trim": "^1.2.10",
+        "string.prototype.trimend": "^1.0.9",
+        "string.prototype.trimstart": "^1.0.8",
+        "typed-array-buffer": "^1.0.3",
+        "typed-array-byte-length": "^1.0.3",
+        "typed-array-byte-offset": "^1.0.4",
+        "typed-array-length": "^1.0.7",
+        "unbox-primitive": "^1.1.0",
+        "which-typed-array": "^1.1.19"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-iterator-helpers": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz",
+      "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.6",
+        "es-errors": "^1.3.0",
+        "es-set-tostringtag": "^2.0.3",
+        "function-bind": "^1.1.2",
+        "get-intrinsic": "^1.2.6",
+        "globalthis": "^1.0.4",
+        "gopd": "^1.2.0",
+        "has-property-descriptors": "^1.0.2",
+        "has-proto": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "internal-slot": "^1.1.0",
+        "iterator.prototype": "^1.1.4",
+        "safe-array-concat": "^1.1.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-shim-unscopables": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
+      "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-to-primitive": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz",
+      "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-callable": "^1.2.7",
+        "is-date-object": "^1.0.5",
+        "is-symbol": "^1.0.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.19.12",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
+      "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.19.12",
+        "@esbuild/android-arm": "0.19.12",
+        "@esbuild/android-arm64": "0.19.12",
+        "@esbuild/android-x64": "0.19.12",
+        "@esbuild/darwin-arm64": "0.19.12",
+        "@esbuild/darwin-x64": "0.19.12",
+        "@esbuild/freebsd-arm64": "0.19.12",
+        "@esbuild/freebsd-x64": "0.19.12",
+        "@esbuild/linux-arm": "0.19.12",
+        "@esbuild/linux-arm64": "0.19.12",
+        "@esbuild/linux-ia32": "0.19.12",
+        "@esbuild/linux-loong64": "0.19.12",
+        "@esbuild/linux-mips64el": "0.19.12",
+        "@esbuild/linux-ppc64": "0.19.12",
+        "@esbuild/linux-riscv64": "0.19.12",
+        "@esbuild/linux-s390x": "0.19.12",
+        "@esbuild/linux-x64": "0.19.12",
+        "@esbuild/netbsd-x64": "0.19.12",
+        "@esbuild/openbsd-x64": "0.19.12",
+        "@esbuild/sunos-x64": "0.19.12",
+        "@esbuild/win32-arm64": "0.19.12",
+        "@esbuild/win32-ia32": "0.19.12",
+        "@esbuild/win32-x64": "0.19.12"
+      }
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/eslint": {
+      "version": "9.37.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz",
+      "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.8.0",
+        "@eslint-community/regexpp": "^4.12.1",
+        "@eslint/config-array": "^0.21.0",
+        "@eslint/config-helpers": "^0.4.0",
+        "@eslint/core": "^0.16.0",
+        "@eslint/eslintrc": "^3.3.1",
+        "@eslint/js": "9.37.0",
+        "@eslint/plugin-kit": "^0.4.0",
+        "@humanfs/node": "^0.16.6",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@humanwhocodes/retry": "^0.4.2",
+        "@types/estree": "^1.0.6",
+        "@types/json-schema": "^7.0.15",
+        "ajv": "^6.12.4",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.6",
+        "debug": "^4.3.2",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^8.4.0",
+        "eslint-visitor-keys": "^4.2.1",
+        "espree": "^10.4.0",
+        "esquery": "^1.5.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^8.0.0",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "ignore": "^5.2.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.1.2",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.3"
+      },
+      "bin": {
+        "eslint": "bin/eslint.js"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://eslint.org/donate"
+      },
+      "peerDependencies": {
+        "jiti": "*"
+      },
+      "peerDependenciesMeta": {
+        "jiti": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-plugin-react": {
+      "version": "7.37.5",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
+      "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "array-includes": "^3.1.8",
+        "array.prototype.findlast": "^1.2.5",
+        "array.prototype.flatmap": "^1.3.3",
+        "array.prototype.tosorted": "^1.1.4",
+        "doctrine": "^2.1.0",
+        "es-iterator-helpers": "^1.2.1",
+        "estraverse": "^5.3.0",
+        "hasown": "^2.0.2",
+        "jsx-ast-utils": "^2.4.1 || ^3.0.0",
+        "minimatch": "^3.1.2",
+        "object.entries": "^1.1.9",
+        "object.fromentries": "^2.0.8",
+        "object.values": "^1.2.1",
+        "prop-types": "^15.8.1",
+        "resolve": "^2.0.0-next.5",
+        "semver": "^6.3.1",
+        "string.prototype.matchall": "^4.0.12",
+        "string.prototype.repeat": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      },
+      "peerDependencies": {
+        "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
+      }
+    },
+    "node_modules/eslint-plugin-react-hooks": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
+      "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+      }
+    },
+    "node_modules/eslint-plugin-react/node_modules/brace-expansion": {
+      "version": "1.1.12",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/eslint-plugin-react/node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/eslint-plugin-react/node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/eslint-scope": {
+      "version": "8.4.0",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+      "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint-visitor-keys": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint/node_modules/brace-expansion": {
+      "version": "1.1.12",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/eslint/node_modules/eslint-visitor-keys": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+      "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint/node_modules/ignore": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+      "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/eslint/node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/espree": {
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+      "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "acorn": "^8.15.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^4.2.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/espree/node_modules/eslint-visitor-keys": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+      "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/esquery": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
+      "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "estraverse": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fast-glob": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+      "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.8"
+      },
+      "engines": {
+        "node": ">=8.6.0"
+      }
+    },
+    "node_modules/fast-glob/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fastq": {
+      "version": "1.19.1",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
+      "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/file-entry-cache": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+      "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "flat-cache": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=16.0.0"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/flat-cache": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+      "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "flatted": "^3.2.9",
+        "keyv": "^4.5.4"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/flatted": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+      "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/for-each": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+      "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-callable": "^1.2.7"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/function.prototype.name": {
+      "version": "1.1.8",
+      "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz",
+      "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "define-properties": "^1.2.1",
+        "functions-have-names": "^1.2.3",
+        "hasown": "^2.0.2",
+        "is-callable": "^1.2.7"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/functions-have-names": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+      "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/generator-function": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
+      "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/get-symbol-description": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
+      "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/globals": {
+      "version": "14.0.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+      "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/globalthis": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+      "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "define-properties": "^1.2.1",
+        "gopd": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/graphemer": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+      "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/has-bigints": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+      "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/has-property-descriptors": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+      "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-define-property": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-proto": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
+      "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ignore": {
+      "version": "7.0.5",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+      "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/import-fresh": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+      "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.8.19"
+      }
+    },
+    "node_modules/internal-slot": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+      "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "hasown": "^2.0.2",
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/is-array-buffer": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+      "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "get-intrinsic": "^1.2.6"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-async-function": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
+      "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "async-function": "^1.0.0",
+        "call-bound": "^1.0.3",
+        "get-proto": "^1.0.1",
+        "has-tostringtag": "^1.0.2",
+        "safe-regex-test": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-bigint": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+      "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-bigints": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-boolean-object": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+      "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-callable": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+      "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.16.1",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+      "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-data-view": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
+      "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "get-intrinsic": "^1.2.6",
+        "is-typed-array": "^1.1.13"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-date-object": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+      "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-finalizationregistry": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
+      "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-generator-function": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
+      "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.4",
+        "generator-function": "^2.0.0",
+        "get-proto": "^1.0.1",
+        "has-tostringtag": "^1.0.2",
+        "safe-regex-test": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-map": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+      "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-negative-zero": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+      "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/is-number-object": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+      "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-regex": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+      "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "gopd": "^1.2.0",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-set": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+      "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-shared-array-buffer": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+      "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-string": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+      "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-symbol": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+      "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "has-symbols": "^1.1.0",
+        "safe-regex-test": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-typed-array": {
+      "version": "1.1.15",
+      "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+      "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "which-typed-array": "^1.1.16"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-weakmap": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+      "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-weakref": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
+      "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-weakset": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+      "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "get-intrinsic": "^1.2.6"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/isarray": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+      "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/iterator.prototype": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
+      "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.1.4",
+        "es-object-atoms": "^1.0.0",
+        "get-intrinsic": "^1.2.6",
+        "get-proto": "^1.0.0",
+        "has-symbols": "^1.1.0",
+        "set-function-name": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "license": "MIT"
+    },
+    "node_modules/js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/json-buffer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+      "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/jsx-ast-utils": {
+      "version": "3.3.5",
+      "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
+      "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "array-includes": "^3.1.6",
+        "array.prototype.flat": "^1.3.1",
+        "object.assign": "^4.1.4",
+        "object.values": "^1.1.6"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/keyv": {
+      "version": "4.5.4",
+      "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+      "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "json-buffer": "3.0.1"
+      }
+    },
+    "node_modules/levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "p-locate": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "braces": "^3.0.3",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/monaco-editor": {
+      "version": "0.44.0",
+      "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.44.0.tgz",
+      "integrity": "sha512-5SmjNStN6bSuSE5WPT2ZV+iYn1/yI9sd4Igtk23ChvqB7kDk9lZbB9F5frsuvpB+2njdIeGGFf2G4gbE6rCC9Q=="
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/object.assign": {
+      "version": "4.1.7",
+      "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+      "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "define-properties": "^1.2.1",
+        "es-object-atoms": "^1.0.0",
+        "has-symbols": "^1.1.0",
+        "object-keys": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object.entries": {
+      "version": "1.1.9",
+      "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz",
+      "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "define-properties": "^1.2.1",
+        "es-object-atoms": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/object.fromentries": {
+      "version": "2.0.8",
+      "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz",
+      "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.7",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.2",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object.values": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz",
+      "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "define-properties": "^1.2.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/optionator": {
+      "version": "0.9.4",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+      "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.5"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/own-keys": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
+      "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "get-intrinsic": "^1.2.6",
+        "object-keys": "^1.1.1",
+        "safe-push-apply": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "yocto-queue": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "p-limit": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "callsites": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.56.0",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz",
+      "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.56.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.56.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz",
+      "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/possible-typed-array-names": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+      "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/prettier": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
+      "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "prettier": "bin/prettier.cjs"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/prettier/prettier?sponsor=1"
+      }
+    },
+    "node_modules/prop-types": {
+      "version": "15.8.1",
+      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.4.0",
+        "object-assign": "^4.1.1",
+        "react-is": "^16.13.1"
+      }
+    },
+    "node_modules/punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/react": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+      "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "18.3.1",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+      "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0",
+        "scheduler": "^0.23.2"
+      },
+      "peerDependencies": {
+        "react": "^18.3.1"
+      }
+    },
+    "node_modules/react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/reflect.getprototypeof": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
+      "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.9",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.0.0",
+        "get-intrinsic": "^1.2.7",
+        "get-proto": "^1.0.1",
+        "which-builtin-type": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/regexp.prototype.flags": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+      "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "define-properties": "^1.2.1",
+        "es-errors": "^1.3.0",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "set-function-name": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/resolve": {
+      "version": "2.0.0-next.5",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
+      "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-core-module": "^2.13.0",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/reusify": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+      "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "node_modules/safe-array-concat": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
+      "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.2",
+        "get-intrinsic": "^1.2.6",
+        "has-symbols": "^1.1.0",
+        "isarray": "^2.0.5"
+      },
+      "engines": {
+        "node": ">=0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/safe-push-apply": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+      "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "isarray": "^2.0.5"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/safe-regex-test": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+      "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "is-regex": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.23.2",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+      "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      }
+    },
+    "node_modules/semver": {
+      "version": "7.7.3",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+      "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/set-function-length": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+      "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.1.4",
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2",
+        "get-intrinsic": "^1.2.4",
+        "gopd": "^1.0.1",
+        "has-property-descriptors": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/set-function-name": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+      "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.1.4",
+        "es-errors": "^1.3.0",
+        "functions-have-names": "^1.2.3",
+        "has-property-descriptors": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/set-proto": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
+      "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/stop-iteration-iterator": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+      "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "internal-slot": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/string.prototype.matchall": {
+      "version": "4.0.12",
+      "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
+      "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.6",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.0.0",
+        "get-intrinsic": "^1.2.6",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "internal-slot": "^1.1.0",
+        "regexp.prototype.flags": "^1.5.3",
+        "set-function-name": "^2.0.2",
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/string.prototype.repeat": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz",
+      "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.17.5"
+      }
+    },
+    "node_modules/string.prototype.trim": {
+      "version": "1.2.10",
+      "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
+      "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.2",
+        "define-data-property": "^1.1.4",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.5",
+        "es-object-atoms": "^1.0.0",
+        "has-property-descriptors": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/string.prototype.trimend": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz",
+      "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.2",
+        "define-properties": "^1.2.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/string.prototype.trimstart": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz",
+      "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.7",
+        "define-properties": "^1.2.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/ts-api-utils": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
+      "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.12"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4"
+      }
+    },
+    "node_modules/type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "prelude-ls": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/typed-array-buffer": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+      "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "es-errors": "^1.3.0",
+        "is-typed-array": "^1.1.14"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/typed-array-byte-length": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz",
+      "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "for-each": "^0.3.3",
+        "gopd": "^1.2.0",
+        "has-proto": "^1.2.0",
+        "is-typed-array": "^1.1.14"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/typed-array-byte-offset": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz",
+      "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "available-typed-arrays": "^1.0.7",
+        "call-bind": "^1.0.8",
+        "for-each": "^0.3.3",
+        "gopd": "^1.2.0",
+        "has-proto": "^1.2.0",
+        "is-typed-array": "^1.1.15",
+        "reflect.getprototypeof": "^1.0.9"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/typed-array-length": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz",
+      "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.7",
+        "for-each": "^0.3.3",
+        "gopd": "^1.0.1",
+        "is-typed-array": "^1.1.13",
+        "possible-typed-array-names": "^1.0.0",
+        "reflect.getprototypeof": "^1.0.6"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/typescript-eslint": {
+      "version": "8.46.0",
+      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.0.tgz",
+      "integrity": "sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/eslint-plugin": "8.46.0",
+        "@typescript-eslint/parser": "8.46.0",
+        "@typescript-eslint/typescript-estree": "8.46.0",
+        "@typescript-eslint/utils": "8.46.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/unbox-primitive": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+      "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "has-bigints": "^1.0.2",
+        "has-symbols": "^1.1.0",
+        "which-boxed-primitive": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/which-boxed-primitive": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+      "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-bigint": "^1.1.0",
+        "is-boolean-object": "^1.2.1",
+        "is-number-object": "^1.1.1",
+        "is-string": "^1.1.1",
+        "is-symbol": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/which-builtin-type": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz",
+      "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "function.prototype.name": "^1.1.6",
+        "has-tostringtag": "^1.0.2",
+        "is-async-function": "^2.0.0",
+        "is-date-object": "^1.1.0",
+        "is-finalizationregistry": "^1.1.0",
+        "is-generator-function": "^1.0.10",
+        "is-regex": "^1.2.1",
+        "is-weakref": "^1.0.2",
+        "isarray": "^2.0.5",
+        "which-boxed-primitive": "^1.1.0",
+        "which-collection": "^1.0.2",
+        "which-typed-array": "^1.1.16"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/which-collection": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+      "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-map": "^2.0.3",
+        "is-set": "^2.0.3",
+        "is-weakmap": "^2.0.2",
+        "is-weakset": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/which-typed-array": {
+      "version": "1.1.19",
+      "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
+      "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "available-typed-arrays": "^1.0.7",
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "for-each": "^0.3.5",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/word-wrap": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    }
+  }
+}

ui/package.json πŸ”—

@@ -0,0 +1,41 @@
+{
+  "name": "shelley-ui",
+  "version": "1.0.0",
+  "type": "module",
+  "packageManager": "npm@11.6.2",
+  "scripts": {
+    "dev": "esbuild src/main.tsx --bundle --outfile=dist/main.js --servedir=dist --watch",
+    "build": "node scripts/build.js",
+    "clean": "rm -rf dist/*",
+    "lint": "eslint src --ext .ts,.tsx",
+    "lint:fix": "eslint src --ext .ts,.tsx --fix",
+    "type-check": "tsc --noEmit",
+    "format": "prettier --write 'src/**/*.{ts,tsx,js,jsx,json,css,html}'",
+    "format:check": "prettier --check 'src/**/*.{ts,tsx,js,jsx,json,css,html}'",
+    "generate-types": "cd .. && go run ./cmd/go2ts.go -o ui/src/generated-types.ts",
+    "test:e2e": "npm run build && playwright test",
+    "test:e2e:headed": "npm run build && playwright test --headed",
+    "test:e2e:ui": "npm run build && playwright test --ui",
+    "test:e2e:debug": "npm run build && playwright test --debug"
+  },
+  "dependencies": {
+    "monaco-editor": "^0.44.0",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  },
+  "devDependencies": {
+    "@eslint/js": "^9.35.0",
+    "@playwright/test": "^1.40.0",
+    "@types/react": "^18.2.0",
+    "@types/react-dom": "^18.2.0",
+    "@typescript-eslint/eslint-plugin": "^8.43.0",
+    "@typescript-eslint/parser": "^8.43.0",
+    "esbuild": "^0.19.0",
+    "eslint": "^9.35.0",
+    "eslint-plugin-react": "^7.37.5",
+    "eslint-plugin-react-hooks": "^5.2.0",
+    "prettier": "^3.6.2",
+    "typescript": "^5.0.0",
+    "typescript-eslint": "^8.43.0"
+  }
+}

ui/playwright.config.ts πŸ”—

@@ -0,0 +1,45 @@
+import { defineConfig, devices } from '@playwright/test';
+
+/**
+ * @see https://playwright.dev/docs/test-configuration
+ */
+export default defineConfig({
+  testDir: './e2e',
+  /* Run tests in files in parallel */
+  fullyParallel: false, // Keep simple for now
+  /* Fail the build on CI if you accidentally left test.only in the source code. */
+  forbidOnly: !!process.env.CI,
+  /* Retry on CI only */
+  retries: process.env.CI ? 1 : 0,
+  /* Single worker for predictable test database state */
+  workers: 1,
+  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+  reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'list',
+  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+  use: {
+    /* Base URL to use in actions like `await page.goto('/')`. */
+    baseURL: process.env.TEST_SERVER_URL || 'http://localhost:9001',
+    /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+    trace: 'on-first-retry',
+    /* Take screenshots on failure */
+    screenshot: 'only-on-failure',
+    /* Record video only on failure */
+    video: 'on-first-retry',
+  },
+
+  /* Just test mobile Chrome for simplicity */
+  projects: [
+    {
+      name: 'Mobile Chrome',
+      use: { ...devices['Pixel 5'] },
+    },
+  ],
+
+  /* Run our test server with isolated database */
+  webServer: {
+    command: 'node scripts/test-server.cjs',
+    url: process.env.TEST_SERVER_URL || 'http://localhost:9001',
+    reuseExistingServer: !process.env.CI, // Allow reuse in dev, always fresh in CI
+    timeout: 60000,
+  },
+});

ui/scripts/build-info.js πŸ”—

@@ -0,0 +1,23 @@
+#!/usr/bin/env node
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+// Get the absolute path to the src directory
+const srcDir = path.resolve(__dirname, '..', 'src');
+
+const buildInfo = {
+  timestamp: Date.now(),
+  date: new Date().toISOString(),
+  srcDir: srcDir
+};
+
+fs.writeFileSync(
+  path.join(__dirname, '..', 'dist', 'build-info.json'),
+  JSON.stringify(buildInfo, null, 2)
+);
+
+console.log('Build info written:', buildInfo.date);

ui/scripts/build.js πŸ”—

@@ -0,0 +1,76 @@
+import * as esbuild from 'esbuild';
+import * as fs from 'fs';
+
+const isWatch = process.argv.includes('--watch');
+const isProd = !isWatch;
+
+async function build() {
+  try {
+    // Ensure dist directory exists
+    if (!fs.existsSync('dist')) {
+      fs.mkdirSync('dist');
+    }
+
+    // Build Monaco editor worker separately (IIFE format for web worker)
+    console.log('Building Monaco editor worker...');
+    await esbuild.build({
+      entryPoints: ['node_modules/monaco-editor/esm/vs/editor/editor.worker.js'],
+      bundle: true,
+      outfile: 'dist/editor.worker.js',
+      format: 'iife',
+      minify: isProd,
+      sourcemap: true,
+    });
+
+    // Build Monaco editor as a separate chunk (JS + CSS)
+    console.log('Building Monaco editor bundle...');
+    await esbuild.build({
+      entryPoints: ['node_modules/monaco-editor/esm/vs/editor/editor.main.js'],
+      bundle: true,
+      outfile: 'dist/monaco-editor.js',
+      format: 'esm',
+      minify: isProd,
+      sourcemap: true,
+      loader: {
+        '.ttf': 'file',
+      },
+    });
+
+    // Build main app - exclude monaco-editor, we'll load it dynamically
+    console.log('Building main application...');
+    const result = await esbuild.build({
+      entryPoints: ['src/main.tsx'],
+      bundle: true,
+      outfile: 'dist/main.js',
+      format: 'esm',
+      minify: isProd,
+      sourcemap: true,
+      metafile: true,
+      external: ['monaco-editor', '/monaco-editor.js'],
+    });
+
+    // Copy static files
+    fs.copyFileSync('src/index.html', 'dist/index.html');
+    fs.copyFileSync('src/styles.css', 'dist/styles.css');
+
+    // Write build info
+    const buildInfo = { timestamp: new Date().toISOString() };
+    fs.writeFileSync('dist/build-info.json', JSON.stringify(buildInfo, null, 2));
+
+    console.log('Build complete!');
+
+    // Show file sizes
+    console.log('\nOutput files:');
+    const files = fs.readdirSync('dist').filter(f => f.endsWith('.js') || f.endsWith('.css') || f.endsWith('.ttf'));
+    for (const file of files.sort()) {
+      const stats = fs.statSync(`dist/${file}`);
+      const sizeKb = (stats.size / 1024).toFixed(1);
+      console.log(`  ${file}: ${sizeKb} KB`);
+    }
+  } catch (error) {
+    console.error('Build failed:', error);
+    process.exit(1);
+  }
+}
+
+build();

ui/scripts/show-screenshots.sh πŸ”—

@@ -0,0 +1,33 @@
+#!/bin/bash
+
+# Script to help inspect Playwright test screenshots
+
+echo "πŸ“Έ Shelley E2E Test Screenshots"
+echo "================================="
+
+cd "$(dirname "$0")/.."
+
+# Create screenshots directory if it doesn't exist
+mkdir -p e2e/screenshots
+
+# Check for test results
+if [ -d "test-results" ]; then
+    echo "\nπŸ” Recent test failures:"
+    find test-results -name "*.png" -type f -exec ls -la {} \; | head -10
+else
+    echo "\n❌ No test-results directory found. Run tests first:"
+    echo "   npm run test:e2e"
+fi
+
+# Check for screenshots in e2e directory
+if [ "$(ls e2e/screenshots/*.png 2>/dev/null | wc -l)" -gt 0 ]; then
+    echo "\nπŸ“· Generated screenshots:"
+    ls -la e2e/screenshots/*.png | head -10
+else
+    echo "\nπŸ“· No screenshots found in e2e/screenshots/"
+fi
+
+echo "\nπŸ’‘ To view screenshots:"
+echo "   - Open files directly with an image viewer"
+echo "   - Use 'npx playwright show-report' for HTML report"
+echo "   - Check test-results/ for failure screenshots"

ui/scripts/test-server.cjs πŸ”—

@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+
+// Test server script for Playwright tests
+const { spawn } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+const { mkdtempSync } = require('fs');
+const { tmpdir } = require('os');
+const net = require('net');
+
+// Function to find an available port starting from a base port
+function getAvailablePort(startPort = 9001) {
+  return new Promise((resolve, reject) => {
+    const server = net.createServer();
+    server.unref();
+    server.on('error', () => {
+      // Try next port
+      resolve(getAvailablePort(startPort + 1));
+    });
+    server.listen(startPort, () => {
+      const port = server.address().port;
+      server.close(() => {
+        resolve(port);
+      });
+    });
+  });
+}
+
+// Create a temporary directory for this test run
+const tempDir = mkdtempSync(path.join(tmpdir(), 'shelley-e2e-'));
+const testDb = path.join(tempDir, 'test.db');
+const testDbShm = testDb + '-shm';
+const testDbWal = testDb + '-wal';
+
+console.log(`Using temporary database: ${testDb}`);
+
+// Get an available port and start the server
+getAvailablePort().then(port => {
+  console.log(`Starting test server on port ${port}`);
+  
+  // Start Shelley server with test configuration
+  const serverProcess = spawn('go', [
+    'run', './cmd/shelley',
+    '--model', 'predictable',
+    '--predictable-only',
+    '--db', testDb,
+    'serve',
+    '--port', port.toString()
+  ], {
+    cwd: path.join(__dirname, '../..'),
+    stdio: 'inherit',
+    env: {
+      ...process.env,
+      PREDICTABLE_DELAY_MS: process.env.PREDICTABLE_DELAY_MS || '400'
+    }
+  });
+
+  // Cleanup function for temporary directory and database files
+  const cleanup = () => {
+    try {
+      // Remove the entire temporary directory and all its contents
+      fs.rmSync(tempDir, { recursive: true, force: true });
+      console.log(`Cleaned up temporary directory: ${tempDir}`);
+    } catch (error) {
+      console.warn(`Failed to clean up temporary directory: ${error.message}`);
+    }
+  };
+
+  // Handle cleanup on exit
+  process.on('SIGINT', () => {
+    console.log('\nShutting down test server...');
+    serverProcess.kill('SIGTERM');
+    cleanup();
+    process.exit(0);
+  });
+
+  process.on('SIGTERM', () => {
+    serverProcess.kill('SIGTERM');
+    cleanup();
+    process.exit(0);
+  });
+
+  serverProcess.on('close', (code) => {
+    console.log(`Test server exited with code ${code}`);
+    cleanup();
+    process.exit(code);
+  });
+}).catch(error => {
+  console.error('Failed to get available port:', error);
+  process.exit(1);
+});

ui/scripts/verify-gitignore.sh πŸ”—

@@ -0,0 +1,59 @@
+#!/bin/bash
+
+# Script to verify that .gitignore is working correctly
+
+echo "🧹 Verifying .gitignore configuration for Shelley"
+echo "================================================"
+
+cd "$(dirname "$0")/../.."
+
+echo "\nβœ… Current git status:"
+git status --porcelain
+
+if [ $? -eq 0 ] && [ -z "$(git status --porcelain)" ]; then
+    echo "βœ… Working tree is clean"
+else
+    echo "⚠️  Working tree has changes"
+fi
+
+echo "\n🚫 Files being ignored by git:"
+git status --ignored --porcelain | grep '^!!' | head -10
+
+echo "\nπŸ“ Build directories that should be ignored:"
+for dir in "ui/node_modules" "ui/dist" "ui/test-results" "ui/playwright-report" "bin"; do
+    if [ -d "$dir" ]; then
+        echo "  βœ… $dir (exists and ignored)"
+    else
+        echo "  βšͺ $dir (doesn't exist)"
+    fi
+done
+
+echo "\nπŸ’Ύ Database files that should be ignored:"
+for pattern in "*.db" "*.db-shm" "*.db-wal"; do
+    files=$(find . -maxdepth 2 -name "$pattern" 2>/dev/null)
+    if [ -n "$files" ]; then
+        echo "  βœ… Found and ignoring: $pattern"
+        echo "$files" | sed 's/^/    /'
+    else
+        echo "  βšͺ No $pattern files found"
+    fi
+done
+
+echo "\n🎭 Playwright outputs that should be ignored:"
+for dir in "ui/test-results" "ui/playwright-report"; do
+    if [ -d "$dir" ]; then
+        echo "  βœ… $dir (exists and ignored)"
+    else
+        echo "  βšͺ $dir (doesn't exist)"
+    fi
+done
+
+echo "\nπŸ“Έ Screenshot directory:"
+if [ -d "ui/e2e/screenshots" ]; then
+    count=$(find ui/e2e/screenshots -name "*.png" 2>/dev/null | wc -l)
+    echo "  βœ… ui/e2e/screenshots exists with $count PNG files (ignored)"
+else
+    echo "  ❌ ui/e2e/screenshots missing"
+fi
+
+echo "\n🎯 Summary: .gitignore is properly configured to exclude build outputs while preserving source code."

ui/src/App.tsx πŸ”—

@@ -0,0 +1,257 @@
+import React, { useState, useEffect, useCallback, useRef } from "react";
+import ChatInterface from "./components/ChatInterface";
+import ConversationDrawer from "./components/ConversationDrawer";
+import { Conversation } from "./types";
+import { api } from "./services/api";
+
+// Check if a slug is a generated ID (format: cXXXX where X is alphanumeric)
+function isGeneratedId(slug: string | null): boolean {
+  if (!slug) return true;
+  return /^c[a-z0-9]+$/i.test(slug);
+}
+
+// Get slug from the current URL path (expects /c/<slug> format)
+function getSlugFromPath(): string | null {
+  const path = window.location.pathname;
+  // Check for /c/<slug> format
+  if (path.startsWith("/c/")) {
+    const slug = path.slice(3); // Remove "/c/" prefix
+    if (slug) {
+      return slug;
+    }
+  }
+  return null;
+}
+
+// Update the URL to reflect the current conversation slug
+function updateUrlWithSlug(conversation: Conversation | undefined) {
+  const currentSlug = getSlugFromPath();
+  const newSlug =
+    conversation?.slug && !isGeneratedId(conversation.slug) ? conversation.slug : null;
+
+  if (currentSlug !== newSlug) {
+    if (newSlug) {
+      window.history.replaceState({}, "", `/c/${newSlug}`);
+    } else {
+      window.history.replaceState({}, "", "/");
+    }
+  }
+}
+
+function updatePageTitle(conversation: Conversation | undefined) {
+  const hostname = window.__SHELLEY_INIT__?.hostname;
+  const parts: string[] = [];
+
+  if (conversation?.slug && !isGeneratedId(conversation.slug)) {
+    parts.push(conversation.slug);
+  }
+  if (hostname) {
+    parts.push(hostname);
+  }
+  parts.push("Shelley Agent");
+
+  document.title = parts.join(" - ");
+}
+
+function App() {
+  const [conversations, setConversations] = useState<Conversation[]>([]);
+  const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
+  const [drawerOpen, setDrawerOpen] = useState(false);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+  const initialSlugResolved = useRef(false);
+
+  // Resolve initial slug from URL
+  const resolveInitialSlug = useCallback(async (convs: Conversation[]) => {
+    if (initialSlugResolved.current) return null;
+    initialSlugResolved.current = true;
+
+    const urlSlug = getSlugFromPath();
+    if (!urlSlug) return null;
+
+    // First check if we already have this conversation in our list
+    const existingConv = convs.find((c) => c.slug === urlSlug);
+    if (existingConv) {
+      return existingConv.conversation_id;
+    }
+
+    // Otherwise, try to fetch by slug
+    try {
+      const conv = await api.getConversationBySlug(urlSlug);
+      if (conv) {
+        return conv.conversation_id;
+      }
+    } catch (err) {
+      console.error("Failed to resolve slug:", err);
+    }
+
+    // Slug not found, clear the URL
+    window.history.replaceState({}, "", "/");
+    return null;
+  }, []);
+
+  // Load conversations on mount
+  useEffect(() => {
+    loadConversations();
+  }, []);
+
+  // Update page title and URL when conversation changes
+  useEffect(() => {
+    const currentConv = conversations.find(
+      (conv) => conv.conversation_id === currentConversationId,
+    );
+    updatePageTitle(currentConv);
+    updateUrlWithSlug(currentConv);
+  }, [currentConversationId, conversations]);
+
+  const loadConversations = async () => {
+    try {
+      setLoading(true);
+      setError(null);
+      const convs = await api.getConversations();
+      setConversations(convs);
+
+      // Try to resolve conversation from URL slug first
+      const slugConvId = await resolveInitialSlug(convs);
+      if (slugConvId) {
+        setCurrentConversationId(slugConvId);
+      } else if (!currentConversationId && convs.length > 0) {
+        // If we have conversations and no current one selected, select the first
+        setCurrentConversationId(convs[0].conversation_id);
+      }
+      // If no conversations exist, leave currentConversationId as null
+      // The UI will show the welcome screen and create conversation on first message
+    } catch (err) {
+      console.error("Failed to load conversations:", err);
+      setError("Failed to load conversations. Please refresh the page.");
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const startNewConversation = () => {
+    // Just clear the current conversation - a new one will be created when the user sends their first message
+    setCurrentConversationId(null);
+    setDrawerOpen(false);
+  };
+
+  const selectConversation = (conversationId: string) => {
+    setCurrentConversationId(conversationId);
+    setDrawerOpen(false);
+  };
+
+  const updateConversation = (updatedConversation: Conversation) => {
+    setConversations((prev) =>
+      prev.map((conv) =>
+        conv.conversation_id === updatedConversation.conversation_id ? updatedConversation : conv,
+      ),
+    );
+  };
+
+  const handleConversationArchived = (conversationId: string) => {
+    setConversations((prev) => prev.filter((conv) => conv.conversation_id !== conversationId));
+    // If the archived conversation was current, switch to another or clear
+    if (currentConversationId === conversationId) {
+      const remaining = conversations.filter((conv) => conv.conversation_id !== conversationId);
+      setCurrentConversationId(remaining.length > 0 ? remaining[0].conversation_id : null);
+    }
+  };
+
+  const handleConversationUnarchived = (conversation: Conversation) => {
+    // Add the unarchived conversation back to the list
+    setConversations((prev) => [conversation, ...prev]);
+  };
+
+  const handleConversationRenamed = (conversation: Conversation) => {
+    // Update the conversation in the list with the new slug
+    setConversations((prev) =>
+      prev.map((c) => (c.conversation_id === conversation.conversation_id ? conversation : c)),
+    );
+  };
+
+  if (loading && conversations.length === 0) {
+    return (
+      <div className="loading-container">
+        <div className="loading-content">
+          <div className="spinner" style={{ margin: "0 auto 1rem" }}></div>
+          <p className="text-secondary">Loading...</p>
+        </div>
+      </div>
+    );
+  }
+
+  if (error && conversations.length === 0) {
+    return (
+      <div className="error-container">
+        <div className="error-content">
+          <p className="error-message" style={{ marginBottom: "1rem" }}>
+            {error}
+          </p>
+          <button onClick={loadConversations} className="btn-primary">
+            Retry
+          </button>
+        </div>
+      </div>
+    );
+  }
+
+  const currentConversation = conversations.find(
+    (conv) => conv.conversation_id === currentConversationId,
+  );
+
+  // Get the CWD from the most recent conversation (first in list, sorted by updated_at desc)
+  const mostRecentCwd = conversations.length > 0 ? conversations[0].cwd : null;
+
+  const handleFirstMessage = async (message: string, model: string, cwd?: string) => {
+    try {
+      const response = await api.sendMessageWithNewConversation({ message, model, cwd });
+      const newConversationId = response.conversation_id;
+
+      // Fetch the new conversation details
+      const updatedConvs = await api.getConversations();
+      setConversations(updatedConvs);
+      setCurrentConversationId(newConversationId);
+    } catch (err) {
+      console.error("Failed to send first message:", err);
+      setError("Failed to send message");
+      throw err;
+    }
+  };
+
+  return (
+    <div className="app-container">
+      {/* Conversations drawer */}
+      <ConversationDrawer
+        isOpen={drawerOpen}
+        onClose={() => setDrawerOpen(false)}
+        conversations={conversations}
+        currentConversationId={currentConversationId}
+        onSelectConversation={selectConversation}
+        onNewConversation={startNewConversation}
+        onConversationArchived={handleConversationArchived}
+        onConversationUnarchived={handleConversationUnarchived}
+        onConversationRenamed={handleConversationRenamed}
+      />
+
+      {/* Main chat interface */}
+      <div className="main-content">
+        <ChatInterface
+          conversationId={currentConversationId}
+          onOpenDrawer={() => setDrawerOpen(true)}
+          onNewConversation={startNewConversation}
+          currentConversation={currentConversation}
+          onConversationUpdate={updateConversation}
+          onFirstMessage={handleFirstMessage}
+          mostRecentCwd={mostRecentCwd}
+        />
+      </div>
+
+      {/* Backdrop for mobile drawer */}
+      {drawerOpen && (
+        <div className="backdrop hide-on-desktop" onClick={() => setDrawerOpen(false)} />
+      )}
+    </div>
+  );
+}
+
+export default App;

ui/src/assets/manifest.json πŸ”—

@@ -0,0 +1,23 @@
+{
+  "name": "Shelley",
+  "short_name": "Shelley",
+  "description": "AI coding assistant",
+  "start_url": "/",
+  "display": "standalone",
+  "background_color": "#1f2937",
+  "theme_color": "#1f2937",
+  "icons": [
+    {
+      "src": "/icon-192.png",
+      "sizes": "192x192",
+      "type": "image/png",
+      "purpose": "any maskable"
+    },
+    {
+      "src": "/icon-512.png",
+      "sizes": "512x512",
+      "type": "image/png",
+      "purpose": "any maskable"
+    }
+  ]
+}

ui/src/components/AGENT.md πŸ”—

@@ -0,0 +1,12 @@
+# Tool Components
+
+When adding a new specialized tool component (e.g., `FooTool.tsx`), you must register it in **two places**:
+
+1. **ChatInterface.tsx** - Add to the `TOOL_COMPONENTS` map for real-time streaming rendering
+2. **Message.tsx** - Add to the switch statements in `renderContent()` for both `tool_use` and `tool_result` cases
+
+If you only add it to one place, the tool will render inconsistently:
+- Missing from `TOOL_COMPONENTS`: Falls back to generic rendering during streaming, but shows specialized widget after page reload
+- Missing from `Message.tsx`: Shows specialized widget during streaming, but falls back to generic after page reload
+
+Both files need the import statement and the rendering logic for the tool.

ui/src/components/BashTool.tsx πŸ”—

@@ -0,0 +1,109 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface BashToolProps {
+  // For tool_use (pending state)
+  toolInput?: unknown;
+  isRunning?: boolean;
+
+  // For tool_result (completed state)
+  toolResult?: LLMContent[];
+  hasError?: boolean;
+  executionTime?: string;
+}
+
+function BashTool({ toolInput, isRunning, toolResult, hasError, executionTime }: BashToolProps) {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  // Extract command from toolInput
+  const command =
+    typeof toolInput === "object" &&
+    toolInput !== null &&
+    "command" in toolInput &&
+    typeof toolInput.command === "string"
+      ? toolInput.command
+      : typeof toolInput === "string"
+        ? toolInput
+        : "";
+
+  // Extract output from toolResult
+  const output =
+    toolResult && toolResult.length > 0 && toolResult[0].Text ? toolResult[0].Text : "";
+
+  // Check if this was a cancelled operation
+  const isCancelled = hasError && output.toLowerCase().includes("cancel");
+
+  // Truncate command for display
+  const truncateCommand = (cmd: string, maxLen: number = 300) => {
+    if (cmd.length <= maxLen) return cmd;
+    return cmd.substring(0, maxLen) + "...";
+  };
+
+  const displayCommand = truncateCommand(command);
+  const isComplete = !isRunning && toolResult !== undefined;
+
+  return (
+    <div
+      className="bash-tool"
+      data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}
+    >
+      <div className="bash-tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+        <div className="bash-tool-summary">
+          <span className={`bash-tool-emoji ${isRunning ? "running" : ""}`}>πŸ› οΈ</span>
+          <span className="bash-tool-command">{displayCommand}</span>
+          {isComplete && isCancelled && <span className="bash-tool-cancelled">βœ— cancelled</span>}
+          {isComplete && hasError && !isCancelled && <span className="bash-tool-error">βœ—</span>}
+          {isComplete && !hasError && <span className="bash-tool-success">βœ“</span>}
+        </div>
+        <button
+          className="bash-tool-toggle"
+          aria-label={isExpanded ? "Collapse" : "Expand"}
+          aria-expanded={isExpanded}
+        >
+          <svg
+            width="12"
+            height="12"
+            viewBox="0 0 12 12"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+            style={{
+              transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+              transition: "transform 0.2s",
+            }}
+          >
+            <path
+              d="M4.5 3L7.5 6L4.5 9"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {isExpanded && (
+        <div className="bash-tool-details">
+          <div className="bash-tool-section">
+            <div className="bash-tool-label">Command:</div>
+            <pre className="bash-tool-code">{command}</pre>
+          </div>
+
+          {isComplete && (
+            <div className="bash-tool-section">
+              <div className="bash-tool-label">
+                Output{hasError ? " (Error)" : ""}:
+                {executionTime && <span className="bash-tool-time">{executionTime}</span>}
+              </div>
+              <pre className={`bash-tool-code ${hasError ? "error" : ""}`}>
+                {output || "(no output)"}
+              </pre>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default BashTool;

ui/src/components/BrowserConsoleLogsTool.tsx πŸ”—

@@ -0,0 +1,94 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface BrowserConsoleLogsToolProps {
+  toolName: string; // to distinguish between recent and clear
+  toolInput?: unknown;
+  isRunning?: boolean;
+  toolResult?: LLMContent[];
+  hasError?: boolean;
+  executionTime?: string;
+}
+
+function BrowserConsoleLogsTool({
+  toolName,
+  isRunning,
+  toolResult,
+  hasError,
+  executionTime,
+}: BrowserConsoleLogsToolProps) {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  // Extract output from toolResult
+  const output =
+    toolResult && toolResult.length > 0 && toolResult[0].Text ? toolResult[0].Text : "";
+
+  // Determine display text based on tool name and state
+  const getDisplayText = () => {
+    if (isRunning) {
+      return toolName === "browser_console_clear_logs"
+        ? "clearing console..."
+        : "fetching console logs...";
+    }
+    return toolName === "browser_console_clear_logs" ? "clear console" : "console logs";
+  };
+
+  const displayText = getDisplayText();
+  const isComplete = !isRunning && toolResult !== undefined;
+
+  return (
+    <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+      <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+        <div className="tool-summary">
+          <span className={`tool-emoji ${isRunning ? "running" : ""}`}>πŸ“‹</span>
+          <span className="tool-command">{displayText}</span>
+          {isComplete && hasError && <span className="tool-error">βœ—</span>}
+          {isComplete && !hasError && <span className="tool-success">βœ“</span>}
+        </div>
+        <button
+          className="tool-toggle"
+          aria-label={isExpanded ? "Collapse" : "Expand"}
+          aria-expanded={isExpanded}
+        >
+          <svg
+            width="12"
+            height="12"
+            viewBox="0 0 12 12"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+            style={{
+              transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+              transition: "transform 0.2s",
+            }}
+          >
+            <path
+              d="M4.5 3L7.5 6L4.5 9"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {isExpanded && (
+        <div className="tool-details">
+          {isComplete && (
+            <div className="tool-section">
+              <div className="tool-label">
+                Output{hasError ? " (Error)" : ""}:
+                {executionTime && <span className="tool-time">{executionTime}</span>}
+              </div>
+              <pre className={`tool-code ${hasError ? "error" : ""}`}>
+                {output || "(no output)"}
+              </pre>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default BrowserConsoleLogsTool;

ui/src/components/BrowserEvalTool.tsx πŸ”—

@@ -0,0 +1,108 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface BrowserEvalToolProps {
+  // For tool_use (pending state)
+  toolInput?: unknown; // { script: string }
+  isRunning?: boolean;
+
+  // For tool_result (completed state)
+  toolResult?: LLMContent[];
+  hasError?: boolean;
+  executionTime?: string;
+}
+
+function BrowserEvalTool({
+  toolInput,
+  isRunning,
+  toolResult,
+  hasError,
+  executionTime,
+}: BrowserEvalToolProps) {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  // Extract script from toolInput
+  const script =
+    typeof toolInput === "object" &&
+    toolInput !== null &&
+    "script" in toolInput &&
+    typeof toolInput.script === "string"
+      ? toolInput.script
+      : typeof toolInput === "string"
+        ? toolInput
+        : "";
+
+  // Extract result from toolResult
+  const result =
+    toolResult && toolResult.length > 0 && toolResult[0].Text ? toolResult[0].Text : "";
+
+  // Truncate script for display
+  const truncateScript = (scr: string, maxLen: number = 300) => {
+    if (scr.length <= maxLen) return scr;
+    return scr.substring(0, maxLen) + "...";
+  };
+
+  const displayScript = truncateScript(script);
+  const isComplete = !isRunning && toolResult !== undefined;
+
+  return (
+    <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+      <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+        <div className="tool-summary">
+          <span className={`tool-emoji ${isRunning ? "running" : ""}`}>⚑</span>
+          <span className="tool-command">{displayScript}</span>
+          {isComplete && hasError && <span className="tool-error">βœ—</span>}
+          {isComplete && !hasError && <span className="tool-success">βœ“</span>}
+        </div>
+        <button
+          className="tool-toggle"
+          aria-label={isExpanded ? "Collapse" : "Expand"}
+          aria-expanded={isExpanded}
+        >
+          <svg
+            width="12"
+            height="12"
+            viewBox="0 0 12 12"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+            style={{
+              transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+              transition: "transform 0.2s",
+            }}
+          >
+            <path
+              d="M4.5 3L7.5 6L4.5 9"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {isExpanded && (
+        <div className="tool-details">
+          <div className="tool-section">
+            <div className="tool-label">Script:</div>
+            <pre className="tool-code">{script}</pre>
+          </div>
+
+          {isComplete && (
+            <div className="tool-section">
+              <div className="tool-label">
+                Result{hasError ? " (Error)" : ""}:
+                {executionTime && <span className="tool-time">{executionTime}</span>}
+              </div>
+              <pre className={`tool-code ${hasError ? "error" : ""}`}>
+                {result || "(no result)"}
+              </pre>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default BrowserEvalTool;

ui/src/components/BrowserNavigateTool.tsx πŸ”—

@@ -0,0 +1,107 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface BrowserNavigateToolProps {
+  toolInput?: unknown; // { url: string }
+  isRunning?: boolean;
+  toolResult?: LLMContent[];
+  hasError?: boolean;
+  executionTime?: string;
+}
+
+function BrowserNavigateTool({
+  toolInput,
+  isRunning,
+  toolResult,
+  hasError,
+  executionTime,
+}: BrowserNavigateToolProps) {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  // Extract URL from toolInput
+  const url =
+    typeof toolInput === "object" &&
+    toolInput !== null &&
+    "url" in toolInput &&
+    typeof toolInput.url === "string"
+      ? toolInput.url
+      : typeof toolInput === "string"
+        ? toolInput
+        : "";
+
+  // Extract output from toolResult
+  const output =
+    toolResult && toolResult.length > 0 && toolResult[0].Text ? toolResult[0].Text : "";
+
+  // Truncate URL for display
+  const truncateUrl = (urlStr: string, maxLen: number = 300) => {
+    if (urlStr.length <= maxLen) return urlStr;
+    return urlStr.substring(0, maxLen) + "...";
+  };
+
+  const displayUrl = truncateUrl(url);
+  const isComplete = !isRunning && toolResult !== undefined;
+
+  return (
+    <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+      <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+        <div className="tool-summary">
+          <span className={`tool-emoji ${isRunning ? "running" : ""}`}>🌐</span>
+          <span className="tool-command">{displayUrl}</span>
+          {isComplete && hasError && <span className="tool-error">βœ—</span>}
+          {isComplete && !hasError && <span className="tool-success">βœ“</span>}
+        </div>
+        <button
+          className="tool-toggle"
+          aria-label={isExpanded ? "Collapse" : "Expand"}
+          aria-expanded={isExpanded}
+        >
+          <svg
+            width="12"
+            height="12"
+            viewBox="0 0 12 12"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+            style={{
+              transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+              transition: "transform 0.2s",
+            }}
+          >
+            <path
+              d="M4.5 3L7.5 6L4.5 9"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {isExpanded && (
+        <div className="tool-details">
+          <div className="tool-section">
+            <div className="tool-label">URL:</div>
+            <div className="tool-code">
+              <a href={url} target="_blank" rel="noopener noreferrer">
+                {url}
+              </a>
+            </div>
+          </div>
+
+          {isComplete && output && (
+            <div className="tool-section">
+              <div className="tool-label">
+                Output{hasError ? " (Error)" : ""}:
+                {executionTime && <span className="tool-time">{executionTime}</span>}
+              </div>
+              <pre className={`tool-code ${hasError ? "error" : ""}`}>{output}</pre>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default BrowserNavigateTool;

ui/src/components/BrowserResizeTool.tsx πŸ”—

@@ -0,0 +1,105 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface BrowserResizeToolProps {
+  toolInput?: unknown; // { width: number, height: number }
+  isRunning?: boolean;
+  toolResult?: LLMContent[];
+  hasError?: boolean;
+  executionTime?: string;
+}
+
+function BrowserResizeTool({
+  toolInput,
+  isRunning,
+  toolResult,
+  hasError,
+  executionTime,
+}: BrowserResizeToolProps) {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  // Extract dimensions from toolInput
+  const width =
+    typeof toolInput === "object" &&
+    toolInput !== null &&
+    "width" in toolInput &&
+    typeof (toolInput as { width: unknown }).width === "number"
+      ? (toolInput as { width: number }).width
+      : 0;
+
+  const height =
+    typeof toolInput === "object" &&
+    toolInput !== null &&
+    "height" in toolInput &&
+    typeof (toolInput as { height: unknown }).height === "number"
+      ? (toolInput as { height: number }).height
+      : 0;
+
+  // Extract output from toolResult
+  const output =
+    toolResult && toolResult.length > 0 && toolResult[0].Text ? toolResult[0].Text : "";
+
+  const isComplete = !isRunning && toolResult !== undefined;
+  const displaySize = width > 0 && height > 0 ? `${width}Γ—${height}` : "...";
+
+  return (
+    <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+      <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+        <div className="tool-summary">
+          <span className={`tool-emoji ${isRunning ? "running" : ""}`}>πŸ“</span>
+          <span className="tool-command">resize {displaySize}</span>
+          {isComplete && hasError && <span className="tool-error">βœ—</span>}
+          {isComplete && !hasError && <span className="tool-success">βœ“</span>}
+        </div>
+        <button
+          className="tool-toggle"
+          aria-label={isExpanded ? "Collapse" : "Expand"}
+          aria-expanded={isExpanded}
+        >
+          <svg
+            width="12"
+            height="12"
+            viewBox="0 0 12 12"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+            style={{
+              transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+              transition: "transform 0.2s",
+            }}
+          >
+            <path
+              d="M4.5 3L7.5 6L4.5 9"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {isExpanded && (
+        <div className="tool-details">
+          <div className="tool-section">
+            <div className="tool-label">Dimensions:</div>
+            <div className="tool-code">
+              {width} Γ— {height} pixels
+            </div>
+          </div>
+
+          {isComplete && output && (
+            <div className="tool-section">
+              <div className="tool-label">
+                Output{hasError ? " (Error)" : ""}:
+                {executionTime && <span className="tool-time">{executionTime}</span>}
+              </div>
+              <pre className={`tool-code ${hasError ? "error" : ""}`}>{output}</pre>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default BrowserResizeTool;

ui/src/components/ChangeDirTool.tsx πŸ”—

@@ -0,0 +1,101 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface ChangeDirToolProps {
+  // For tool_use (pending state)
+  toolInput?: unknown; // { path: string }
+  isRunning?: boolean;
+
+  // For tool_result (completed state)
+  toolResult?: LLMContent[];
+  hasError?: boolean;
+  executionTime?: string;
+}
+
+function ChangeDirTool({
+  toolInput,
+  isRunning,
+  toolResult,
+  hasError,
+  executionTime,
+}: ChangeDirToolProps) {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  // Extract path from toolInput
+  const path =
+    typeof toolInput === "object" &&
+    toolInput !== null &&
+    "path" in toolInput &&
+    typeof (toolInput as { path: unknown }).path === "string"
+      ? (toolInput as { path: string }).path
+      : "";
+
+  // Get result text
+  const resultText =
+    toolResult
+      ?.map((r) => r.Text)
+      .filter(Boolean)
+      .join("") || "";
+
+  const isComplete = !isRunning && toolResult !== undefined;
+
+  return (
+    <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+      <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+        <div className="tool-summary">
+          <span className={`tool-emoji ${isRunning ? "running" : ""}`}>πŸ“‚</span>
+          <span className="tool-command">cd {path || "..."}</span>
+          {isComplete && hasError && <span className="tool-error">βœ—</span>}
+          {isComplete && !hasError && <span className="tool-success">βœ“</span>}
+        </div>
+        <button
+          className="tool-toggle"
+          aria-label={isExpanded ? "Collapse" : "Expand"}
+          aria-expanded={isExpanded}
+        >
+          <svg
+            width="12"
+            height="12"
+            viewBox="0 0 12 12"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+            style={{
+              transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+              transition: "transform 0.2s",
+            }}
+          >
+            <path
+              d="M4.5 3L7.5 6L4.5 9"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {isExpanded && (
+        <div className="tool-details">
+          <div className="tool-section">
+            <div className="tool-label">
+              Path:
+              {executionTime && <span className="tool-time">{executionTime}</span>}
+            </div>
+            <div className={`tool-code ${hasError ? "error" : ""}`}>{path || "(no path)"}</div>
+          </div>
+          {isComplete && (
+            <div className="tool-section">
+              <div className="tool-label">Result:</div>
+              <div className={`tool-code ${hasError ? "error" : ""}`}>
+                {resultText || "(no output)"}
+              </div>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default ChangeDirTool;

ui/src/components/ChatInterface.tsx πŸ”—

@@ -0,0 +1,1283 @@
+import React, { useState, useEffect, useRef } from "react";
+import { Message, Conversation, StreamResponse, LLMContent } from "../types";
+import { api } from "../services/api";
+import MessageComponent from "./Message";
+import MessageInput from "./MessageInput";
+import DiffViewer from "./DiffViewer";
+import BashTool from "./BashTool";
+import PatchTool from "./PatchTool";
+import ScreenshotTool from "./ScreenshotTool";
+import ThinkTool from "./ThinkTool";
+import KeywordSearchTool from "./KeywordSearchTool";
+import BrowserNavigateTool from "./BrowserNavigateTool";
+import BrowserEvalTool from "./BrowserEvalTool";
+import ReadImageTool from "./ReadImageTool";
+import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool";
+import ChangeDirTool from "./ChangeDirTool";
+import BrowserResizeTool from "./BrowserResizeTool";
+import DirectoryPickerModal from "./DirectoryPickerModal";
+
+interface ContextUsageBarProps {
+  contextWindowSize: number;
+  maxContextTokens: number;
+}
+
+function ContextUsageBar({ contextWindowSize, maxContextTokens }: ContextUsageBarProps) {
+  const [showPopup, setShowPopup] = useState(false);
+  const barRef = useRef<HTMLDivElement>(null);
+
+  const percentage = maxContextTokens > 0 ? (contextWindowSize / maxContextTokens) * 100 : 0;
+  const clampedPercentage = Math.min(percentage, 100);
+
+  const getBarColor = () => {
+    if (percentage >= 90) return "var(--error-text)";
+    if (percentage >= 70) return "var(--warning-text, #f59e0b)";
+    return "var(--blue-text)";
+  };
+
+  const formatTokens = (tokens: number) => {
+    if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
+    if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}k`;
+    return tokens.toString();
+  };
+
+  const handleClick = () => {
+    setShowPopup(!showPopup);
+  };
+
+  // Close popup when clicking outside
+  useEffect(() => {
+    if (!showPopup) return;
+    const handleClickOutside = (e: MouseEvent) => {
+      if (barRef.current && !barRef.current.contains(e.target as Node)) {
+        setShowPopup(false);
+      }
+    };
+    document.addEventListener("click", handleClickOutside);
+    return () => document.removeEventListener("click", handleClickOutside);
+  }, [showPopup]);
+
+  // Calculate fixed position when popup should be shown
+  const [popupPosition, setPopupPosition] = useState<{ bottom: number; right: number } | null>(
+    null,
+  );
+
+  useEffect(() => {
+    if (showPopup && barRef.current) {
+      const rect = barRef.current.getBoundingClientRect();
+      setPopupPosition({
+        bottom: window.innerHeight - rect.top + 4,
+        right: window.innerWidth - rect.right,
+      });
+    } else {
+      setPopupPosition(null);
+    }
+  }, [showPopup]);
+
+  return (
+    <div ref={barRef}>
+      {showPopup && popupPosition && (
+        <div
+          style={{
+            position: "fixed",
+            bottom: popupPosition.bottom,
+            right: popupPosition.right,
+            padding: "6px 10px",
+            backgroundColor: "var(--bg-secondary)",
+            border: "1px solid var(--border-color)",
+            borderRadius: "4px",
+            fontSize: "12px",
+            color: "var(--text-secondary)",
+            whiteSpace: "nowrap",
+            boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
+            zIndex: 100,
+          }}
+        >
+          {formatTokens(contextWindowSize)} / {formatTokens(maxContextTokens)} (
+          {percentage.toFixed(1)}%) tokens used
+        </div>
+      )}
+      <div
+        className="context-usage-bar"
+        onClick={handleClick}
+        title={`Context: ${formatTokens(contextWindowSize)} / ${formatTokens(maxContextTokens)} tokens (${percentage.toFixed(1)}%)`}
+      >
+        <div
+          className="context-usage-fill"
+          style={{
+            width: `${clampedPercentage}%`,
+            backgroundColor: getBarColor(),
+          }}
+        />
+      </div>
+    </div>
+  );
+}
+
+interface CoalescedToolCallProps {
+  toolName: string;
+  toolInput?: unknown;
+  toolResult?: LLMContent[];
+  toolError?: boolean;
+  toolStartTime?: string | null;
+  toolEndTime?: string | null;
+  hasResult?: boolean;
+  display?: unknown;
+}
+
+// Map tool names to their specialized components.
+// IMPORTANT: When adding a new tool here, also add it to Message.tsx renderContent()
+// for both tool_use and tool_result cases. See AGENT.md in this directory.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const TOOL_COMPONENTS: Record<string, React.ComponentType<any>> = {
+  bash: BashTool,
+  patch: PatchTool,
+  screenshot: ScreenshotTool,
+  browser_take_screenshot: ScreenshotTool,
+  think: ThinkTool,
+  keyword_search: KeywordSearchTool,
+  browser_navigate: BrowserNavigateTool,
+  browser_eval: BrowserEvalTool,
+  read_image: ReadImageTool,
+  browser_recent_console_logs: BrowserConsoleLogsTool,
+  browser_clear_console_logs: BrowserConsoleLogsTool,
+  change_dir: ChangeDirTool,
+  browser_resize: BrowserResizeTool,
+};
+
+function CoalescedToolCall({
+  toolName,
+  toolInput,
+  toolResult,
+  toolError,
+  toolStartTime,
+  toolEndTime,
+  hasResult,
+  display,
+}: CoalescedToolCallProps) {
+  // Calculate execution time if available
+  let executionTime = "";
+  if (hasResult && toolStartTime && toolEndTime) {
+    const start = new Date(toolStartTime).getTime();
+    const end = new Date(toolEndTime).getTime();
+    const diffMs = end - start;
+    if (diffMs < 1000) {
+      executionTime = `${diffMs}ms`;
+    } else {
+      executionTime = `${(diffMs / 1000).toFixed(1)}s`;
+    }
+  }
+
+  // Look up the specialized component for this tool
+  const ToolComponent = TOOL_COMPONENTS[toolName];
+  if (ToolComponent) {
+    const props = {
+      toolInput,
+      isRunning: !hasResult,
+      toolResult,
+      hasError: toolError,
+      executionTime,
+      display,
+      // BrowserConsoleLogsTool needs the toolName prop
+      ...(toolName === "browser_recent_console_logs" || toolName === "browser_clear_console_logs"
+        ? { toolName }
+        : {}),
+    };
+    return <ToolComponent {...props} />;
+  }
+
+  const getToolResultSummary = (results: LLMContent[]) => {
+    if (!results || results.length === 0) return "No output";
+
+    const firstResult = results[0];
+    if (firstResult.Type === 2 && firstResult.Text) {
+      // text content
+      const text = firstResult.Text.trim();
+      if (text.length <= 50) return text;
+      return text.substring(0, 47) + "...";
+    }
+
+    return `${results.length} result${results.length > 1 ? "s" : ""}`;
+  };
+
+  const renderContent = (content: LLMContent) => {
+    if (content.Type === 2) {
+      // text
+      return <div className="whitespace-pre-wrap break-words">{content.Text || ""}</div>;
+    }
+    return <div className="text-secondary text-sm italic">[Content type {content.Type}]</div>;
+  };
+
+  if (!hasResult) {
+    // Show "running" state
+    return (
+      <div className="message message-tool" data-testid="tool-call-running">
+        <div className="message-content">
+          <div className="tool-running">
+            <div className="tool-running-header">
+              <svg
+                fill="none"
+                stroke="currentColor"
+                viewBox="0 0 24 24"
+                style={{ width: "1rem", height: "1rem", color: "var(--blue-text)" }}
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
+                />
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
+                />
+              </svg>
+              <span className="tool-name">Tool: {toolName}</span>
+              <span className="tool-status-running">(running)</span>
+            </div>
+            <div className="tool-input">
+              {typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput, null, 2)}
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  // Show completed state with result
+  const summary = toolResult ? getToolResultSummary(toolResult) : "No output";
+
+  return (
+    <div className="message message-tool" data-testid="tool-call-completed">
+      <div className="message-content">
+        <details className={`tool-result-details ${toolError ? "error" : ""}`}>
+          <summary className="tool-result-summary">
+            <div className="tool-result-meta">
+              <div className="flex items-center space-x-2">
+                <svg
+                  fill="none"
+                  stroke="currentColor"
+                  viewBox="0 0 24 24"
+                  style={{ width: "1rem", height: "1rem", color: "var(--blue-text)" }}
+                >
+                  <path
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                    strokeWidth={2}
+                    d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
+                  />
+                  <path
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                    strokeWidth={2}
+                    d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
+                  />
+                </svg>
+                <span className="text-sm font-medium text-blue">{toolName}</span>
+                <span className={`tool-result-status text-xs ${toolError ? "error" : "success"}`}>
+                  {toolError ? "βœ—" : "βœ“"} {summary}
+                </span>
+              </div>
+              <div className="tool-result-time">
+                {executionTime && <span>{executionTime}</span>}
+              </div>
+            </div>
+          </summary>
+          <div className="tool-result-content">
+            {/* Show tool input */}
+            <div className="tool-result-section">
+              <div className="tool-result-label">Input:</div>
+              <div className="tool-result-data">
+                {toolInput ? (
+                  typeof toolInput === "string" ? (
+                    toolInput
+                  ) : (
+                    JSON.stringify(toolInput, null, 2)
+                  )
+                ) : (
+                  <span className="text-secondary italic">No input data</span>
+                )}
+              </div>
+            </div>
+
+            {/* Show tool output with header */}
+            <div className={`tool-result-section output ${toolError ? "error" : ""}`}>
+              <div className="tool-result-label">Output{toolError ? " (Error)" : ""}:</div>
+              <div className="space-y-2">
+                {toolResult?.map((result, idx) => (
+                  <div key={idx}>{renderContent(result)}</div>
+                ))}
+              </div>
+            </div>
+          </div>
+        </details>
+      </div>
+    </div>
+  );
+}
+
+// Animated "Agent working..." with letter-by-letter bold animation
+function AnimatedWorkingStatus() {
+  const text = "Agent working...";
+  const [boldIndex, setBoldIndex] = useState(0);
+
+  useEffect(() => {
+    const interval = setInterval(() => {
+      setBoldIndex((prev) => (prev + 1) % text.length);
+    }, 100); // 100ms per letter
+    return () => clearInterval(interval);
+  }, []);
+
+  return (
+    <span className="status-message animated-working">
+      {text.split("").map((char, idx) => (
+        <span key={idx} className={idx === boldIndex ? "bold-letter" : ""}>
+          {char}
+        </span>
+      ))}
+    </span>
+  );
+}
+
+interface ChatInterfaceProps {
+  conversationId: string | null;
+  onOpenDrawer: () => void;
+  onNewConversation: () => void;
+  currentConversation?: Conversation;
+  onConversationUpdate?: (conversation: Conversation) => void;
+  onFirstMessage?: (message: string, model: string, cwd?: string) => Promise<void>;
+  mostRecentCwd?: string | null;
+}
+
+function ChatInterface({
+  conversationId,
+  onOpenDrawer,
+  onNewConversation,
+  currentConversation,
+  onConversationUpdate,
+  onFirstMessage,
+  mostRecentCwd,
+}: ChatInterfaceProps) {
+  const [messages, setMessages] = useState<Message[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [sending, setSending] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const models = window.__SHELLEY_INIT__?.models || [];
+  const [selectedModel, setSelectedModelState] = useState<string>(() => {
+    // First check localStorage for a sticky model preference
+    const storedModel = localStorage.getItem("shelley_selected_model");
+    const initModels = window.__SHELLEY_INIT__?.models || [];
+    // Validate that the stored model exists and is ready
+    if (storedModel) {
+      const modelInfo = initModels.find((m) => m.id === storedModel);
+      if (modelInfo?.ready) {
+        return storedModel;
+      }
+    }
+    // Fall back to server default or first ready model
+    const defaultModel = window.__SHELLEY_INIT__?.default_model;
+    if (defaultModel) {
+      return defaultModel;
+    }
+    const firstReady = initModels.find((m) => m.ready);
+    return firstReady?.id || "claude-sonnet-4.5";
+  });
+  // Wrapper to persist model selection to localStorage
+  const setSelectedModel = (model: string) => {
+    setSelectedModelState(model);
+    localStorage.setItem("shelley_selected_model", model);
+  };
+  const [selectedCwd, setSelectedCwdState] = useState<string>("");
+  const [cwdInitialized, setCwdInitialized] = useState(false);
+  // Wrapper to persist cwd selection to localStorage
+  const setSelectedCwd = (cwd: string) => {
+    setSelectedCwdState(cwd);
+    localStorage.setItem("shelley_selected_cwd", cwd);
+  };
+
+  // Initialize CWD with priority: localStorage > mostRecentCwd > server default
+  useEffect(() => {
+    if (cwdInitialized) return;
+
+    // First check localStorage for a sticky cwd preference
+    const storedCwd = localStorage.getItem("shelley_selected_cwd");
+    if (storedCwd) {
+      setSelectedCwdState(storedCwd);
+      setCwdInitialized(true);
+      return;
+    }
+
+    // Use most recent conversation's CWD if available
+    if (mostRecentCwd) {
+      setSelectedCwdState(mostRecentCwd);
+      setCwdInitialized(true);
+      return;
+    }
+
+    // Fall back to server default
+    const defaultCwd = window.__SHELLEY_INIT__?.default_cwd || "";
+    if (defaultCwd) {
+      setSelectedCwdState(defaultCwd);
+      setCwdInitialized(true);
+    }
+  }, [mostRecentCwd, cwdInitialized]);
+  const [cwdError, setCwdError] = useState<string | null>(null);
+  const [editingModel, setEditingModel] = useState(false);
+  const [showDirectoryPicker, setShowDirectoryPicker] = useState(false);
+  // Settings modal removed - configuration moved to status bar for empty conversations
+  const [showOverflowMenu, setShowOverflowMenu] = useState(false);
+  const [showDiffViewer, setShowDiffViewer] = useState(false);
+  const [diffCommentText, setDiffCommentText] = useState("");
+  const [agentWorking, setAgentWorking] = useState(false);
+  const [cancelling, setCancelling] = useState(false);
+  const [contextWindowSize, setContextWindowSize] = useState(0);
+  const terminalURL = window.__SHELLEY_INIT__?.terminal_url || null;
+  const links = window.__SHELLEY_INIT__?.links || [];
+  const hostname = window.__SHELLEY_INIT__?.hostname || "localhost";
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  const [reconnectAttempts, setReconnectAttempts] = useState(0);
+  const [isDisconnected, setIsDisconnected] = useState(false);
+  const [showScrollToBottom, setShowScrollToBottom] = useState(false);
+  const messagesEndRef = useRef<HTMLDivElement>(null);
+  const messagesContainerRef = useRef<HTMLDivElement>(null);
+  const eventSourceRef = useRef<EventSource | null>(null);
+  const overflowMenuRef = useRef<HTMLDivElement>(null);
+  const reconnectTimeoutRef = useRef<number | null>(null);
+  const userScrolledRef = useRef(false);
+
+  // Load messages and set up streaming
+  useEffect(() => {
+    if (conversationId) {
+      setAgentWorking(false);
+      loadMessages();
+      setupMessageStream();
+    } else {
+      // No conversation yet, show empty state
+      setMessages([]);
+      setLoading(false);
+    }
+
+    return () => {
+      if (eventSourceRef.current) {
+        eventSourceRef.current.close();
+      }
+      if (reconnectTimeoutRef.current) {
+        clearTimeout(reconnectTimeoutRef.current);
+      }
+    };
+  }, [conversationId]);
+
+  // Check scroll position and handle scroll-to-bottom button
+  useEffect(() => {
+    const container = messagesContainerRef.current;
+    if (!container) return;
+
+    const handleScroll = () => {
+      const { scrollTop, scrollHeight, clientHeight } = container;
+      const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
+      setShowScrollToBottom(!isNearBottom);
+      userScrolledRef.current = !isNearBottom;
+    };
+
+    container.addEventListener("scroll", handleScroll);
+    return () => container.removeEventListener("scroll", handleScroll);
+  }, []);
+
+  // Auto-scroll to bottom when new messages arrive (only if user is already at bottom)
+  useEffect(() => {
+    if (!userScrolledRef.current) {
+      scrollToBottom();
+    }
+  }, [messages]);
+
+  // Close overflow menu when clicking outside
+  useEffect(() => {
+    const handleClickOutside = (event: MouseEvent) => {
+      if (overflowMenuRef.current && !overflowMenuRef.current.contains(event.target as Node)) {
+        setShowOverflowMenu(false);
+      }
+    };
+
+    if (showOverflowMenu) {
+      document.addEventListener("mousedown", handleClickOutside);
+      return () => {
+        document.removeEventListener("mousedown", handleClickOutside);
+      };
+    }
+  }, [showOverflowMenu]);
+
+  const loadMessages = async () => {
+    if (!conversationId) return;
+    try {
+      setLoading(true);
+      setError(null);
+      const response = await api.getConversation(conversationId);
+      setMessages(response.messages ?? []);
+      setAgentWorking(Boolean(response.agent_working));
+      if (typeof response.context_window_size === "number") {
+        setContextWindowSize(response.context_window_size);
+      }
+      if (onConversationUpdate) {
+        onConversationUpdate(response.conversation);
+      }
+    } catch (err) {
+      console.error("Failed to load messages:", err);
+      setError("Failed to load messages");
+    } finally {
+      // Always set loading to false, even if other operations fail
+      setLoading(false);
+    }
+  };
+
+  const setupMessageStream = () => {
+    if (!conversationId) return;
+
+    if (eventSourceRef.current) {
+      eventSourceRef.current.close();
+    }
+
+    const eventSource = api.createMessageStream(conversationId);
+    eventSourceRef.current = eventSource;
+
+    eventSource.onmessage = (event) => {
+      try {
+        const streamResponse: StreamResponse = JSON.parse(event.data);
+        const incomingMessages = Array.isArray(streamResponse.messages)
+          ? streamResponse.messages
+          : [];
+
+        // Merge new messages without losing existing ones.
+        // If no new messages (e.g., only conversation/slug update), keep existing list.
+        if (incomingMessages.length > 0) {
+          setMessages((prev) => {
+            const byId = new Map<string, Message>();
+            for (const m of prev) byId.set(m.message_id, m);
+            for (const m of incomingMessages) byId.set(m.message_id, m);
+            // Preserve original order, then append truly new ones in the order received
+            const result: Message[] = [];
+            for (const m of prev) result.push(byId.get(m.message_id)!);
+            for (const m of incomingMessages) {
+              if (!prev.find((p) => p.message_id === m.message_id)) result.push(m);
+            }
+            return result;
+          });
+        }
+
+        // Update conversation data if provided
+        if (onConversationUpdate) {
+          onConversationUpdate(streamResponse.conversation);
+        }
+
+        if (typeof streamResponse.agent_working === "boolean") {
+          setAgentWorking(streamResponse.agent_working);
+        }
+
+        if (typeof streamResponse.context_window_size === "number") {
+          setContextWindowSize(streamResponse.context_window_size);
+        }
+      } catch (err) {
+        console.error("Failed to parse message stream data:", err);
+      }
+    };
+
+    eventSource.onerror = (event) => {
+      console.warn("Message stream error (will retry):", event);
+      // Close and retry after a delay
+      if (eventSourceRef.current) {
+        eventSourceRef.current.close();
+        eventSourceRef.current = null;
+      }
+
+      // Backoff delays: 1s, 5s, 10s, then give up
+      const delays = [1000, 5000, 10000];
+
+      setReconnectAttempts((prev) => {
+        const attempts = prev + 1;
+
+        if (attempts > delays.length) {
+          // Give up and show disconnected UI
+          setIsDisconnected(true);
+          return attempts;
+        }
+
+        const delay = delays[attempts - 1];
+        console.log(`Reconnecting in ${delay}ms (attempt ${attempts}/${delays.length})`);
+
+        reconnectTimeoutRef.current = window.setTimeout(() => {
+          if (eventSourceRef.current === null) {
+            setupMessageStream();
+          }
+        }, delay);
+
+        return attempts;
+      });
+    };
+
+    eventSource.onopen = () => {
+      console.log("Message stream connected");
+      // Reset reconnect attempts on successful connection
+      setReconnectAttempts(0);
+      setIsDisconnected(false);
+    };
+  };
+
+  const sendMessage = async (message: string) => {
+    if (!message.trim() || sending) return;
+
+    try {
+      setSending(true);
+      setError(null);
+      setAgentWorking(true);
+
+      // If no conversation ID, this is the first message - validate cwd first
+      if (!conversationId && onFirstMessage) {
+        // Validate cwd if provided
+        if (selectedCwd) {
+          const validation = await api.validateCwd(selectedCwd);
+          if (!validation.valid) {
+            throw new Error(`Invalid working directory: ${validation.error}`);
+          }
+        }
+        await onFirstMessage(message.trim(), selectedModel, selectedCwd || undefined);
+      } else if (conversationId) {
+        await api.sendMessage(conversationId, {
+          message: message.trim(),
+          model: selectedModel,
+        });
+      }
+    } catch (err) {
+      console.error("Failed to send message:", err);
+      const message = err instanceof Error ? err.message : "Unknown error";
+      setError(message);
+      setAgentWorking(false);
+      throw err; // Re-throw so MessageInput can preserve the text
+    } finally {
+      setSending(false);
+    }
+  };
+
+  const scrollToBottom = () => {
+    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+    userScrolledRef.current = false;
+    setShowScrollToBottom(false);
+  };
+
+  const handleManualReconnect = () => {
+    setIsDisconnected(false);
+    setReconnectAttempts(0);
+    if (reconnectTimeoutRef.current) {
+      clearTimeout(reconnectTimeoutRef.current);
+      reconnectTimeoutRef.current = null;
+    }
+    setupMessageStream();
+  };
+
+  const handleCancel = async () => {
+    if (!conversationId || cancelling) return;
+
+    try {
+      setCancelling(true);
+      await api.cancelConversation(conversationId);
+      setAgentWorking(false);
+    } catch (err) {
+      console.error("Failed to cancel conversation:", err);
+      setError("Failed to cancel. Please try again.");
+    } finally {
+      setCancelling(false);
+    }
+  };
+
+  const getDisplayTitle = () => {
+    return currentConversation?.slug || "Shelley";
+  };
+
+  // Process messages to coalesce tool calls
+  const processMessages = () => {
+    if (messages.length === 0) {
+      return [];
+    }
+
+    interface CoalescedItem {
+      type: "message" | "tool";
+      message?: Message;
+      toolUseId?: string;
+      toolName?: string;
+      toolInput?: unknown;
+      toolResult?: LLMContent[];
+      toolError?: boolean;
+      toolStartTime?: string | null;
+      toolEndTime?: string | null;
+      hasResult?: boolean;
+      display?: unknown;
+    }
+
+    const coalescedItems: CoalescedItem[] = [];
+    const toolResultMap: Record<
+      string,
+      {
+        result: LLMContent[];
+        error: boolean;
+        startTime: string | null;
+        endTime: string | null;
+      }
+    > = {};
+    // Some tool results may be delivered only as display_data (e.g., screenshots)
+    const displayResultSet: Set<string> = new Set();
+    const displayDataMap: Record<string, unknown> = {};
+
+    // First pass: collect all tool results
+    messages.forEach((message) => {
+      // Collect tool_result data from llm_data if present
+      if (message.llm_data) {
+        try {
+          const llmData =
+            typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
+          if (llmData && llmData.Content && Array.isArray(llmData.Content)) {
+            llmData.Content.forEach((content: LLMContent) => {
+              if (content && content.Type === 6 && content.ToolUseID) {
+                // tool_result
+                toolResultMap[content.ToolUseID] = {
+                  result: content.ToolResult || [],
+                  error: content.ToolError || false,
+                  startTime: content.ToolUseStartTime || null,
+                  endTime: content.ToolUseEndTime || null,
+                };
+              }
+            });
+          }
+        } catch (err) {
+          console.error("Failed to parse message LLM data for tool results:", err);
+        }
+      }
+
+      // Also collect tool_use_ids from display_data to mark completion even if llm_data is omitted
+      if (message.display_data) {
+        try {
+          const displays =
+            typeof message.display_data === "string"
+              ? JSON.parse(message.display_data)
+              : message.display_data;
+          if (Array.isArray(displays)) {
+            for (const d of displays) {
+              if (
+                d &&
+                typeof d === "object" &&
+                "tool_use_id" in d &&
+                typeof d.tool_use_id === "string"
+              ) {
+                displayResultSet.add(d.tool_use_id);
+                // Store the display data for this tool use
+                if ("display" in d) {
+                  displayDataMap[d.tool_use_id] = d.display;
+                }
+              }
+            }
+          }
+        } catch (err) {
+          console.error("Failed to parse display_data for tool completion:", err);
+        }
+      }
+    });
+
+    // Second pass: process messages and extract tool uses
+    messages.forEach((message) => {
+      // Skip system messages
+      if (message.type === "system") {
+        return;
+      }
+
+      if (message.type === "error") {
+        coalescedItems.push({ type: "message", message });
+        return;
+      }
+
+      // Check if this is a user message with tool results (skip rendering them as messages)
+      let hasToolResult = false;
+      if (message.llm_data) {
+        try {
+          const llmData =
+            typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
+          if (llmData && llmData.Content && Array.isArray(llmData.Content)) {
+            hasToolResult = llmData.Content.some((c: LLMContent) => c.Type === 6);
+          }
+        } catch (err) {
+          console.error("Failed to parse message LLM data:", err);
+        }
+      }
+
+      // If it's a user message without tool results, show it
+      if (message.type === "user" && !hasToolResult) {
+        coalescedItems.push({ type: "message", message });
+        return;
+      }
+
+      // If it's a user message with tool results, skip it (we'll handle it via the toolResultMap)
+      if (message.type === "user" && hasToolResult) {
+        return;
+      }
+
+      if (message.llm_data) {
+        try {
+          const llmData =
+            typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
+          if (llmData && llmData.Content && Array.isArray(llmData.Content)) {
+            // Extract text content and tool uses separately
+            const textContents: LLMContent[] = [];
+            const toolUses: LLMContent[] = [];
+
+            llmData.Content.forEach((content: LLMContent) => {
+              if (content.Type === 2) {
+                // text
+                textContents.push(content);
+              } else if (content.Type === 5) {
+                // tool_use
+                toolUses.push(content);
+              }
+            });
+
+            // If we have text content, add it as a message (but only if it's not empty)
+            const textString = textContents
+              .map((c) => c.Text || "")
+              .join("")
+              .trim();
+            if (textString) {
+              coalescedItems.push({ type: "message", message });
+            }
+
+            // Add tool uses as separate items
+            toolUses.forEach((toolUse) => {
+              const resultData = toolUse.ID ? toolResultMap[toolUse.ID] : undefined;
+              const completedViaDisplay = toolUse.ID ? displayResultSet.has(toolUse.ID) : false;
+              const displayData = toolUse.ID ? displayDataMap[toolUse.ID] : undefined;
+              coalescedItems.push({
+                type: "tool",
+                toolUseId: toolUse.ID,
+                toolName: toolUse.ToolName,
+                toolInput: toolUse.ToolInput,
+                toolResult: resultData?.result,
+                toolError: resultData?.error,
+                toolStartTime: resultData?.startTime,
+                toolEndTime: resultData?.endTime,
+                hasResult: !!resultData || completedViaDisplay,
+                display: displayData,
+              });
+            });
+          }
+        } catch (err) {
+          console.error("Failed to parse message LLM data:", err);
+          coalescedItems.push({ type: "message", message });
+        }
+      } else {
+        coalescedItems.push({ type: "message", message });
+      }
+    });
+
+    return coalescedItems;
+  };
+
+  const renderMessages = () => {
+    if (messages.length === 0) {
+      const proxyURL = `https://${hostname}/`;
+      return (
+        <div className="empty-state">
+          <div className="empty-state-content">
+            <p className="text-base" style={{ marginBottom: "1rem", lineHeight: "1.6" }}>
+              Shelley is an agent, running on <strong>{hostname}</strong>. You can ask Shelley to do
+              stuff. If you build a web site with Shelley, you can use exe.dev&apos;s proxy features
+              (see{" "}
+              <a
+                href="https://exe.dev/docs/proxy"
+                target="_blank"
+                rel="noopener noreferrer"
+                style={{ color: "var(--blue-text)", textDecoration: "underline" }}
+              >
+                docs
+              </a>
+              ) to visit it over the web at{" "}
+              <a
+                href={proxyURL}
+                target="_blank"
+                rel="noopener noreferrer"
+                style={{ color: "var(--blue-text)", textDecoration: "underline" }}
+              >
+                {proxyURL}
+              </a>
+              .
+            </p>
+            <p className="text-sm" style={{ color: "var(--text-secondary)" }}>
+              Send a message to start the conversation.
+            </p>
+          </div>
+        </div>
+      );
+    }
+
+    const coalescedItems = processMessages();
+
+    const rendered = coalescedItems.map((item, index) => {
+      if (item.type === "message" && item.message) {
+        return <MessageComponent key={item.message.message_id} message={item.message} />;
+      } else if (item.type === "tool") {
+        return (
+          <CoalescedToolCall
+            key={item.toolUseId || `tool-${index}`}
+            toolName={item.toolName || "Unknown Tool"}
+            toolInput={item.toolInput}
+            toolResult={item.toolResult}
+            toolError={item.toolError}
+            toolStartTime={item.toolStartTime}
+            toolEndTime={item.toolEndTime}
+            hasResult={item.hasResult}
+            display={item.display}
+          />
+        );
+      }
+      return null;
+    });
+
+    return rendered;
+  };
+
+  return (
+    <div className="full-height flex flex-col">
+      {/* Header */}
+      <div className="header">
+        <div className="header-left">
+          <button
+            onClick={onOpenDrawer}
+            className="btn-icon hide-on-desktop"
+            aria-label="Open conversations"
+          >
+            <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2}
+                d="M4 6h16M4 12h16M4 18h16"
+              />
+            </svg>
+          </button>
+
+          <h1 className="header-title" title={currentConversation?.slug || "Shelley"}>
+            {getDisplayTitle()}
+          </h1>
+        </div>
+
+        <div className="header-actions">
+          {/* Green + icon in circle for new conversation */}
+          <button onClick={onNewConversation} className="btn-new" aria-label="New conversation">
+            <svg
+              fill="none"
+              stroke="currentColor"
+              viewBox="0 0 24 24"
+              style={{ width: "1rem", height: "1rem" }}
+            >
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2}
+                d="M12 4v16m8-8H4"
+              />
+            </svg>
+          </button>
+
+          {/* Overflow menu */}
+          <div ref={overflowMenuRef} style={{ position: "relative" }}>
+            <button
+              onClick={() => setShowOverflowMenu(!showOverflowMenu)}
+              className="btn-icon"
+              aria-label="More options"
+            >
+              <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
+                />
+              </svg>
+            </button>
+
+            {showOverflowMenu && (
+              <div className="overflow-menu">
+                {/* Diffs button - show when we have a CWD */}
+                {(currentConversation?.cwd || selectedCwd) && (
+                  <button
+                    onClick={() => {
+                      setShowOverflowMenu(false);
+                      setShowDiffViewer(true);
+                    }}
+                    className="overflow-menu-item"
+                  >
+                    <svg
+                      fill="none"
+                      stroke="currentColor"
+                      viewBox="0 0 24 24"
+                      style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
+                    >
+                      <path
+                        strokeLinecap="round"
+                        strokeLinejoin="round"
+                        strokeWidth={2}
+                        d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
+                      />
+                    </svg>
+                    Diffs
+                  </button>
+                )}
+                {terminalURL && (
+                  <button
+                    onClick={() => {
+                      setShowOverflowMenu(false);
+                      window.open(terminalURL, "_blank");
+                    }}
+                    className="overflow-menu-item"
+                  >
+                    <svg
+                      fill="none"
+                      stroke="currentColor"
+                      viewBox="0 0 24 24"
+                      style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
+                    >
+                      <path
+                        strokeLinecap="round"
+                        strokeLinejoin="round"
+                        strokeWidth={2}
+                        d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
+                      />
+                    </svg>
+                    Terminal
+                  </button>
+                )}
+                {links.map((link, index) => (
+                  <button
+                    key={index}
+                    onClick={() => {
+                      setShowOverflowMenu(false);
+                      window.open(link.url, "_blank");
+                    }}
+                    className="overflow-menu-item"
+                  >
+                    <svg
+                      fill="none"
+                      stroke="currentColor"
+                      viewBox="0 0 24 24"
+                      style={{ width: "1.25rem", height: "1.25rem", marginRight: "0.75rem" }}
+                    >
+                      <path
+                        strokeLinecap="round"
+                        strokeLinejoin="round"
+                        strokeWidth={2}
+                        d={
+                          link.icon_svg ||
+                          "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
+                        }
+                      />
+                    </svg>
+                    {link.title}
+                  </button>
+                ))}
+              </div>
+            )}
+          </div>
+        </div>
+      </div>
+
+      {/* Messages area */}
+      {/* Messages area with scroll-to-bottom button wrapper */}
+      <div className="messages-area-wrapper">
+        <div className="messages-container scrollable" ref={messagesContainerRef}>
+          {loading ? (
+            <div className="flex items-center justify-center full-height">
+              <div className="spinner"></div>
+            </div>
+          ) : (
+            <div className="messages-list">
+              {renderMessages()}
+
+              <div ref={messagesEndRef} />
+            </div>
+          )}
+        </div>
+
+        {/* Scroll to bottom button - outside scrollable area */}
+        {showScrollToBottom && (
+          <button
+            className="scroll-to-bottom-button"
+            onClick={scrollToBottom}
+            aria-label="Scroll to bottom"
+          >
+            <svg
+              fill="none"
+              stroke="currentColor"
+              viewBox="0 0 24 24"
+              style={{ width: "1.25rem", height: "1.25rem" }}
+            >
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2}
+                d="M19 14l-7 7m0 0l-7-7m7 7V3"
+              />
+            </svg>
+          </button>
+        )}
+      </div>
+
+      {/* Unified Status Bar */}
+      <div className="status-bar">
+        <div className="status-bar-content">
+          {isDisconnected ? (
+            // Disconnected state
+            <>
+              <span className="status-message status-warning">Disconnected</span>
+              <button
+                onClick={handleManualReconnect}
+                className="status-button status-button-primary"
+              >
+                Retry
+              </button>
+            </>
+          ) : error ? (
+            // Error state
+            <>
+              <span className="status-message status-error">{error}</span>
+              <button onClick={() => setError(null)} className="status-button status-button-text">
+                <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                  <path
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                    strokeWidth={2}
+                    d="M6 18L18 6M6 6l12 12"
+                  />
+                </svg>
+              </button>
+            </>
+          ) : agentWorking && conversationId ? (
+            // Agent working - show status with stop button and context bar
+            <div className="status-bar-active">
+              <div className="status-working-group">
+                <AnimatedWorkingStatus />
+                <button
+                  onClick={handleCancel}
+                  disabled={cancelling}
+                  className="status-stop-button"
+                  title={cancelling ? "Cancelling..." : "Stop"}
+                >
+                  <svg viewBox="0 0 24 24" fill="currentColor">
+                    <rect x="6" y="6" width="12" height="12" rx="1" />
+                  </svg>
+                  <span className="status-stop-label">{cancelling ? "Cancelling..." : "Stop"}</span>
+                </button>
+              </div>
+              <ContextUsageBar
+                contextWindowSize={contextWindowSize}
+                maxContextTokens={
+                  models.find((m) => m.id === selectedModel)?.max_context_tokens || 200000
+                }
+              />
+            </div>
+          ) : // Idle state - show ready message, or configuration for empty conversation
+          !conversationId ? (
+            // Empty conversation - show model (left) and cwd (right)
+            <div className="status-bar-new-conversation">
+              {/* Model selector - far left */}
+              <div
+                className="status-field status-field-model"
+                title="AI model to use for this conversation"
+              >
+                <span className="status-field-label">Model:</span>
+                {editingModel ? (
+                  <select
+                    id="model-select-status"
+                    value={selectedModel}
+                    onChange={(e) => setSelectedModel(e.target.value)}
+                    onBlur={() => setEditingModel(false)}
+                    disabled={sending}
+                    className="status-select"
+                    autoFocus
+                  >
+                    {models.map((model) => (
+                      <option key={model.id} value={model.id} disabled={!model.ready}>
+                        {model.id} {!model.ready ? "(not ready)" : ""}
+                      </option>
+                    ))}
+                  </select>
+                ) : (
+                  <button
+                    className="status-chip"
+                    onClick={() => setEditingModel(true)}
+                    disabled={sending}
+                  >
+                    {selectedModel}
+                  </button>
+                )}
+              </div>
+
+              {/* CWD indicator - far right */}
+              <div
+                className={`status-field status-field-cwd${cwdError ? " status-field-error" : ""}`}
+                title={cwdError || "Working directory for file operations"}
+              >
+                <span className="status-field-label">Dir:</span>
+                <button
+                  className={`status-chip${cwdError ? " status-chip-error" : ""}`}
+                  onClick={() => setShowDirectoryPicker(true)}
+                  disabled={sending}
+                >
+                  {selectedCwd || "(no cwd)"}
+                </button>
+              </div>
+            </div>
+          ) : (
+            // Active conversation - show Ready + context bar
+            <div className="status-bar-active">
+              <span className="status-message status-ready">Ready</span>
+              <ContextUsageBar
+                contextWindowSize={contextWindowSize}
+                maxContextTokens={
+                  models.find((m) => m.id === selectedModel)?.max_context_tokens || 200000
+                }
+              />
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* Message input */}
+      {/* Message input */}
+      <MessageInput
+        key={conversationId || "new"}
+        onSend={sendMessage}
+        disabled={sending || loading}
+        autoFocus={true}
+        injectedText={diffCommentText}
+        onClearInjectedText={() => setDiffCommentText("")}
+        persistKey={conversationId || "new-conversation"}
+      />
+
+      {/* Directory Picker Modal */}
+      <DirectoryPickerModal
+        isOpen={showDirectoryPicker}
+        onClose={() => setShowDirectoryPicker(false)}
+        onSelect={(path) => {
+          setSelectedCwd(path);
+          setCwdError(null);
+        }}
+        initialPath={selectedCwd}
+      />
+
+      {/* Diff Viewer */}
+      <DiffViewer
+        cwd={currentConversation?.cwd || selectedCwd}
+        isOpen={showDiffViewer}
+        onClose={() => setShowDiffViewer(false)}
+        onCommentTextChange={setDiffCommentText}
+      />
+    </div>
+  );
+}
+
+export default ChatInterface;

ui/src/components/ContextMenu.tsx πŸ”—

@@ -0,0 +1,105 @@
+import React from "react";
+
+interface ContextMenuProps {
+  x: number;
+  y: number;
+  onClose: () => void;
+  items: ContextMenuItem[];
+}
+
+interface ContextMenuItem {
+  label: string;
+  icon: React.ReactNode;
+  onClick: () => void;
+}
+
+function ContextMenu({ x, y, onClose, items }: ContextMenuProps) {
+  // Clamp menu within viewport
+  const vw = typeof window !== "undefined" ? window.innerWidth : 0;
+  const vh = typeof window !== "undefined" ? window.innerHeight : 0;
+  const menuWidth = 200;
+  const menuHeight = items.length * 44 + 8; // approximate height
+
+  const clampedX = Math.max(8, Math.min(x, vw - menuWidth - 8));
+  const clampedY = Math.max(8, Math.min(y, vh - menuHeight - 8));
+
+  // Close on any click outside (handled by parent)
+  React.useEffect(() => {
+    const handleClickOutside = (e: MouseEvent) => {
+      const target = e.target as HTMLElement;
+      if (!target.closest("[data-context-menu]")) {
+        onClose();
+      }
+    };
+
+    // Use capture phase to ensure we catch the click before other handlers
+    document.addEventListener("mousedown", handleClickOutside, true);
+    return () => document.removeEventListener("mousedown", handleClickOutside, true);
+  }, [onClose]);
+
+  // Close on escape key
+  React.useEffect(() => {
+    const handleEscape = (e: KeyboardEvent) => {
+      if (e.key === "Escape") {
+        onClose();
+      }
+    };
+    document.addEventListener("keydown", handleEscape);
+    return () => document.removeEventListener("keydown", handleEscape);
+  }, [onClose]);
+
+  return (
+    <div
+      data-context-menu
+      style={{
+        position: "fixed",
+        left: `${clampedX}px`,
+        top: `${clampedY}px`,
+        backgroundColor: "#ffffff",
+        border: "1px solid #e5e7eb",
+        borderRadius: "6px",
+        boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
+        zIndex: 10000,
+        minWidth: `${menuWidth}px`,
+        padding: "4px 0",
+      }}
+    >
+      {items.map((item, index) => (
+        <button
+          key={index}
+          onClick={() => {
+            item.onClick();
+            onClose();
+          }}
+          style={{
+            display: "flex",
+            alignItems: "center",
+            gap: "12px",
+            width: "100%",
+            padding: "10px 16px",
+            border: "none",
+            backgroundColor: "transparent",
+            cursor: "pointer",
+            fontSize: "14px",
+            color: "#1f2937",
+            textAlign: "left",
+            transition: "background-color 0.1s",
+          }}
+          onMouseEnter={(e) => {
+            e.currentTarget.style.backgroundColor = "#f3f4f6";
+          }}
+          onMouseLeave={(e) => {
+            e.currentTarget.style.backgroundColor = "transparent";
+          }}
+        >
+          <span style={{ display: "flex", alignItems: "center", width: "20px", height: "20px" }}>
+            {item.icon}
+          </span>
+          <span>{item.label}</span>
+        </button>
+      ))}
+    </div>
+  );
+}
+
+export default ContextMenu;

ui/src/components/ConversationDrawer.tsx πŸ”—

@@ -0,0 +1,451 @@
+import React, { useState, useEffect } from "react";
+import { Conversation } from "../types";
+import { api } from "../services/api";
+
+interface ConversationDrawerProps {
+  isOpen: boolean;
+  onClose: () => void;
+  conversations: Conversation[];
+  currentConversationId: string | null;
+  onSelectConversation: (id: string) => void;
+  onNewConversation: () => void;
+  onConversationArchived?: (id: string) => void;
+  onConversationUnarchived?: (conversation: Conversation) => void;
+  onConversationRenamed?: (conversation: Conversation) => void;
+}
+
+function ConversationDrawer({
+  isOpen,
+  onClose,
+  conversations,
+  currentConversationId,
+  onSelectConversation,
+  onNewConversation,
+  onConversationArchived,
+  onConversationUnarchived,
+  onConversationRenamed,
+}: ConversationDrawerProps) {
+  const [showArchived, setShowArchived] = useState(false);
+  const [archivedConversations, setArchivedConversations] = useState<Conversation[]>([]);
+  const [loadingArchived, setLoadingArchived] = useState(false);
+  const [editingId, setEditingId] = useState<string | null>(null);
+  const [editingSlug, setEditingSlug] = useState("");
+  const renameInputRef = React.useRef<HTMLInputElement>(null);
+
+  useEffect(() => {
+    if (showArchived && archivedConversations.length === 0) {
+      loadArchivedConversations();
+    }
+  }, [showArchived]);
+
+  const loadArchivedConversations = async () => {
+    setLoadingArchived(true);
+    try {
+      const archived = await api.getArchivedConversations();
+      setArchivedConversations(archived);
+    } catch (err) {
+      console.error("Failed to load archived conversations:", err);
+    } finally {
+      setLoadingArchived(false);
+    }
+  };
+
+  const formatDate = (timestamp: string) => {
+    const date = new Date(timestamp);
+    const now = new Date();
+    const diffMs = now.getTime() - date.getTime();
+    const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+    if (diffDays === 0) {
+      return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+    } else if (diffDays === 1) {
+      return "Yesterday";
+    } else if (diffDays < 7) {
+      return `${diffDays} days ago`;
+    } else {
+      return date.toLocaleDateString();
+    }
+  };
+
+  // Format cwd with ~ for home directory (display only)
+  const formatCwdForDisplay = (cwd: string | null | undefined): string | null => {
+    if (!cwd) return null;
+    const homeDir = window.__SHELLEY_INIT__?.home_dir;
+    if (homeDir && cwd === homeDir) {
+      return "~";
+    }
+    if (homeDir && cwd.startsWith(homeDir + "/")) {
+      return "~" + cwd.slice(homeDir.length);
+    }
+    return cwd;
+  };
+
+  const getConversationPreview = (conversation: Conversation) => {
+    if (conversation.slug) {
+      return conversation.slug;
+    }
+    // Show full conversation ID
+    return conversation.conversation_id;
+  };
+
+  const handleArchive = async (e: React.MouseEvent, conversationId: string) => {
+    e.stopPropagation();
+    try {
+      await api.archiveConversation(conversationId);
+      onConversationArchived?.(conversationId);
+      // Refresh archived list if viewing
+      if (showArchived) {
+        loadArchivedConversations();
+      }
+    } catch (err) {
+      console.error("Failed to archive conversation:", err);
+    }
+  };
+
+  const handleUnarchive = async (e: React.MouseEvent, conversationId: string) => {
+    e.stopPropagation();
+    try {
+      const conversation = await api.unarchiveConversation(conversationId);
+      setArchivedConversations((prev) => prev.filter((c) => c.conversation_id !== conversationId));
+      onConversationUnarchived?.(conversation);
+    } catch (err) {
+      console.error("Failed to unarchive conversation:", err);
+    }
+  };
+
+  const handleDelete = async (e: React.MouseEvent, conversationId: string) => {
+    e.stopPropagation();
+    if (!confirm("Are you sure you want to permanently delete this conversation?")) {
+      return;
+    }
+    try {
+      await api.deleteConversation(conversationId);
+      setArchivedConversations((prev) => prev.filter((c) => c.conversation_id !== conversationId));
+    } catch (err) {
+      console.error("Failed to delete conversation:", err);
+    }
+  };
+
+  // Sanitize slug: lowercase, alphanumeric and hyphens only, max 60 chars
+  const sanitizeSlug = (input: string): string => {
+    return input
+      .toLowerCase()
+      .replace(/[\s_]+/g, "-")
+      .replace(/[^a-z0-9-]+/g, "")
+      .replace(/-+/g, "-")
+      .replace(/^-|-$/g, "")
+      .slice(0, 60)
+      .replace(/-$/g, "");
+  };
+
+  const handleStartRename = (e: React.MouseEvent, conversation: Conversation) => {
+    e.stopPropagation();
+    setEditingId(conversation.conversation_id);
+    setEditingSlug(conversation.slug || "");
+    // Select all text after render
+    setTimeout(() => renameInputRef.current?.select(), 0);
+  };
+
+  const handleRename = async (conversationId: string) => {
+    const sanitized = sanitizeSlug(editingSlug);
+    if (!sanitized) {
+      setEditingId(null);
+      return;
+    }
+
+    // Check for uniqueness against current conversations
+    const isDuplicate = [...conversations, ...archivedConversations].some(
+      (c) => c.slug === sanitized && c.conversation_id !== conversationId,
+    );
+    if (isDuplicate) {
+      alert("A conversation with this name already exists");
+      return;
+    }
+
+    try {
+      const updated = await api.renameConversation(conversationId, sanitized);
+      onConversationRenamed?.(updated);
+      setEditingId(null);
+    } catch (err) {
+      console.error("Failed to rename conversation:", err);
+    }
+  };
+
+  const handleRenameKeyDown = (e: React.KeyboardEvent, conversationId: string) => {
+    if (e.key === "Enter") {
+      e.preventDefault();
+      handleRename(conversationId);
+    } else if (e.key === "Escape") {
+      setEditingId(null);
+    }
+  };
+
+  const displayedConversations = showArchived ? archivedConversations : conversations;
+
+  return (
+    <>
+      {/* Drawer */}
+      <div className={`drawer ${isOpen ? "open" : ""}`}>
+        {/* Header */}
+        <div className="drawer-header">
+          <h2 className="drawer-title">{showArchived ? "Archived" : "Conversations"}</h2>
+          <button
+            onClick={onClose}
+            className="btn-icon hide-on-desktop"
+            aria-label="Close conversations"
+          >
+            <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2}
+                d="M6 18L18 6M6 6l12 12"
+              />
+            </svg>
+          </button>
+        </div>
+
+        {/* New conversation button */}
+        {!showArchived && (
+          <div className="drawer-section">
+            <button
+              onClick={onNewConversation}
+              className="btn-primary"
+              style={{
+                width: "100%",
+                display: "flex",
+                alignItems: "center",
+                justifyContent: "center",
+                gap: "0.5rem",
+              }}
+            >
+              <svg
+                fill="none"
+                stroke="currentColor"
+                viewBox="0 0 24 24"
+                style={{ width: "1rem", height: "1rem" }}
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M12 4v16m8-8H4"
+                />
+              </svg>
+              <span>New Conversation</span>
+            </button>
+          </div>
+        )}
+
+        {/* Conversations list */}
+        <div className="drawer-body scrollable">
+          {loadingArchived && showArchived ? (
+            <div style={{ padding: "1rem", textAlign: "center" }} className="text-secondary">
+              <p>Loading...</p>
+            </div>
+          ) : displayedConversations.length === 0 ? (
+            <div style={{ padding: "1rem", textAlign: "center" }} className="text-secondary">
+              <p>{showArchived ? "No archived conversations" : "No conversations yet"}</p>
+              {!showArchived && (
+                <p className="text-sm" style={{ marginTop: "0.25rem" }}>
+                  Start a new conversation to get started
+                </p>
+              )}
+            </div>
+          ) : (
+            <div className="conversation-list">
+              {displayedConversations.map((conversation) => {
+                const isActive = conversation.conversation_id === currentConversationId;
+                return (
+                  <div
+                    key={conversation.conversation_id}
+                    className={`conversation-item ${isActive ? "active" : ""}`}
+                    onClick={() => {
+                      if (!showArchived) {
+                        onSelectConversation(conversation.conversation_id);
+                      }
+                    }}
+                    style={{ cursor: showArchived ? "default" : "pointer" }}
+                  >
+                    <div style={{ flex: 1, minWidth: 0 }}>
+                      {editingId === conversation.conversation_id ? (
+                        <input
+                          ref={renameInputRef}
+                          type="text"
+                          value={editingSlug}
+                          onChange={(e) => setEditingSlug(e.target.value)}
+                          onBlur={() => handleRename(conversation.conversation_id)}
+                          onKeyDown={(e) => handleRenameKeyDown(e, conversation.conversation_id)}
+                          onClick={(e) => e.stopPropagation()}
+                          autoFocus
+                          className="conversation-title"
+                          style={{
+                            width: "100%",
+                            background: "transparent",
+                            border: "none",
+                            borderBottom: "1px solid var(--text-secondary)",
+                            outline: "none",
+                            padding: 0,
+                            font: "inherit",
+                            color: "inherit",
+                          }}
+                        />
+                      ) : (
+                        <div className="conversation-title">
+                          {getConversationPreview(conversation)}
+                        </div>
+                      )}
+                      <div className="conversation-meta">
+                        <span className="conversation-date">
+                          {formatDate(conversation.updated_at)}
+                        </span>
+                        {conversation.cwd && (
+                          <span className="conversation-cwd" title={conversation.cwd}>
+                            {formatCwdForDisplay(conversation.cwd)}
+                          </span>
+                        )}
+                      </div>
+                    </div>
+                    <div
+                      className="conversation-actions"
+                      style={{ display: "flex", gap: "0.25rem", marginLeft: "0.5rem" }}
+                    >
+                      {showArchived ? (
+                        <>
+                          <button
+                            onClick={(e) => handleUnarchive(e, conversation.conversation_id)}
+                            className="btn-icon-sm"
+                            title="Restore"
+                            aria-label="Restore conversation"
+                          >
+                            <svg
+                              fill="none"
+                              stroke="currentColor"
+                              viewBox="0 0 24 24"
+                              style={{ width: "1rem", height: "1rem" }}
+                            >
+                              <path
+                                strokeLinecap="round"
+                                strokeLinejoin="round"
+                                strokeWidth={2}
+                                d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
+                              />
+                            </svg>
+                          </button>
+                          <button
+                            onClick={(e) => handleDelete(e, conversation.conversation_id)}
+                            className="btn-icon-sm btn-danger"
+                            title="Delete permanently"
+                            aria-label="Delete conversation"
+                          >
+                            <svg
+                              fill="none"
+                              stroke="currentColor"
+                              viewBox="0 0 24 24"
+                              style={{ width: "1rem", height: "1rem" }}
+                            >
+                              <path
+                                strokeLinecap="round"
+                                strokeLinejoin="round"
+                                strokeWidth={2}
+                                d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+                              />
+                            </svg>
+                          </button>
+                        </>
+                      ) : (
+                        <>
+                          <button
+                            onClick={(e) => handleStartRename(e, conversation)}
+                            className="btn-icon-sm"
+                            title="Rename"
+                            aria-label="Rename conversation"
+                          >
+                            <svg
+                              fill="none"
+                              stroke="currentColor"
+                              viewBox="0 0 24 24"
+                              style={{ width: "1rem", height: "1rem" }}
+                            >
+                              <path
+                                strokeLinecap="round"
+                                strokeLinejoin="round"
+                                strokeWidth={2}
+                                d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+                              />
+                            </svg>
+                          </button>
+                          <button
+                            onClick={(e) => handleArchive(e, conversation.conversation_id)}
+                            className="btn-icon-sm"
+                            title="Archive"
+                            aria-label="Archive conversation"
+                          >
+                            <svg
+                              fill="none"
+                              stroke="currentColor"
+                              viewBox="0 0 24 24"
+                              style={{ width: "1rem", height: "1rem" }}
+                            >
+                              <path
+                                strokeLinecap="round"
+                                strokeLinejoin="round"
+                                strokeWidth={2}
+                                d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
+                              />
+                            </svg>
+                          </button>
+                        </>
+                      )}
+                    </div>
+                  </div>
+                );
+              })}
+            </div>
+          )}
+        </div>
+
+        {/* Footer with archived toggle */}
+        <div className="drawer-footer">
+          <button
+            onClick={() => setShowArchived(!showArchived)}
+            className="btn-secondary"
+            style={{
+              width: "100%",
+              display: "flex",
+              alignItems: "center",
+              justifyContent: "center",
+              gap: "0.5rem",
+            }}
+          >
+            <svg
+              fill="none"
+              stroke="currentColor"
+              viewBox="0 0 24 24"
+              style={{ width: "1rem", height: "1rem" }}
+            >
+              {showArchived ? (
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M11 15l-3-3m0 0l3-3m-3 3h8M3 12a9 9 0 1118 0 9 9 0 01-18 0z"
+                />
+              ) : (
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
+                />
+              )}
+            </svg>
+            <span>{showArchived ? "Back to Conversations" : "View Archived"}</span>
+          </button>
+        </div>
+      </div>
+    </>
+  );
+}
+
+export default ConversationDrawer;

ui/src/components/DiffViewer.tsx πŸ”—

@@ -0,0 +1,757 @@
+import React, { useState, useEffect, useCallback, useRef } from "react";
+import type * as Monaco from "monaco-editor";
+import { api } from "../services/api";
+import { GitDiffInfo, GitFileInfo, GitFileDiff } from "../types";
+
+interface DiffViewerProps {
+  cwd: string;
+  isOpen: boolean;
+  onClose: () => void;
+  onCommentTextChange: (text: string) => void;
+}
+
+// Global Monaco instance - loaded lazily
+let monacoInstance: typeof Monaco | null = null;
+let monacoLoadPromise: Promise<typeof Monaco> | null = null;
+
+function loadMonaco(): Promise<typeof Monaco> {
+  if (monacoInstance) {
+    return Promise.resolve(monacoInstance);
+  }
+  if (monacoLoadPromise) {
+    return monacoLoadPromise;
+  }
+
+  monacoLoadPromise = (async () => {
+    // Configure Monaco environment for web workers before importing
+    const monacoEnv: Monaco.Environment = {
+      getWorkerUrl: () => "/editor.worker.js",
+    };
+    (self as Window).MonacoEnvironment = monacoEnv;
+
+    // Load Monaco CSS if not already loaded
+    if (!document.querySelector('link[href="/monaco-editor.css"]')) {
+      const link = document.createElement("link");
+      link.rel = "stylesheet";
+      link.href = "/monaco-editor.css";
+      document.head.appendChild(link);
+    }
+
+    // Load Monaco from our local bundle (runtime URL, cast to proper types)
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-ignore - dynamic runtime URL import
+    const monaco = (await import("/monaco-editor.js")) as typeof Monaco;
+    monacoInstance = monaco;
+    return monacoInstance;
+  })();
+
+  return monacoLoadPromise;
+}
+
+type ViewMode = "comment" | "edit";
+
+function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange }: DiffViewerProps) {
+  const [diffs, setDiffs] = useState<GitDiffInfo[]>([]);
+  const [gitRoot, setGitRoot] = useState<string | null>(null);
+  const [selectedDiff, setSelectedDiff] = useState<string | null>(null);
+  const [files, setFiles] = useState<GitFileInfo[]>([]);
+  const [selectedFile, setSelectedFile] = useState<string | null>(null);
+  const [fileDiff, setFileDiff] = useState<GitFileDiff | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [monacoLoaded, setMonacoLoaded] = useState(false);
+  const [currentChangeIndex, setCurrentChangeIndex] = useState<number>(-1);
+  const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
+  const saveTimeoutRef = useRef<number | null>(null);
+  const pendingSaveRef = useRef<(() => Promise<void>) | null>(null);
+  const scheduleSaveRef = useRef<(() => void) | null>(null);
+  const contentChangeDisposableRef = useRef<Monaco.IDisposable | null>(null);
+  const [showCommentDialog, setShowCommentDialog] = useState<{
+    line: number;
+    side: "left" | "right";
+    selectedText?: string;
+    startLine?: number;
+    endLine?: number;
+  } | null>(null);
+  const [commentText, setCommentText] = useState("");
+  const [mode, setMode] = useState<ViewMode>("comment");
+  const [selectorsExpanded, setSelectorsExpanded] = useState(false);
+  const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
+  const editorContainerRef = useRef<HTMLDivElement>(null);
+  const editorRef = useRef<Monaco.editor.IStandaloneDiffEditor | null>(null);
+  const monacoRef = useRef<typeof Monaco | null>(null);
+  const modeRef = useRef<ViewMode>(mode);
+
+  // Keep modeRef in sync with mode state and update editor options
+  useEffect(() => {
+    modeRef.current = mode;
+    // Update editor readOnly state when mode changes
+    if (editorRef.current) {
+      const modifiedEditor = editorRef.current.getModifiedEditor();
+      modifiedEditor.updateOptions({ readOnly: mode === "comment" });
+    }
+  }, [mode]);
+
+  // Track viewport size
+  useEffect(() => {
+    const handleResize = () => {
+      setIsMobile(window.innerWidth < 768);
+    };
+    window.addEventListener("resize", handleResize);
+    return () => window.removeEventListener("resize", handleResize);
+  }, []);
+
+  // Load Monaco when viewer opens
+  useEffect(() => {
+    if (isOpen && !monacoLoaded) {
+      loadMonaco()
+        .then((monaco) => {
+          monacoRef.current = monaco;
+          setMonacoLoaded(true);
+        })
+        .catch((err) => {
+          console.error("Failed to load Monaco:", err);
+          setError("Failed to load diff editor");
+        });
+    }
+  }, [isOpen, monacoLoaded]);
+
+  // Load diffs when viewer opens
+  useEffect(() => {
+    if (isOpen && cwd) {
+      loadDiffs();
+    }
+  }, [isOpen, cwd]);
+
+  // Load files when diff is selected
+  useEffect(() => {
+    if (selectedDiff && cwd) {
+      loadFiles(selectedDiff);
+    }
+  }, [selectedDiff, cwd]);
+
+  // Load file diff when file is selected
+  useEffect(() => {
+    if (selectedDiff && selectedFile && cwd) {
+      loadFileDiff(selectedDiff, selectedFile);
+      setCurrentChangeIndex(-1); // Reset change index for new file
+    }
+  }, [selectedDiff, selectedFile, cwd]);
+
+  // Create/update Monaco editor when fileDiff changes
+  useEffect(() => {
+    if (!monacoLoaded || !fileDiff || !editorContainerRef.current || !monacoRef.current) {
+      return;
+    }
+
+    const monaco = monacoRef.current;
+
+    // Dispose previous editor
+    if (editorRef.current) {
+      editorRef.current.dispose();
+      editorRef.current = null;
+    }
+
+    // Get language from file extension
+    const ext = "." + (fileDiff.path.split(".").pop()?.toLowerCase() || "");
+    const languages = monaco.languages.getLanguages();
+    let language = "plaintext";
+    for (const lang of languages) {
+      if (lang.extensions?.includes(ext)) {
+        language = lang.id;
+        break;
+      }
+    }
+
+    // Create models with unique URIs (include timestamp to avoid conflicts)
+    const timestamp = Date.now();
+    const originalUri = monaco.Uri.file(`original-${timestamp}-${fileDiff.path}`);
+    const modifiedUri = monaco.Uri.file(`modified-${timestamp}-${fileDiff.path}`);
+
+    const originalModel = monaco.editor.createModel(fileDiff.oldContent, language, originalUri);
+    const modifiedModel = monaco.editor.createModel(fileDiff.newContent, language, modifiedUri);
+
+    // Create diff editor with mobile-friendly options
+    const diffEditor = monaco.editor.createDiffEditor(editorContainerRef.current, {
+      theme: "vs",
+      readOnly: true, // Always read-only in diff viewer
+      originalEditable: false,
+      automaticLayout: true,
+      renderSideBySide: !isMobile,
+      enableSplitViewResizing: true,
+      renderIndicators: true,
+      renderMarginRevertIcon: false,
+      lineNumbers: isMobile ? "off" : "on",
+      minimap: { enabled: false },
+      scrollBeyondLastLine: false,
+      wordWrap: "on",
+      glyphMargin: false, // No glyph margin - click on lines to comment
+      lineDecorationsWidth: isMobile ? 0 : 10,
+      lineNumbersMinChars: isMobile ? 0 : 3,
+      quickSuggestions: false,
+      suggestOnTriggerCharacters: false,
+      lightbulb: { enabled: false },
+      codeLens: false,
+      contextmenu: false,
+      links: false,
+      folding: !isMobile,
+    });
+
+    diffEditor.setModel({
+      original: originalModel,
+      modified: modifiedModel,
+    });
+
+    editorRef.current = diffEditor;
+
+    // Add click handler for commenting - clicking on a line in comment mode opens dialog
+    const modifiedEditor = diffEditor.getModifiedEditor();
+    modifiedEditor.onMouseDown((e: Monaco.editor.IEditorMouseEvent) => {
+      // In comment mode, clicking on line content opens comment dialog
+      const isLineClick =
+        e.target.type === monaco.editor.MouseTargetType.CONTENT_TEXT ||
+        e.target.type === monaco.editor.MouseTargetType.CONTENT_EMPTY;
+
+      if (isLineClick && modeRef.current === "comment") {
+        const position = e.target.position;
+        if (position) {
+          const model = modifiedEditor.getModel();
+          const selection = modifiedEditor.getSelection();
+          let selectedText = "";
+          let startLine = position.lineNumber;
+          let endLine = position.lineNumber;
+
+          if (selection && !selection.isEmpty() && model) {
+            selectedText = model.getValueInRange(selection);
+            startLine = selection.startLineNumber;
+            endLine = selection.endLineNumber;
+          } else if (model) {
+            selectedText = model.getLineContent(position.lineNumber) || "";
+          }
+
+          setShowCommentDialog({
+            line: startLine,
+            side: "right",
+            selectedText,
+            startLine,
+            endLine,
+          });
+        }
+      }
+    });
+
+    // Add content change listener for auto-save
+    contentChangeDisposableRef.current?.dispose();
+    contentChangeDisposableRef.current = modifiedEditor.onDidChangeModelContent(() => {
+      scheduleSaveRef.current?.();
+    });
+
+    // Cleanup function
+    return () => {
+      contentChangeDisposableRef.current?.dispose();
+      contentChangeDisposableRef.current = null;
+      if (editorRef.current) {
+        editorRef.current.dispose();
+        editorRef.current = null;
+      }
+    };
+  }, [monacoLoaded, fileDiff, isMobile]);
+
+  const loadDiffs = async () => {
+    try {
+      setLoading(true);
+      setError(null);
+      const response = await api.getGitDiffs(cwd);
+      setDiffs(response.diffs);
+      setGitRoot(response.gitRoot);
+      // Auto-select working changes if non-empty
+      if (response.diffs.length > 0) {
+        const working = response.diffs.find((d) => d.id === "working");
+        if (working && working.filesCount > 0) {
+          setSelectedDiff("working");
+        } else if (response.diffs.length > 1) {
+          setSelectedDiff(response.diffs[1].id);
+        }
+      }
+    } catch (err) {
+      const errStr = String(err);
+      if (errStr.toLowerCase().includes("not a git repository")) {
+        setError(`Not a git repository: ${cwd}`);
+      } else {
+        setError(`Failed to load diffs: ${errStr}`);
+      }
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const loadFiles = async (diffId: string) => {
+    try {
+      setLoading(true);
+      setError(null);
+      const filesData = await api.getGitDiffFiles(diffId, cwd);
+      setFiles(filesData || []);
+      if (filesData && filesData.length > 0) {
+        setSelectedFile(filesData[0].path);
+      } else {
+        setSelectedFile(null);
+        setFileDiff(null);
+      }
+    } catch (err) {
+      setError(`Failed to load files: ${err}`);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const loadFileDiff = async (diffId: string, filePath: string) => {
+    try {
+      setLoading(true);
+      setError(null);
+      const diffData = await api.getGitFileDiff(diffId, filePath, cwd);
+      setFileDiff(diffData);
+    } catch (err) {
+      setError(`Failed to load file diff: ${err}`);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleAddComment = () => {
+    if (!showCommentDialog || !commentText.trim() || !selectedFile) return;
+
+    // Format and append comment directly to the message input
+    let commentBlock = `**${selectedFile}**`;
+    if (showCommentDialog.startLine !== showCommentDialog.endLine) {
+      commentBlock += ` (lines ${showCommentDialog.startLine}-${showCommentDialog.endLine})`;
+    } else {
+      commentBlock += ` (line ${showCommentDialog.line})`;
+    }
+    commentBlock += ":\n";
+    if (showCommentDialog.selectedText) {
+      commentBlock += "```\n" + showCommentDialog.selectedText + "\n```\n";
+    }
+    commentBlock += commentText + "\n\n";
+
+    onCommentTextChange(commentBlock);
+    setShowCommentDialog(null);
+    setCommentText("");
+  };
+
+  const goToNextFile = useCallback(() => {
+    if (files.length === 0 || !selectedFile) return false;
+    const idx = files.findIndex((f) => f.path === selectedFile);
+    if (idx < files.length - 1) {
+      setSelectedFile(files[idx + 1].path);
+      setCurrentChangeIndex(-1); // Reset to start of new file
+      return true;
+    }
+    return false;
+  }, [files, selectedFile]);
+
+  const goToPreviousFile = useCallback(() => {
+    if (files.length === 0 || !selectedFile) return false;
+    const idx = files.findIndex((f) => f.path === selectedFile);
+    if (idx > 0) {
+      setSelectedFile(files[idx - 1].path);
+      setCurrentChangeIndex(-1); // Will go to last change when file loads
+      return true;
+    }
+    return false;
+  }, [files, selectedFile]);
+
+  const goToNextChange = useCallback(() => {
+    if (!editorRef.current) return;
+    const changes = editorRef.current.getLineChanges();
+    if (!changes || changes.length === 0) {
+      // No changes in this file, try next file
+      goToNextFile();
+      return;
+    }
+
+    const modifiedEditor = editorRef.current.getModifiedEditor();
+    const nextIdx = currentChangeIndex + 1;
+
+    if (nextIdx >= changes.length) {
+      // At end of file, try to go to next file
+      if (goToNextFile()) {
+        return;
+      }
+      // No next file, stay at last change
+      return;
+    }
+
+    const change = changes[nextIdx];
+    const targetLine = change.modifiedStartLineNumber || 1;
+    modifiedEditor.revealLineInCenter(targetLine);
+    modifiedEditor.setPosition({ lineNumber: targetLine, column: 1 });
+    setCurrentChangeIndex(nextIdx);
+  }, [currentChangeIndex, goToNextFile]);
+
+  const goToPreviousChange = useCallback(() => {
+    if (!editorRef.current) return;
+    const changes = editorRef.current.getLineChanges();
+    if (!changes || changes.length === 0) {
+      // No changes in this file, try previous file
+      goToPreviousFile();
+      return;
+    }
+
+    const modifiedEditor = editorRef.current.getModifiedEditor();
+    const prevIdx = currentChangeIndex <= 0 ? -1 : currentChangeIndex - 1;
+
+    if (prevIdx < 0) {
+      // At start of file, try to go to previous file
+      if (goToPreviousFile()) {
+        return;
+      }
+      // No previous file, go to first change
+      const change = changes[0];
+      const targetLine = change.modifiedStartLineNumber || 1;
+      modifiedEditor.revealLineInCenter(targetLine);
+      modifiedEditor.setPosition({ lineNumber: targetLine, column: 1 });
+      setCurrentChangeIndex(0);
+      return;
+    }
+
+    const change = changes[prevIdx];
+    const targetLine = change.modifiedStartLineNumber || 1;
+    modifiedEditor.revealLineInCenter(targetLine);
+    modifiedEditor.setPosition({ lineNumber: targetLine, column: 1 });
+    setCurrentChangeIndex(prevIdx);
+  }, [currentChangeIndex, goToPreviousFile]);
+
+  // Save the current file (in edit mode)
+  const saveCurrentFile = useCallback(async () => {
+    if (
+      !editorRef.current ||
+      !selectedFile ||
+      !fileDiff ||
+      modeRef.current !== "edit" ||
+      !gitRoot
+    ) {
+      return;
+    }
+
+    const modifiedEditor = editorRef.current.getModifiedEditor();
+    const model = modifiedEditor.getModel();
+    if (!model) return;
+
+    const content = model.getValue();
+    const fullPath = gitRoot + "/" + selectedFile;
+
+    try {
+      setSaveStatus("saving");
+      const response = await fetch("/api/write-file", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ path: fullPath, content }),
+      });
+
+      if (response.ok) {
+        setSaveStatus("saved");
+        setTimeout(() => setSaveStatus("idle"), 2000);
+      } else {
+        setSaveStatus("error");
+        setTimeout(() => setSaveStatus("idle"), 3000);
+      }
+    } catch (err) {
+      console.error("Failed to save:", err);
+      setSaveStatus("error");
+      setTimeout(() => setSaveStatus("idle"), 3000);
+    }
+  }, [selectedFile, fileDiff, gitRoot]);
+
+  // Debounced auto-save
+  const scheduleSave = useCallback(() => {
+    if (modeRef.current !== "edit") return; // Only auto-save in edit mode
+    if (saveTimeoutRef.current) {
+      clearTimeout(saveTimeoutRef.current);
+    }
+    pendingSaveRef.current = saveCurrentFile;
+    saveTimeoutRef.current = window.setTimeout(() => {
+      pendingSaveRef.current?.();
+      pendingSaveRef.current = null;
+      saveTimeoutRef.current = null;
+    }, 1000);
+  }, [saveCurrentFile]);
+
+  // Keep scheduleSaveRef in sync
+  useEffect(() => {
+    scheduleSaveRef.current = scheduleSave;
+  }, [scheduleSave]);
+
+  // Force immediate save (for Ctrl+S)
+  const saveImmediately = useCallback(() => {
+    if (saveTimeoutRef.current) {
+      clearTimeout(saveTimeoutRef.current);
+      saveTimeoutRef.current = null;
+    }
+    pendingSaveRef.current = null;
+    saveCurrentFile();
+  }, [saveCurrentFile]);
+
+  // Keyboard shortcuts
+  useEffect(() => {
+    if (!isOpen) return;
+
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === "Escape") {
+        if (showCommentDialog) {
+          setShowCommentDialog(null);
+        } else {
+          onClose();
+        }
+        return;
+      }
+      if ((e.ctrlKey || e.metaKey) && e.key === "s") {
+        e.preventDefault();
+        saveImmediately();
+        return;
+      }
+      if (!e.ctrlKey) return;
+      if (e.key === "j") {
+        e.preventDefault();
+        goToNextFile();
+      } else if (e.key === "k") {
+        e.preventDefault();
+        goToPreviousFile();
+      }
+    };
+
+    window.addEventListener("keydown", handleKeyDown);
+    return () => window.removeEventListener("keydown", handleKeyDown);
+  }, [isOpen, goToNextFile, goToPreviousFile, showCommentDialog, onClose, saveImmediately]);
+
+  if (!isOpen) return null;
+
+  const getStatusSymbol = (status: string) => {
+    switch (status) {
+      case "added":
+        return "+";
+      case "deleted":
+        return "-";
+      case "modified":
+        return "~";
+      default:
+        return "";
+    }
+  };
+
+  const currentFileIndex = files.findIndex((f) => f.path === selectedFile);
+  const hasNextFile = currentFileIndex < files.length - 1;
+  const hasPrevFile = currentFileIndex > 0;
+
+  return (
+    <div className="diff-viewer-overlay">
+      <div className="diff-viewer-container">
+        {/* Toast notification */}
+        {saveStatus !== "idle" && (
+          <div className={`diff-viewer-toast diff-viewer-toast-${saveStatus}`}>
+            {saveStatus === "saving" && "πŸ’Ύ Saving..."}
+            {saveStatus === "saved" && "βœ… Saved"}
+            {saveStatus === "error" && "❌ Error saving"}
+          </div>
+        )}
+
+        {/* Header */}
+        <div className="diff-viewer-header">
+          <div className="diff-viewer-header-row">
+            {/* Mode toggle */}
+            <div className="diff-viewer-mode-toggle">
+              <button
+                className={`diff-viewer-mode-btn ${mode === "comment" ? "active" : ""}`}
+                onClick={() => setMode("comment")}
+                title="Comment mode"
+              >
+                πŸ’¬
+              </button>
+              <button
+                className={`diff-viewer-mode-btn ${mode === "edit" ? "active" : ""}`}
+                onClick={() => setMode("edit")}
+                title="Edit mode"
+              >
+                ✏️
+              </button>
+            </div>
+
+            {/* Navigation buttons: <<(prev file) <(prev change) >(next change) >>(next file) */}
+            <div className="diff-viewer-nav-buttons">
+              <button
+                className="diff-viewer-nav-btn"
+                onClick={goToPreviousFile}
+                disabled={!hasPrevFile}
+                title="Previous file"
+              >
+                <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
+                  <path d="M11 2L5 8l6 6V2zM4 2v12H2V2h2z" />
+                </svg>
+              </button>
+              <button
+                className="diff-viewer-nav-btn"
+                onClick={goToPreviousChange}
+                disabled={!fileDiff}
+                title="Previous change"
+              >
+                <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
+                  <path d="M10 2L4 8l6 6V2z" />
+                </svg>
+              </button>
+              <button
+                className="diff-viewer-nav-btn"
+                onClick={goToNextChange}
+                disabled={!fileDiff}
+                title="Next change"
+              >
+                <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
+                  <path d="M6 2l6 6-6 6V2z" />
+                </svg>
+              </button>
+              <button
+                className="diff-viewer-nav-btn"
+                onClick={() => goToNextFile()}
+                disabled={!hasNextFile}
+                title="Next file"
+              >
+                <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
+                  <path d="M5 2l6 6-6 6V2zM12 2v12h2V2h-2z" />
+                </svg>
+              </button>
+            </div>
+
+            {/* Expand/collapse selectors */}
+            <button
+              className="diff-viewer-expand-btn"
+              onClick={() => setSelectorsExpanded(!selectorsExpanded)}
+              title={selectorsExpanded ? "Collapse selectors" : "Expand selectors"}
+            >
+              {selectorsExpanded ? "β–²" : "β–Ό"}
+              <span className="diff-viewer-expand-label">
+                {selectedFile ? selectedFile.split("/").pop() : "Select..."}
+              </span>
+            </button>
+
+            <button className="diff-viewer-close" onClick={onClose} title="Close (Esc)">
+              Γ—
+            </button>
+          </div>
+
+          {/* Collapsible selectors */}
+          {selectorsExpanded && (
+            <div className="diff-viewer-selectors">
+              {/* Diff selector */}
+              <select
+                value={selectedDiff || ""}
+                onChange={(e) => setSelectedDiff(e.target.value || null)}
+                className="diff-viewer-select"
+              >
+                <option value="">Choose base...</option>
+                {diffs.map((diff) => {
+                  const stats = `${diff.filesCount} files, +${diff.additions}/-${diff.deletions}`;
+                  return (
+                    <option key={diff.id} value={diff.id}>
+                      {diff.id === "working"
+                        ? `Working Changes (${stats})`
+                        : `${diff.message.slice(0, 40)} (${stats})`}
+                    </option>
+                  );
+                })}
+              </select>
+
+              {/* File selector */}
+              <select
+                value={selectedFile || ""}
+                onChange={(e) => setSelectedFile(e.target.value || null)}
+                className="diff-viewer-select"
+                disabled={files.length === 0}
+              >
+                <option value="">{files.length === 0 ? "No files" : "Choose file..."}</option>
+                {files.map((file) => (
+                  <option key={file.path} value={file.path}>
+                    {getStatusSymbol(file.status)} {file.path}
+                    {file.additions > 0 && ` (+${file.additions})`}
+                    {file.deletions > 0 && ` (-${file.deletions})`}
+                  </option>
+                ))}
+              </select>
+            </div>
+          )}
+        </div>
+
+        {/* Error banner */}
+        {error && <div className="diff-viewer-error">{error}</div>}
+
+        {/* Main content */}
+        <div className="diff-viewer-content">
+          {loading && !fileDiff && (
+            <div className="diff-viewer-loading">
+              <div className="spinner"></div>
+              <span>Loading...</span>
+            </div>
+          )}
+
+          {!loading && !monacoLoaded && !error && (
+            <div className="diff-viewer-loading">
+              <div className="spinner"></div>
+              <span>Loading editor...</span>
+            </div>
+          )}
+
+          {!loading && monacoLoaded && !fileDiff && !error && (
+            <div className="diff-viewer-empty">
+              <p>Select a diff and file to view changes.</p>
+              <p className="diff-viewer-hint">Click on line numbers to add comments.</p>
+            </div>
+          )}
+
+          {/* Monaco editor container */}
+          <div
+            ref={editorContainerRef}
+            className="diff-viewer-editor"
+            style={{ display: fileDiff && monacoLoaded ? "block" : "none" }}
+          />
+        </div>
+
+        {/* Comment dialog */}
+        {showCommentDialog && (
+          <div className="diff-viewer-comment-dialog">
+            <h4>
+              Add Comment (Line
+              {showCommentDialog.startLine !== showCommentDialog.endLine
+                ? `s ${showCommentDialog.startLine}-${showCommentDialog.endLine}`
+                : ` ${showCommentDialog.line}`}
+              , {showCommentDialog.side === "left" ? "old" : "new"})
+            </h4>
+            {showCommentDialog.selectedText && (
+              <pre className="diff-viewer-selected-text">{showCommentDialog.selectedText}</pre>
+            )}
+            <textarea
+              value={commentText}
+              onChange={(e) => setCommentText(e.target.value)}
+              placeholder="Enter your comment..."
+              className="diff-viewer-comment-input"
+              autoFocus
+            />
+            <div className="diff-viewer-comment-actions">
+              <button
+                onClick={() => setShowCommentDialog(null)}
+                className="diff-viewer-btn diff-viewer-btn-secondary"
+              >
+                Cancel
+              </button>
+              <button
+                onClick={handleAddComment}
+                className="diff-viewer-btn diff-viewer-btn-primary"
+                disabled={!commentText.trim()}
+              >
+                Add Comment
+              </button>
+            </div>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}
+
+export default DiffViewer;

ui/src/components/DirectoryPickerModal.tsx πŸ”—

@@ -0,0 +1,336 @@
+import React, { useState, useEffect, useRef, useCallback } from "react";
+import { api } from "../services/api";
+
+interface DirectoryEntry {
+  name: string;
+  is_dir: boolean;
+}
+
+interface CachedDirectory {
+  path: string;
+  parent: string;
+  entries: DirectoryEntry[];
+}
+
+interface DirectoryPickerModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  onSelect: (path: string) => void;
+  initialPath?: string;
+}
+
+function DirectoryPickerModal({
+  isOpen,
+  onClose,
+  onSelect,
+  initialPath,
+}: DirectoryPickerModalProps) {
+  const [inputPath, setInputPath] = useState(() => {
+    if (!initialPath) return "";
+    return initialPath.endsWith("/") ? initialPath : initialPath + "/";
+  });
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  // Cache for directory listings
+  const cacheRef = useRef<Map<string, CachedDirectory>>(new Map());
+
+  // Current directory being displayed (the parent directory of what's being typed)
+  const [displayDir, setDisplayDir] = useState<CachedDirectory | null>(null);
+  // Filter prefix (the part after the last slash that we're filtering by)
+  const [filterPrefix, setFilterPrefix] = useState("");
+
+  // Parse input path into directory and filter prefix
+  const parseInputPath = useCallback((path: string): { dirPath: string; prefix: string } => {
+    if (!path) {
+      return { dirPath: "", prefix: "" };
+    }
+
+    // If path ends with /, we're looking at contents of that directory
+    if (path.endsWith("/")) {
+      return { dirPath: path.slice(0, -1) || "/", prefix: "" };
+    }
+
+    // Otherwise, split into directory and prefix
+    const lastSlash = path.lastIndexOf("/");
+    if (lastSlash === -1) {
+      // No slash, treat as prefix in current directory
+      return { dirPath: "", prefix: path };
+    }
+    if (lastSlash === 0) {
+      // Root directory with prefix
+      return { dirPath: "/", prefix: path.slice(1) };
+    }
+    return {
+      dirPath: path.slice(0, lastSlash),
+      prefix: path.slice(lastSlash + 1),
+    };
+  }, []);
+
+  // Load directory from cache or API
+  const loadDirectory = useCallback(async (path: string): Promise<CachedDirectory | null> => {
+    const normalizedPath = path || "/";
+
+    // Check cache first
+    const cached = cacheRef.current.get(normalizedPath);
+    if (cached) {
+      return cached;
+    }
+
+    // Load from API
+    setLoading(true);
+    setError(null);
+    try {
+      const result = await api.listDirectory(path || undefined);
+      if (result.error) {
+        setError(result.error);
+        return null;
+      }
+
+      const dirData: CachedDirectory = {
+        path: result.path,
+        parent: result.parent,
+        entries: result.entries || [],
+      };
+
+      // Cache it
+      cacheRef.current.set(result.path, dirData);
+
+      return dirData;
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "Failed to load directory");
+      return null;
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  // Update display when input changes
+  useEffect(() => {
+    if (!isOpen) return;
+
+    const { dirPath, prefix } = parseInputPath(inputPath);
+    setFilterPrefix(prefix);
+
+    // Load the directory
+    loadDirectory(dirPath).then((dir) => {
+      if (dir) {
+        setDisplayDir(dir);
+        setError(null);
+      }
+    });
+  }, [isOpen, inputPath, parseInputPath, loadDirectory]);
+
+  // Initialize when modal opens
+  useEffect(() => {
+    if (isOpen) {
+      if (!initialPath) {
+        setInputPath("");
+      } else {
+        setInputPath(initialPath.endsWith("/") ? initialPath : initialPath + "/");
+      }
+      // Clear cache on open to get fresh data
+      cacheRef.current.clear();
+    }
+  }, [isOpen, initialPath]);
+
+  // Focus input when modal opens (but not on mobile to avoid keyboard popup)
+  useEffect(() => {
+    if (isOpen && inputRef.current) {
+      // Check if mobile device (touch-based)
+      const isMobile = window.matchMedia("(max-width: 768px)").matches || "ontouchstart" in window;
+      if (!isMobile) {
+        inputRef.current.focus();
+        // Move cursor to end
+        const len = inputRef.current.value.length;
+        inputRef.current.setSelectionRange(len, len);
+      }
+    }
+  }, [isOpen]);
+
+  // Filter entries based on prefix (case-insensitive)
+  const filteredEntries =
+    displayDir?.entries.filter((entry) => {
+      if (!filterPrefix) return true;
+      return entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase());
+    }) || [];
+
+  const handleEntryClick = (entry: DirectoryEntry) => {
+    if (entry.is_dir) {
+      const basePath = displayDir?.path || "";
+      const newPath = basePath === "/" ? `/${entry.name}/` : `${basePath}/${entry.name}/`;
+      setInputPath(newPath);
+    }
+  };
+
+  const handleParentClick = () => {
+    if (displayDir?.parent) {
+      const newPath = displayDir.parent === "/" ? "/" : `${displayDir.parent}/`;
+      setInputPath(newPath);
+    }
+  };
+
+  const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+    if (e.key === "Enter") {
+      e.preventDefault();
+      handleSelect();
+    }
+  };
+
+  const handleSelect = () => {
+    // Use the current directory path for selection
+    const { dirPath } = parseInputPath(inputPath);
+    const selectedPath = inputPath.endsWith("/") ? (dirPath === "/" ? "/" : dirPath) : dirPath;
+    onSelect(selectedPath || displayDir?.path || "");
+    onClose();
+  };
+
+  const handleBackdropClick = (e: React.MouseEvent) => {
+    if (e.target === e.currentTarget) {
+      onClose();
+    }
+  };
+
+  if (!isOpen) return null;
+
+  // Determine if we should show the parent entry
+  const showParent = displayDir?.parent && displayDir.parent !== "";
+
+  return (
+    <div className="modal-overlay" onClick={handleBackdropClick}>
+      <div className="modal directory-picker-modal">
+        {/* Header */}
+        <div className="modal-header">
+          <h2 className="modal-title">Select Directory</h2>
+          <button onClick={onClose} className="btn-icon" aria-label="Close modal">
+            <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2}
+                d="M6 18L18 6M6 6l12 12"
+              />
+            </svg>
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="modal-body directory-picker-body">
+          {/* Path input */}
+          <div className="directory-picker-input-container">
+            <input
+              ref={inputRef}
+              type="text"
+              value={inputPath}
+              onChange={(e) => setInputPath(e.target.value)}
+              onKeyDown={handleInputKeyDown}
+              className="directory-picker-input"
+              placeholder="/path/to/directory"
+            />
+          </div>
+
+          {/* Current directory indicator */}
+          {displayDir && (
+            <div className="directory-picker-current">
+              {displayDir.path}
+              {filterPrefix && <span className="directory-picker-filter">/{filterPrefix}*</span>}
+            </div>
+          )}
+
+          {/* Error message */}
+          {error && <div className="directory-picker-error">{error}</div>}
+
+          {/* Loading state */}
+          {loading && (
+            <div className="directory-picker-loading">
+              <div className="spinner spinner-small"></div>
+              <span>Loading...</span>
+            </div>
+          )}
+
+          {/* Directory listing */}
+          {!loading && !error && (
+            <div className="directory-picker-list">
+              {/* Parent directory entry */}
+              {showParent && (
+                <button
+                  className="directory-picker-entry directory-picker-entry-parent"
+                  onClick={handleParentClick}
+                >
+                  <svg
+                    fill="none"
+                    stroke="currentColor"
+                    viewBox="0 0 24 24"
+                    className="directory-picker-icon"
+                  >
+                    <path
+                      strokeLinecap="round"
+                      strokeLinejoin="round"
+                      strokeWidth={2}
+                      d="M11 17l-5-5m0 0l5-5m-5 5h12"
+                    />
+                  </svg>
+                  <span>..</span>
+                </button>
+              )}
+
+              {/* Directory entries */}
+              {filteredEntries.map((entry) => (
+                <button
+                  key={entry.name}
+                  className="directory-picker-entry"
+                  onClick={() => handleEntryClick(entry)}
+                >
+                  <svg
+                    fill="none"
+                    stroke="currentColor"
+                    viewBox="0 0 24 24"
+                    className="directory-picker-icon"
+                  >
+                    <path
+                      strokeLinecap="round"
+                      strokeLinejoin="round"
+                      strokeWidth={2}
+                      d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
+                    />
+                  </svg>
+                  <span>
+                    {filterPrefix &&
+                    entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase()) ? (
+                      <>
+                        <strong>{entry.name.slice(0, filterPrefix.length)}</strong>
+                        {entry.name.slice(filterPrefix.length)}
+                      </>
+                    ) : (
+                      entry.name
+                    )}
+                  </span>
+                </button>
+              ))}
+
+              {/* Empty state */}
+              {filteredEntries.length === 0 && !showParent && (
+                <div className="directory-picker-empty">
+                  {filterPrefix ? "No matching directories" : "No subdirectories"}
+                </div>
+              )}
+            </div>
+          )}
+        </div>
+
+        {/* Footer */}
+        <div className="directory-picker-footer">
+          <button className="btn" onClick={onClose}>
+            Cancel
+          </button>
+          <button className="btn-primary" onClick={handleSelect} disabled={loading || !!error}>
+            Select
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export default DirectoryPickerModal;

ui/src/components/GenericTool.tsx πŸ”—

@@ -0,0 +1,115 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface GenericToolProps {
+  toolName: string;
+
+  // For tool_use (pending state)
+  toolInput?: unknown;
+  isRunning?: boolean;
+
+  // For tool_result (completed state)
+  toolResult?: LLMContent[];
+  hasError?: boolean;
+  executionTime?: string;
+}
+
+function GenericTool({
+  toolName,
+  toolInput,
+  isRunning,
+  toolResult,
+  hasError,
+  executionTime,
+}: GenericToolProps) {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  // Format data for display
+  const formatData = (data: unknown): string => {
+    if (data === undefined || data === null) return "";
+    if (typeof data === "string") return data;
+    try {
+      return JSON.stringify(data, null, 2);
+    } catch {
+      return String(data);
+    }
+  };
+
+  // Extract output from toolResult
+  const output =
+    toolResult && toolResult.length > 0
+      ? toolResult.map((result) => result.Text || formatData(result)).join("\n")
+      : "";
+
+  const isComplete = !isRunning && toolResult !== undefined;
+
+  return (
+    <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+      <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+        <div className="tool-summary">
+          <span className={`tool-emoji ${isRunning ? "running" : ""}`}>βš™οΈ</span>
+          <span className="tool-command">{toolName}</span>
+          {isComplete && hasError && <span className="tool-error">βœ—</span>}
+          {isComplete && !hasError && <span className="tool-success">βœ“</span>}
+        </div>
+        <button
+          className="tool-toggle"
+          aria-label={isExpanded ? "Collapse" : "Expand"}
+          aria-expanded={isExpanded}
+        >
+          <svg
+            width="12"
+            height="12"
+            viewBox="0 0 12 12"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+            style={{
+              transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+              transition: "transform 0.2s",
+            }}
+          >
+            <path
+              d="M4.5 3L7.5 6L4.5 9"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {isExpanded && (
+        <div className="tool-details">
+          {toolInput !== undefined && (
+            <div className="tool-section">
+              <div className="tool-label">Input:</div>
+              <pre className="tool-code">{formatData(toolInput)}</pre>
+            </div>
+          )}
+
+          {isRunning && (
+            <div className="tool-section">
+              <div className="tool-label">Status:</div>
+              <div className="tool-running-text">running...</div>
+            </div>
+          )}
+
+          {isComplete && (
+            <div className="tool-section">
+              <div className="tool-label">
+                Output{hasError ? " (Error)" : ""}:
+                {executionTime && <span className="tool-time">{executionTime}</span>}
+              </div>
+              <pre className={`tool-code ${hasError ? "error" : ""}`}>
+                {output || "(no output)"}
+              </pre>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default GenericTool;

ui/src/components/KeywordSearchTool.tsx πŸ”—

@@ -0,0 +1,124 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface KeywordSearchToolProps {
+  // For tool_use (pending state)
+  toolInput?: unknown; // { query: string, search_terms: string[] }
+  isRunning?: boolean;
+
+  // For tool_result (completed state)
+  toolResult?: LLMContent[];
+  hasError?: boolean;
+  executionTime?: string;
+}
+
+function KeywordSearchTool({
+  toolInput,
+  isRunning,
+  toolResult,
+  hasError,
+  executionTime,
+}: KeywordSearchToolProps) {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  // Extract query and search terms from toolInput
+  const query =
+    typeof toolInput === "object" &&
+    toolInput !== null &&
+    "query" in toolInput &&
+    typeof toolInput.query === "string"
+      ? toolInput.query
+      : "";
+
+  const searchTerms =
+    typeof toolInput === "object" &&
+    toolInput !== null &&
+    "search_terms" in toolInput &&
+    Array.isArray(toolInput.search_terms)
+      ? toolInput.search_terms
+      : [];
+
+  // Extract output from toolResult
+  const output =
+    toolResult && toolResult.length > 0 && toolResult[0].Text ? toolResult[0].Text : "";
+
+  // Truncate search terms for display
+  const truncateSearchTerms = (terms: string[], maxLen: number = 300) => {
+    const joined = terms.join(", ");
+    if (joined.length <= maxLen) return joined;
+    return joined.substring(0, maxLen) + "...";
+  };
+
+  const displayText = query || truncateSearchTerms(searchTerms);
+  const isComplete = !isRunning && toolResult !== undefined;
+
+  return (
+    <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+      <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+        <div className="tool-summary">
+          <span className={`tool-emoji ${isRunning ? "running" : ""}`}>πŸ”</span>
+          <span className="tool-command">{displayText}</span>
+          {isComplete && hasError && <span className="tool-error">βœ—</span>}
+          {isComplete && !hasError && <span className="tool-success">βœ“</span>}
+        </div>
+        <button
+          className="tool-toggle"
+          aria-label={isExpanded ? "Collapse" : "Expand"}
+          aria-expanded={isExpanded}
+        >
+          <svg
+            width="12"
+            height="12"
+            viewBox="0 0 12 12"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+            style={{
+              transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+              transition: "transform 0.2s",
+            }}
+          >
+            <path
+              d="M4.5 3L7.5 6L4.5 9"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {isExpanded && (
+        <div className="tool-details">
+          {query && (
+            <div className="tool-section">
+              <div className="tool-label">Query:</div>
+              <pre className="tool-code">{query}</pre>
+            </div>
+          )}
+
+          {searchTerms.length > 0 && (
+            <div className="tool-section">
+              <div className="tool-label">Search Terms:</div>
+              <pre className="tool-code">{searchTerms.join(", ")}</pre>
+            </div>
+          )}
+
+          {isComplete && (
+            <div className="tool-section">
+              <div className="tool-label">
+                Results{hasError ? " (Error)" : ""}:
+                {executionTime && <span className="tool-time">{executionTime}</span>}
+              </div>
+              <pre className={`tool-code ${hasError ? "error" : ""}`}>
+                {output || "(no output)"}
+              </pre>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default KeywordSearchTool;

ui/src/components/Message.tsx πŸ”—

@@ -0,0 +1,855 @@
+import React, { useState, useRef } from "react";
+import { Message as MessageType, LLMMessage, LLMContent, Usage } from "../types";
+import BashTool from "./BashTool";
+import PatchTool from "./PatchTool";
+import ScreenshotTool from "./ScreenshotTool";
+import GenericTool from "./GenericTool";
+import ThinkTool from "./ThinkTool";
+import KeywordSearchTool from "./KeywordSearchTool";
+import BrowserNavigateTool from "./BrowserNavigateTool";
+import BrowserEvalTool from "./BrowserEvalTool";
+import ReadImageTool from "./ReadImageTool";
+import BrowserConsoleLogsTool from "./BrowserConsoleLogsTool";
+import ChangeDirTool from "./ChangeDirTool";
+import BrowserResizeTool from "./BrowserResizeTool";
+import ContextMenu from "./ContextMenu";
+import UsageDetailModal from "./UsageDetailModal";
+
+// Display data types from different tools
+interface ToolDisplay {
+  tool_use_id: string;
+  tool_name?: string;
+  display: unknown;
+}
+
+interface MessageProps {
+  message: MessageType;
+}
+
+function Message({ message }: MessageProps) {
+  // Hide system messages from the UI
+  if (message.type === "system") {
+    return null;
+  }
+
+  // Context menu state
+  const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
+  const [showUsageModal, setShowUsageModal] = useState(false);
+  const [longPressTimer, setLongPressTimer] = useState<number | null>(null);
+  const messageRef = useRef<HTMLDivElement | null>(null);
+
+  // Parse usage data if available (only for agent messages)
+  let usage: Usage | null = null;
+  if (message.type === "agent" && message.usage_data) {
+    try {
+      usage =
+        typeof message.usage_data === "string"
+          ? JSON.parse(message.usage_data)
+          : message.usage_data;
+    } catch (err) {
+      console.error("Failed to parse usage data:", err);
+    }
+  }
+
+  // Calculate duration if we have timing info
+  let durationMs: number | null = null;
+  if (usage?.start_time && usage?.end_time) {
+    const start = new Date(usage.start_time).getTime();
+    const end = new Date(usage.end_time).getTime();
+    durationMs = end - start;
+  }
+
+  // Convert Go struct Type field (number) to string type
+  // Based on llm/llm.go constants (iota continues across types in same const block):
+  // MessageRoleUser = 0, MessageRoleAssistant = 1,
+  // ContentTypeText = 2, ContentTypeThinking = 3, ContentTypeRedactedThinking = 4,
+  // ContentTypeToolUse = 5, ContentTypeToolResult = 6
+  const getContentType = (type: number): string => {
+    switch (type) {
+      case 0:
+        return "message_role_user"; // Should not occur in Content, but handle gracefully
+      case 1:
+        return "message_role_assistant"; // Should not occur in Content, but handle gracefully
+      case 2:
+        return "text";
+      case 3:
+        return "thinking";
+      case 4:
+        return "redacted_thinking";
+      case 5:
+        return "tool_use";
+      case 6:
+        return "tool_result";
+      default:
+        return "unknown";
+    }
+  };
+
+  // Get text content from message for copying
+  const getMessageText = (): string => {
+    if (!llmMessage?.Content) return "";
+
+    const textParts: string[] = [];
+    llmMessage.Content.forEach((content) => {
+      const contentType = getContentType(content.Type);
+      if (contentType === "text" && content.Text) {
+        textParts.push(content.Text);
+      }
+    });
+    return textParts.join("\n");
+  };
+
+  // Handle right-click (desktop)
+  const handleContextMenu = (e: React.MouseEvent) => {
+    e.preventDefault();
+    setContextMenu({ x: e.clientX, y: e.clientY });
+  };
+
+  // Handle long-press (mobile)
+  const handleTouchStart = (e: React.TouchEvent) => {
+    const touch = e.touches[0];
+    const timer = setTimeout(() => {
+      setContextMenu({ x: touch.clientX, y: touch.clientY });
+    }, 500); // 500ms long press
+    setLongPressTimer(timer);
+  };
+
+  const handleTouchEnd = () => {
+    if (longPressTimer) {
+      clearTimeout(longPressTimer);
+      setLongPressTimer(null);
+    }
+  };
+
+  const handleTouchMove = () => {
+    if (longPressTimer) {
+      clearTimeout(longPressTimer);
+      setLongPressTimer(null);
+    }
+  };
+
+  // Copy icon SVG
+  const CopyIcon = () => (
+    <svg
+      width="20"
+      height="20"
+      viewBox="0 0 24 24"
+      fill="none"
+      stroke="currentColor"
+      strokeWidth="2"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+    >
+      <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
+      <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
+    </svg>
+  );
+
+  // Info icon SVG
+  const InfoIcon = () => (
+    <svg
+      width="20"
+      height="20"
+      viewBox="0 0 24 24"
+      fill="none"
+      stroke="currentColor"
+      strokeWidth="2"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+    >
+      <circle cx="12" cy="12" r="10"></circle>
+      <line x1="12" y1="16" x2="12" y2="12"></line>
+      <line x1="12" y1="8" x2="12.01" y2="8"></line>
+    </svg>
+  );
+
+  // Handle copy action
+  const handleCopy = () => {
+    const text = getMessageText();
+    if (text) {
+      navigator.clipboard.writeText(text).catch((err) => {
+        console.error("Failed to copy text:", err);
+      });
+    }
+  };
+
+  let displayData: ToolDisplay[] | null = null;
+  if (message.display_data) {
+    try {
+      displayData =
+        typeof message.display_data === "string"
+          ? JSON.parse(message.display_data)
+          : message.display_data;
+    } catch (err) {
+      console.error("Failed to parse display data:", err);
+    }
+  }
+
+  // Parse LLM data if available
+  let llmMessage: LLMMessage | null = null;
+  if (message.llm_data) {
+    try {
+      llmMessage =
+        typeof message.llm_data === "string" ? JSON.parse(message.llm_data) : message.llm_data;
+    } catch (err) {
+      console.error("Failed to parse LLM data:", err);
+    }
+  }
+
+  const isUser = message.type === "user" && !hasToolResult(llmMessage);
+  const isTool = message.type === "tool" || hasToolContent(llmMessage);
+  const isError = message.type === "error";
+
+  // Build context menu items after llmMessage is available
+  const contextMenuItems = [];
+
+  // Always show copy for messages with text content
+  const messageText = getMessageText();
+  if (messageText) {
+    contextMenuItems.push({
+      label: "Copy",
+      icon: <CopyIcon />,
+      onClick: handleCopy,
+    });
+  }
+
+  // Show usage detail only for agent messages with usage data
+  if (message.type === "agent" && usage) {
+    contextMenuItems.push({
+      label: "Usage Detail",
+      icon: <InfoIcon />,
+      onClick: () => setShowUsageModal(true),
+    });
+  }
+
+  // Build a map of tool use IDs to their inputs for linking tool_result back to tool_use
+  const toolUseMap: Record<string, { name: string; input: unknown }> = {};
+  if (llmMessage && llmMessage.Content) {
+    llmMessage.Content.forEach((content) => {
+      if (content.Type === 5 && content.ID && content.ToolName) {
+        // tool_use
+        toolUseMap[content.ID] = {
+          name: content.ToolName,
+          input: content.ToolInput,
+        };
+      }
+    });
+  }
+
+  const renderContent = (content: LLMContent) => {
+    const contentType = getContentType(content.Type);
+
+    switch (contentType) {
+      case "message_role_user":
+      case "message_role_assistant":
+        // These shouldn't occur in Content objects, but display as text if they do
+        return (
+          <div
+            style={{
+              background: "#fff7ed",
+              border: "1px solid #fed7aa",
+              borderRadius: "0.25rem",
+              padding: "0.5rem",
+              fontSize: "0.875rem",
+            }}
+          >
+            <div style={{ color: "#9a3412", fontFamily: "monospace" }}>
+              [Unexpected message role content: {contentType}]
+            </div>
+            <div style={{ marginTop: "0.25rem" }}>{content.Text || JSON.stringify(content)}</div>
+          </div>
+        );
+      case "text":
+        return <div className="whitespace-pre-wrap break-words">{content.Text || ""}</div>;
+      case "tool_use":
+        // IMPORTANT: When adding a new tool component here, also add it to:
+        // 1. The tool_result case below
+        // 2. TOOL_COMPONENTS map in ChatInterface.tsx
+        // See AGENT.md in this directory.
+
+        // Use specialized component for bash tool
+        if (content.ToolName === "bash") {
+          return <BashTool toolInput={content.ToolInput} isRunning={true} />;
+        }
+        // Use specialized component for patch tool
+        if (content.ToolName === "patch") {
+          return <PatchTool toolInput={content.ToolInput} isRunning={true} />;
+        }
+        // Use specialized component for screenshot tool
+        if (content.ToolName === "screenshot" || content.ToolName === "browser_take_screenshot") {
+          return <ScreenshotTool toolInput={content.ToolInput} isRunning={true} />;
+        }
+        // Use specialized component for think tool
+        if (content.ToolName === "think") {
+          return <ThinkTool toolInput={content.ToolInput} isRunning={true} />;
+        }
+        // Use specialized component for change_dir tool
+        if (content.ToolName === "change_dir") {
+          return <ChangeDirTool toolInput={content.ToolInput} isRunning={true} />;
+        }
+        // Use specialized component for keyword search tool
+        if (content.ToolName === "keyword_search") {
+          return <KeywordSearchTool toolInput={content.ToolInput} isRunning={true} />;
+        }
+        // Use specialized component for browser navigate tool
+        if (content.ToolName === "browser_navigate") {
+          return <BrowserNavigateTool toolInput={content.ToolInput} isRunning={true} />;
+        }
+        // Use specialized component for browser eval tool
+        if (content.ToolName === "browser_eval") {
+          return <BrowserEvalTool toolInput={content.ToolInput} isRunning={true} />;
+        }
+        // Use specialized component for read image tool
+        if (content.ToolName === "read_image") {
+          return <ReadImageTool toolInput={content.ToolInput} isRunning={true} />;
+        }
+        // Use specialized component for browser resize tool
+        if (content.ToolName === "browser_resize") {
+          return <BrowserResizeTool toolInput={content.ToolInput} isRunning={true} />;
+        }
+        // Use specialized component for browser console logs tools
+        if (
+          content.ToolName === "browser_recent_console_logs" ||
+          content.ToolName === "browser_clear_console_logs"
+        ) {
+          return (
+            <BrowserConsoleLogsTool
+              toolName={content.ToolName}
+              toolInput={content.ToolInput}
+              isRunning={true}
+            />
+          );
+        }
+        // Default rendering for other tools using GenericTool
+        return (
+          <GenericTool
+            toolName={content.ToolName || "Unknown Tool"}
+            toolInput={content.ToolInput}
+            isRunning={true}
+          />
+        );
+      case "tool_result": {
+        const hasError = content.ToolError;
+        const toolUseId = content.ToolUseID;
+        const startTime = content.ToolUseStartTime;
+        const endTime = content.ToolUseEndTime;
+
+        // Calculate execution time if available
+        let executionTime = "";
+        if (startTime && endTime) {
+          const start = new Date(startTime).getTime();
+          const end = new Date(endTime).getTime();
+          const diffMs = end - start;
+          if (diffMs < 1000) {
+            executionTime = `${diffMs}ms`;
+          } else {
+            executionTime = `${(diffMs / 1000).toFixed(1)}s`;
+          }
+        }
+
+        // Get a short summary of the tool result for mobile-friendly display
+        const getToolResultSummary = (results: LLMContent[]) => {
+          if (!results || results.length === 0) return "No output";
+
+          const firstResult = results[0];
+          if (firstResult.Type === 2 && firstResult.Text) {
+            // text content
+            const text = firstResult.Text.trim();
+            if (text.length <= 50) return text;
+            return text.substring(0, 47) + "...";
+          }
+
+          return `${results.length} result${results.length > 1 ? "s" : ""}`;
+        };
+
+        // unused for now
+        void getToolResultSummary;
+
+        // Get tool information from the toolUseMap or fallback to content
+        const toolInfo = toolUseId && toolUseMap && toolUseMap[toolUseId];
+        const toolName =
+          (toolInfo && typeof toolInfo === "object" && toolInfo.name) ||
+          content.ToolName ||
+          "Unknown Tool";
+        const toolInput = toolInfo && typeof toolInfo === "object" ? toolInfo.input : undefined;
+
+        // Use specialized component for bash tool
+        if (toolName === "bash") {
+          return (
+            <BashTool
+              toolInput={toolInput}
+              isRunning={false}
+              toolResult={content.ToolResult}
+              hasError={hasError}
+              executionTime={executionTime}
+            />
+          );
+        }
+
+        // Use specialized component for patch tool
+        if (toolName === "patch") {
+          return (
+            <PatchTool
+              toolInput={toolInput}
+              isRunning={false}
+              toolResult={content.ToolResult}
+              hasError={hasError}
+              executionTime={executionTime}
+              display={content.Display}
+            />
+          );
+        }
+
+        // Use specialized component for screenshot tool
+        if (toolName === "screenshot" || toolName === "browser_take_screenshot") {
+          return (
+            <ScreenshotTool
+              toolInput={toolInput}
+              isRunning={false}
+              toolResult={content.ToolResult}
+              hasError={hasError}
+              executionTime={executionTime}
+              display={content.Display}
+            />
+          );
+        }
+
+        // Use specialized component for think tool
+        if (toolName === "think") {
+          return (
+            <ThinkTool
+              toolInput={toolInput}
+              isRunning={false}
+              toolResult={content.ToolResult}
+              hasError={hasError}
+              executionTime={executionTime}
+            />
+          );
+        }
+
+        // Use specialized component for change_dir tool
+        if (toolName === "change_dir") {
+          return (
+            <ChangeDirTool
+              toolInput={toolInput}
+              isRunning={false}
+              toolResult={content.ToolResult}
+              hasError={hasError}
+              executionTime={executionTime}
+            />
+          );
+        }
+
+        // Use specialized component for keyword search tool
+        if (toolName === "keyword_search") {
+          return (
+            <KeywordSearchTool
+              toolInput={toolInput}
+              isRunning={false}
+              toolResult={content.ToolResult}
+              hasError={hasError}
+              executionTime={executionTime}
+            />
+          );
+        }
+
+        // Use specialized component for browser navigate tool
+        if (toolName === "browser_navigate") {
+          return (
+            <BrowserNavigateTool
+              toolInput={toolInput}
+              isRunning={false}
+              toolResult={content.ToolResult}
+              hasError={hasError}
+              executionTime={executionTime}
+            />
+          );
+        }
+
+        // Use specialized component for browser eval tool
+        if (toolName === "browser_eval") {
+          return (
+            <BrowserEvalTool
+              toolInput={toolInput}
+              isRunning={false}
+              toolResult={content.ToolResult}
+              hasError={hasError}
+              executionTime={executionTime}
+            />
+          );
+        }
+
+        // Use specialized component for read image tool
+        if (toolName === "read_image") {
+          return (
+            <ReadImageTool
+              toolInput={toolInput}
+              isRunning={false}
+              toolResult={content.ToolResult}
+              hasError={hasError}
+              executionTime={executionTime}
+              display={content.Display}
+            />
+          );
+        }
+
+        // Use specialized component for browser resize tool
+        if (toolName === "browser_resize") {
+          return (
+            <BrowserResizeTool
+              toolInput={toolInput}
+              isRunning={false}
+              toolResult={content.ToolResult}
+              hasError={hasError}
+              executionTime={executionTime}
+            />
+          );
+        }
+
+        // Use specialized component for browser console logs tools
+        if (
+          toolName === "browser_recent_console_logs" ||
+          toolName === "browser_clear_console_logs"
+        ) {
+          return (
+            <BrowserConsoleLogsTool
+              toolName={toolName}
+              toolInput={toolInput}
+              isRunning={false}
+              toolResult={content.ToolResult}
+              hasError={hasError}
+              executionTime={executionTime}
+            />
+          );
+        }
+
+        // Default rendering for other tools using GenericTool
+        return (
+          <GenericTool
+            toolName={toolName}
+            toolInput={toolInput}
+            isRunning={false}
+            toolResult={content.ToolResult}
+            hasError={hasError}
+            executionTime={executionTime}
+          />
+        );
+      }
+      case "redacted_thinking":
+        return <div className="text-tertiary italic text-sm">[Thinking content hidden]</div>;
+      case "thinking":
+        // Hide thinking content by default in main flow, but could be made expandable
+        return null;
+      default: {
+        // For unknown content types, show the type and try to display useful content
+        const displayText = content.Text || content.Data || "";
+        const hasMediaType = content.MediaType;
+        const hasOtherData = Object.keys(content).some(
+          (key) => key !== "Type" && key !== "ID" && content[key as keyof typeof content],
+        );
+
+        return (
+          <div
+            style={{
+              background: "var(--bg-tertiary)",
+              border: "1px solid var(--border)",
+              borderRadius: "0.25rem",
+              padding: "0.75rem",
+            }}
+          >
+            <div
+              className="text-xs text-secondary"
+              style={{ marginBottom: "0.5rem", fontFamily: "monospace" }}
+            >
+              Unknown content type: {contentType} (value: {content.Type})
+            </div>
+
+            {/* Show media content if available */}
+            {hasMediaType && (
+              <div style={{ marginBottom: "0.5rem" }}>
+                <div className="text-xs text-secondary" style={{ marginBottom: "0.25rem" }}>
+                  Media Type: {content.MediaType}
+                </div>
+                {content.MediaType?.startsWith("image/") && content.Data && (
+                  <img
+                    src={`data:${content.MediaType};base64,${content.Data}`}
+                    alt="Tool output image"
+                    className="rounded border"
+                    style={{ maxWidth: "100%", height: "auto", maxHeight: "300px" }}
+                  />
+                )}
+              </div>
+            )}
+
+            {/* Show text content if available */}
+            {displayText && (
+              <div className="text-sm whitespace-pre-wrap break-words">{displayText}</div>
+            )}
+
+            {/* Show raw JSON for debugging if no text content */}
+            {!displayText && hasOtherData && (
+              <details className="text-xs">
+                <summary className="text-secondary" style={{ cursor: "pointer" }}>
+                  Show raw content
+                </summary>
+                <pre
+                  style={{
+                    marginTop: "0.5rem",
+                    padding: "0.5rem",
+                    background: "var(--bg-base)",
+                    borderRadius: "0.25rem",
+                    fontSize: "0.75rem",
+                    overflow: "auto",
+                  }}
+                >
+                  {JSON.stringify(content, null, 2)}
+                </pre>
+              </details>
+            )}
+          </div>
+        );
+      }
+    }
+  };
+
+  // Render display data for tool-specific rendering
+  const renderDisplayData = (toolDisplay: ToolDisplay, toolName?: string) => {
+    const display = toolDisplay.display;
+
+    // Skip rendering screenshot displays here - they are handled by tool_result rendering
+    if (
+      display &&
+      typeof display === "object" &&
+      "type" in display &&
+      display.type === "screenshot"
+    ) {
+      return null;
+    }
+
+    // Infer tool type from display content if tool name not provided
+    const inferredToolName =
+      toolName ||
+      (typeof display === "string" && display.includes("---") && display.includes("+++")
+        ? "patch"
+        : undefined);
+
+    // Render patch tool displays using PatchTool component
+    if (inferredToolName === "patch" && typeof display === "string") {
+      // Create a mock toolResult with the diff in Text field
+      const mockToolResult: LLMContent[] = [
+        {
+          ID: toolDisplay.tool_use_id,
+          Type: 6, // tool_result
+          Text: display,
+        },
+      ];
+
+      return (
+        <PatchTool toolInput={{}} isRunning={false} toolResult={mockToolResult} hasError={false} />
+      );
+    }
+
+    // For other types of display data, use GenericTool component
+    const mockToolResult: LLMContent[] = [
+      {
+        ID: toolDisplay.tool_use_id,
+        Type: 6, // tool_result
+        Text: JSON.stringify(display, null, 2),
+      },
+    ];
+
+    return (
+      <GenericTool
+        toolName={inferredToolName || toolName || "Tool output"}
+        toolInput={{}}
+        isRunning={false}
+        toolResult={mockToolResult}
+        hasError={false}
+      />
+    );
+  };
+
+  const getMessageClasses = () => {
+    if (isUser) {
+      return "message message-user";
+    }
+    if (isError) {
+      return "message message-error";
+    }
+    if (isTool) {
+      return "message message-tool";
+    }
+    return "message message-agent";
+  };
+
+  // Special rendering for error messages
+  if (isError) {
+    let errorText = "An error occurred";
+    if (llmMessage && llmMessage.Content && llmMessage.Content.length > 0) {
+      const textContent = llmMessage.Content.find((c) => c.Type === 2);
+      if (textContent && textContent.Text) {
+        errorText = textContent.Text;
+      }
+    }
+    return (
+      <>
+        <div
+          ref={messageRef}
+          className={getMessageClasses()}
+          onContextMenu={handleContextMenu}
+          onTouchStart={handleTouchStart}
+          onTouchEnd={handleTouchEnd}
+          onTouchMove={handleTouchMove}
+          style={{ position: "relative" }}
+          data-testid="message"
+          role="alert"
+          aria-label="Error message"
+        >
+          <div className="message-content" data-testid="message-content">
+            <div className="whitespace-pre-wrap break-words">{errorText}</div>
+          </div>
+        </div>
+        {contextMenu && contextMenuItems.length > 0 && (
+          <ContextMenu
+            x={contextMenu.x}
+            y={contextMenu.y}
+            onClose={() => setContextMenu(null)}
+            items={contextMenuItems}
+          />
+        )}
+        {showUsageModal && usage && (
+          <UsageDetailModal
+            usage={usage}
+            durationMs={durationMs}
+            onClose={() => setShowUsageModal(false)}
+          />
+        )}
+      </>
+    );
+  }
+
+  // If we have display_data, use that for rendering (more compact, tool-specific)
+  if (displayData && displayData.length > 0) {
+    return (
+      <>
+        <div
+          ref={messageRef}
+          className={getMessageClasses()}
+          onContextMenu={handleContextMenu}
+          onTouchStart={handleTouchStart}
+          onTouchEnd={handleTouchEnd}
+          onTouchMove={handleTouchMove}
+          style={{ position: "relative" }}
+          data-testid="message"
+          role="article"
+        >
+          <div className="message-content" data-testid="message-content">
+            {displayData.map((toolDisplay, index) => (
+              <div key={index}>{renderDisplayData(toolDisplay, toolDisplay.tool_name)}</div>
+            ))}
+          </div>
+        </div>
+        {contextMenu && contextMenuItems.length > 0 && (
+          <ContextMenu
+            x={contextMenu.x}
+            y={contextMenu.y}
+            onClose={() => setContextMenu(null)}
+            items={contextMenuItems}
+          />
+        )}
+        {showUsageModal && usage && (
+          <UsageDetailModal
+            usage={usage}
+            durationMs={durationMs}
+            onClose={() => setShowUsageModal(false)}
+          />
+        )}
+      </>
+    );
+  }
+
+  // Don't render messages with no meaningful content
+  if (!llmMessage || !llmMessage.Content || llmMessage.Content.length === 0) {
+    return null;
+  }
+
+  // Filter out thinking content, empty content, tool_use, and tool_result
+  const meaningfulContent =
+    llmMessage?.Content?.filter((c) => {
+      const contentType = c.Type;
+      // Filter out thinking (3), redacted thinking (4), tool_use (5), tool_result (6), and empty text content
+      return (
+        contentType !== 3 &&
+        contentType !== 4 &&
+        contentType !== 5 &&
+        contentType !== 6 &&
+        (c.Text?.trim() || contentType !== 2)
+      ); // 3 = thinking, 4 = redacted_thinking, 5 = tool_use, 6 = tool_result, 2 = text
+    }) || [];
+
+  // Don't filter out messages that contain operation status like "[Operation cancelled]"
+  const hasOperationStatus = llmMessage?.Content?.some(
+    (c) => c.Type === 2 && c.Text?.includes("[Operation"),
+  );
+
+  if (meaningfulContent.length === 0 && !hasOperationStatus) {
+    return null;
+  }
+
+  // If we have operation status but no meaningful content, render the status
+  const contentToRender =
+    meaningfulContent.length > 0
+      ? meaningfulContent
+      : llmMessage?.Content?.filter((c) => c.Type === 2 && c.Text?.includes("[Operation")) || [];
+
+  return (
+    <>
+      <div
+        ref={messageRef}
+        className={getMessageClasses()}
+        onContextMenu={handleContextMenu}
+        onTouchStart={handleTouchStart}
+        onTouchEnd={handleTouchEnd}
+        onTouchMove={handleTouchMove}
+        style={{ position: "relative" }}
+        data-testid="message"
+        role="article"
+      >
+        {/* Message content */}
+        <div className="message-content" data-testid="message-content">
+          {contentToRender.map((content, index) => (
+            <div key={index}>{renderContent(content)}</div>
+          ))}
+        </div>
+      </div>
+      {contextMenu && contextMenuItems.length > 0 && (
+        <ContextMenu
+          x={contextMenu.x}
+          y={contextMenu.y}
+          onClose={() => setContextMenu(null)}
+          items={contextMenuItems}
+        />
+      )}
+      {showUsageModal && usage && (
+        <UsageDetailModal
+          usage={usage}
+          durationMs={durationMs}
+          onClose={() => setShowUsageModal(false)}
+        />
+      )}
+    </>
+  );
+}
+
+// Helper functions
+function hasToolResult(llmMessage: LLMMessage | null): boolean {
+  if (!llmMessage) return false;
+  return llmMessage.Content?.some((c) => c.Type === 6) ?? false; // 6 = tool_result
+}
+
+function hasToolContent(llmMessage: LLMMessage | null): boolean {
+  if (!llmMessage) return false;
+  return llmMessage.Content?.some((c) => c.Type === 5 || c.Type === 6) ?? false; // 5 = tool_use, 6 = tool_result
+}
+
+export default Message;

ui/src/components/MessageInput.tsx πŸ”—

@@ -0,0 +1,428 @@
+import React, { useState, useRef, useEffect, useCallback } from "react";
+
+// Web Speech API types
+interface SpeechRecognitionEvent extends Event {
+  results: SpeechRecognitionResultList;
+  resultIndex: number;
+}
+
+interface SpeechRecognitionResultList {
+  length: number;
+  item(index: number): SpeechRecognitionResult;
+  [index: number]: SpeechRecognitionResult;
+}
+
+interface SpeechRecognitionResult {
+  isFinal: boolean;
+  length: number;
+  item(index: number): SpeechRecognitionAlternative;
+  [index: number]: SpeechRecognitionAlternative;
+}
+
+interface SpeechRecognitionAlternative {
+  transcript: string;
+  confidence: number;
+}
+
+interface SpeechRecognition extends EventTarget {
+  continuous: boolean;
+  interimResults: boolean;
+  lang: string;
+  onresult: ((event: SpeechRecognitionEvent) => void) | null;
+  onerror: ((event: Event & { error: string }) => void) | null;
+  onend: (() => void) | null;
+  start(): void;
+  stop(): void;
+  abort(): void;
+}
+
+declare global {
+  interface Window {
+    SpeechRecognition: new () => SpeechRecognition;
+    webkitSpeechRecognition: new () => SpeechRecognition;
+  }
+}
+
+interface MessageInputProps {
+  onSend: (message: string) => Promise<void>;
+  disabled?: boolean;
+  autoFocus?: boolean;
+  onFocus?: () => void;
+  injectedText?: string;
+  onClearInjectedText?: () => void;
+  /** If set, persist draft message to localStorage under this key */
+  persistKey?: string;
+}
+
+const PERSIST_KEY_PREFIX = "shelley_draft_";
+
+function MessageInput({
+  onSend,
+  disabled = false,
+  autoFocus = false,
+  onFocus,
+  injectedText,
+  onClearInjectedText,
+  persistKey,
+}: MessageInputProps) {
+  const [message, setMessage] = useState(() => {
+    // Load persisted draft if persistKey is set
+    if (persistKey) {
+      return localStorage.getItem(PERSIST_KEY_PREFIX + persistKey) || "";
+    }
+    return "";
+  });
+  const [submitting, setSubmitting] = useState(false);
+  const [uploadsInProgress, setUploadsInProgress] = useState(0);
+  const [dragCounter, setDragCounter] = useState(0);
+  const [isListening, setIsListening] = useState(false);
+  const textareaRef = useRef<HTMLTextAreaElement>(null);
+  const recognitionRef = useRef<SpeechRecognition | null>(null);
+  // Track the base text (before speech recognition started) and finalized speech text
+  const baseTextRef = useRef<string>("");
+  const finalizedTextRef = useRef<string>("");
+
+  // Check if speech recognition is available
+  const speechRecognitionAvailable =
+    typeof window !== "undefined" && (window.SpeechRecognition || window.webkitSpeechRecognition);
+
+  const stopListening = useCallback(() => {
+    if (recognitionRef.current) {
+      recognitionRef.current.stop();
+      recognitionRef.current = null;
+    }
+    setIsListening(false);
+  }, []);
+
+  const startListening = useCallback(() => {
+    if (!speechRecognitionAvailable) return;
+
+    const SpeechRecognitionClass = window.SpeechRecognition || window.webkitSpeechRecognition;
+    const recognition = new SpeechRecognitionClass();
+
+    recognition.continuous = true;
+    recognition.interimResults = true;
+    recognition.lang = navigator.language || "en-US";
+
+    // Capture current message as base text
+    setMessage((current) => {
+      baseTextRef.current = current;
+      finalizedTextRef.current = "";
+      return current;
+    });
+
+    recognition.onresult = (event: SpeechRecognitionEvent) => {
+      let finalTranscript = "";
+      let interimTranscript = "";
+
+      for (let i = event.resultIndex; i < event.results.length; i++) {
+        const transcript = event.results[i][0].transcript;
+        if (event.results[i].isFinal) {
+          finalTranscript += transcript;
+        } else {
+          interimTranscript += transcript;
+        }
+      }
+
+      // Accumulate finalized text
+      if (finalTranscript) {
+        finalizedTextRef.current += finalTranscript;
+      }
+
+      // Build the full message: base + finalized + interim
+      const base = baseTextRef.current;
+      const needsSpace = base.length > 0 && !/\s$/.test(base);
+      const spacer = needsSpace ? " " : "";
+      const fullText = base + spacer + finalizedTextRef.current + interimTranscript;
+
+      setMessage(fullText);
+    };
+
+    recognition.onerror = (event) => {
+      console.error("Speech recognition error:", event.error);
+      stopListening();
+    };
+
+    recognition.onend = () => {
+      setIsListening(false);
+      recognitionRef.current = null;
+    };
+
+    recognitionRef.current = recognition;
+    recognition.start();
+    setIsListening(true);
+  }, [speechRecognitionAvailable, stopListening]);
+
+  const toggleListening = useCallback(() => {
+    if (isListening) {
+      stopListening();
+    } else {
+      startListening();
+    }
+  }, [isListening, startListening, stopListening]);
+
+  // Cleanup on unmount
+  useEffect(() => {
+    return () => {
+      if (recognitionRef.current) {
+        recognitionRef.current.abort();
+      }
+    };
+  }, []);
+
+  const uploadFile = async (file: File, insertPosition: number) => {
+    const textBefore = message.substring(0, insertPosition);
+    const textAfter = message.substring(insertPosition);
+
+    // Add a loading indicator
+    const loadingText = `[uploading ${file.name}...]`;
+    setMessage(`${textBefore}${loadingText}${textAfter}`);
+    setUploadsInProgress((prev) => prev + 1);
+
+    try {
+      const formData = new FormData();
+      formData.append("file", file);
+
+      const response = await fetch("/api/upload", {
+        method: "POST",
+        headers: { "X-Shelley-Request": "1" },
+        body: formData,
+      });
+
+      if (!response.ok) {
+        throw new Error(`Upload failed: ${response.statusText}`);
+      }
+
+      const data = await response.json();
+
+      // Replace the loading placeholder with the actual file path
+      setMessage((currentMessage) => currentMessage.replace(loadingText, `[${data.path}]`));
+    } catch (error) {
+      console.error("Failed to upload file:", error);
+      // Replace loading indicator with error message
+      const errorText = `[upload failed: ${error instanceof Error ? error.message : "unknown error"}]`;
+      setMessage((currentMessage) => currentMessage.replace(loadingText, errorText));
+    } finally {
+      setUploadsInProgress((prev) => prev - 1);
+    }
+  };
+
+  const handlePaste = async (event: React.ClipboardEvent) => {
+    // Check clipboard items (works on both desktop and mobile)
+    // Mobile browsers often don't populate clipboardData.files, but items works
+    const items = event.clipboardData?.items;
+    if (items) {
+      for (let i = 0; i < items.length; i++) {
+        const item = items[i];
+        if (item.kind === "file") {
+          const file = item.getAsFile();
+          if (file) {
+            event.preventDefault();
+            const cursorPos = textareaRef.current?.selectionStart ?? message.length;
+            await uploadFile(file, cursorPos);
+            return;
+          }
+        }
+      }
+    }
+  };
+
+  const handleDragOver = (event: React.DragEvent) => {
+    event.preventDefault();
+    event.stopPropagation();
+  };
+
+  const handleDragEnter = (event: React.DragEvent) => {
+    event.preventDefault();
+    event.stopPropagation();
+    setDragCounter((prev) => prev + 1);
+  };
+
+  const handleDragLeave = (event: React.DragEvent) => {
+    event.preventDefault();
+    event.stopPropagation();
+    setDragCounter((prev) => prev - 1);
+  };
+
+  const handleDrop = async (event: React.DragEvent) => {
+    event.preventDefault();
+    event.stopPropagation();
+    setDragCounter(0);
+
+    if (event.dataTransfer && event.dataTransfer.files.length > 0) {
+      // Process all dropped files
+      for (let i = 0; i < event.dataTransfer.files.length; i++) {
+        const file = event.dataTransfer.files[i];
+        const insertPosition =
+          i === 0 ? (textareaRef.current?.selectionStart ?? message.length) : message.length;
+        await uploadFile(file, insertPosition);
+        // Add a space between files
+        if (i < event.dataTransfer.files.length - 1) {
+          setMessage((prev) => prev + " ");
+        }
+      }
+    }
+  };
+
+  // Auto-insert injected text (diff comments) directly into the textarea
+  useEffect(() => {
+    if (injectedText) {
+      setMessage((prev) => {
+        const needsNewline = prev.length > 0 && !prev.endsWith("\n");
+        return prev + (needsNewline ? "\n\n" : "") + injectedText;
+      });
+      onClearInjectedText?.();
+      // Focus the textarea after inserting
+      setTimeout(() => textareaRef.current?.focus(), 0);
+    }
+  }, [injectedText, onClearInjectedText]);
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (message.trim() && !disabled && !submitting && uploadsInProgress === 0) {
+      // Stop listening if we were recording
+      if (isListening) {
+        stopListening();
+      }
+
+      const messageToSend = message;
+      setSubmitting(true);
+      try {
+        await onSend(messageToSend);
+        // Only clear on success
+        setMessage("");
+        // Clear persisted draft on successful send
+        if (persistKey) {
+          localStorage.removeItem(PERSIST_KEY_PREFIX + persistKey);
+        }
+      } catch {
+        // Keep the message on error so user can retry
+      } finally {
+        setSubmitting(false);
+      }
+    }
+  };
+
+  const handleKeyDown = (e: React.KeyboardEvent) => {
+    if (e.key === "Enter" && !e.shiftKey) {
+      e.preventDefault();
+      handleSubmit(e);
+    }
+  };
+
+  const adjustTextareaHeight = () => {
+    if (textareaRef.current) {
+      textareaRef.current.style.height = "auto";
+      const scrollHeight = textareaRef.current.scrollHeight;
+      const maxHeight = 200; // Maximum height in pixels
+      textareaRef.current.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
+    }
+  };
+
+  useEffect(() => {
+    adjustTextareaHeight();
+  }, [message]);
+
+  // Persist draft to localStorage when persistKey is set
+  useEffect(() => {
+    if (persistKey) {
+      if (message) {
+        localStorage.setItem(PERSIST_KEY_PREFIX + persistKey, message);
+      } else {
+        localStorage.removeItem(PERSIST_KEY_PREFIX + persistKey);
+      }
+    }
+  }, [message, persistKey]);
+
+  useEffect(() => {
+    if (autoFocus && textareaRef.current) {
+      // Use setTimeout to ensure the component is fully rendered
+      setTimeout(() => {
+        textareaRef.current?.focus();
+      }, 0);
+    }
+  }, [autoFocus]);
+
+  const isDisabled = disabled || uploadsInProgress > 0;
+  const canSubmit = message.trim() && !isDisabled && !submitting;
+
+  const isDraggingOver = dragCounter > 0;
+  // Note: injectedText is auto-inserted via useEffect, no manual UI needed
+
+  return (
+    <div
+      className={`message-input-container ${isDraggingOver ? "drag-over" : ""}`}
+      onDragOver={handleDragOver}
+      onDragEnter={handleDragEnter}
+      onDragLeave={handleDragLeave}
+      onDrop={handleDrop}
+    >
+      {isDraggingOver && (
+        <div className="drag-overlay">
+          <div className="drag-overlay-content">Drop files here</div>
+        </div>
+      )}
+      <form onSubmit={handleSubmit} className="message-input-form">
+        <textarea
+          ref={textareaRef}
+          value={message}
+          onChange={(e) => setMessage(e.target.value)}
+          onKeyDown={handleKeyDown}
+          onPaste={handlePaste}
+          onFocus={() => {
+            // Scroll to bottom after keyboard animation settles
+            if (onFocus) {
+              requestAnimationFrame(() => requestAnimationFrame(onFocus));
+            }
+          }}
+          placeholder="Type your message..."
+          className="message-textarea"
+          disabled={isDisabled}
+          rows={1}
+          aria-label="Message input"
+          data-testid="message-input"
+          autoFocus={autoFocus}
+        />
+        {speechRecognitionAvailable && (
+          <button
+            type="button"
+            onClick={toggleListening}
+            disabled={isDisabled}
+            className={`message-voice-btn ${isListening ? "listening" : ""}`}
+            aria-label={isListening ? "Stop voice input" : "Start voice input"}
+            data-testid="voice-button"
+          >
+            {isListening ? (
+              <svg fill="currentColor" viewBox="0 0 24 24" width="20" height="20">
+                <circle cx="12" cy="12" r="6" />
+              </svg>
+            ) : (
+              <svg fill="currentColor" viewBox="0 0 24 24" width="20" height="20">
+                <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
+              </svg>
+            )}
+          </button>
+        )}
+        <button
+          type="submit"
+          disabled={!canSubmit}
+          className="message-send-btn"
+          aria-label="Send message"
+          data-testid="send-button"
+        >
+          {isDisabled || submitting ? (
+            <div className="flex items-center justify-center">
+              <div className="spinner spinner-small" style={{ borderTopColor: "white" }}></div>
+            </div>
+          ) : (
+            <svg fill="currentColor" viewBox="0 0 24 24" width="20" height="20">
+              <path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z" />
+            </svg>
+          )}
+        </button>
+      </form>
+    </div>
+  );
+}
+
+export default MessageInput;

ui/src/components/Modal.tsx πŸ”—

@@ -0,0 +1,44 @@
+import React from "react";
+
+interface ModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  title: string;
+  children: React.ReactNode;
+}
+
+function Modal({ isOpen, onClose, title, children }: ModalProps) {
+  if (!isOpen) return null;
+
+  const handleBackdropClick = (e: React.MouseEvent) => {
+    if (e.target === e.currentTarget) {
+      onClose();
+    }
+  };
+
+  return (
+    <div className="modal-overlay" onClick={handleBackdropClick}>
+      <div className="modal">
+        {/* Header */}
+        <div className="modal-header">
+          <h2 className="modal-title">{title}</h2>
+          <button onClick={onClose} className="btn-icon" aria-label="Close modal">
+            <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2}
+                d="M6 18L18 6M6 6l12 12"
+              />
+            </svg>
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="modal-body">{children}</div>
+      </div>
+    </div>
+  );
+}
+
+export default Modal;

ui/src/components/PatchTool.tsx πŸ”—

@@ -0,0 +1,165 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface PatchToolProps {
+  // For tool_use (pending state)
+  toolInput?: unknown;
+  isRunning?: boolean;
+
+  // For tool_result (completed state)
+  toolResult?: LLMContent[];
+  hasError?: boolean;
+  executionTime?: string;
+  display?: unknown; // Display data from the tool_result Content (contains the diff)
+}
+
+function PatchTool({
+  toolInput,
+  isRunning,
+  toolResult,
+  hasError,
+  executionTime,
+  display,
+}: PatchToolProps) {
+  // Default to collapsed for errors (since agents typically recover), expanded otherwise
+  const [isExpanded, setIsExpanded] = useState(!hasError);
+
+  // Extract path from toolInput
+  const path =
+    typeof toolInput === "object" &&
+    toolInput !== null &&
+    "path" in toolInput &&
+    typeof toolInput.path === "string"
+      ? toolInput.path
+      : typeof toolInput === "string"
+        ? toolInput
+        : "";
+
+  // Extract diff from display (preferred) or fall back to toolResult
+  const diff =
+    typeof display === "string"
+      ? display
+      : toolResult && toolResult.length > 0 && toolResult[0].Text
+        ? toolResult[0].Text
+        : "";
+
+  const isComplete = !isRunning && toolResult !== undefined;
+
+  // Parse unified diff to extract filename and colorize lines
+  const parseDiff = (diffText: string) => {
+    if (!diffText) return { filename: path, lines: [] };
+
+    const lines = diffText.split("\n");
+    let filename = path;
+
+    // Extract filename from diff header if present
+    for (const line of lines) {
+      if (line.startsWith("---")) {
+        // Format: --- a/path/to/file.txt
+        const match = line.match(/^---\s+(.+?)\s*$/);
+        if (match) {
+          filename = match[1].replace(/^[ab]\//, ""); // Remove a/ or b/ prefix
+        }
+      }
+    }
+
+    return { filename, lines };
+  };
+
+  const { filename, lines } = parseDiff(diff);
+
+  return (
+    <div
+      className="patch-tool"
+      data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}
+    >
+      <div className="patch-tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+        <div className="patch-tool-summary">
+          <span className={`patch-tool-emoji ${isRunning ? "running" : ""}`}>πŸ–‹οΈ</span>
+          <span className="patch-tool-filename">{filename || "patch"}</span>
+          {isComplete && hasError && <span className="patch-tool-error">βœ—</span>}
+          {isComplete && !hasError && <span className="patch-tool-success">βœ“</span>}
+        </div>
+        <button
+          className="patch-tool-toggle"
+          aria-label={isExpanded ? "Collapse" : "Expand"}
+          aria-expanded={isExpanded}
+        >
+          <svg
+            width="12"
+            height="12"
+            viewBox="0 0 12 12"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+            style={{
+              transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+              transition: "transform 0.2s",
+            }}
+          >
+            <path
+              d="M4.5 3L7.5 6L4.5 9"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {isExpanded && (
+        <div className="patch-tool-details">
+          {isComplete && !hasError && diff && (
+            <div className="patch-tool-section">
+              {executionTime && (
+                <div className="patch-tool-label">
+                  <span>Diff:</span>
+                  <span className="patch-tool-time">{executionTime}</span>
+                </div>
+              )}
+              <pre className="patch-tool-diff">
+                {lines.map((line, idx) => {
+                  // Determine line type for styling
+                  let className = "patch-diff-line";
+                  if (line.startsWith("+") && !line.startsWith("+++")) {
+                    className += " patch-diff-addition";
+                  } else if (line.startsWith("-") && !line.startsWith("---")) {
+                    className += " patch-diff-deletion";
+                  } else if (line.startsWith("@@")) {
+                    className += " patch-diff-hunk";
+                  } else if (line.startsWith("---") || line.startsWith("+++")) {
+                    className += " patch-diff-header";
+                  }
+
+                  return (
+                    <div key={idx} className={className}>
+                      {line || " "}
+                    </div>
+                  );
+                })}
+              </pre>
+            </div>
+          )}
+
+          {isComplete && hasError && (
+            <div className="patch-tool-section">
+              <div className="patch-tool-label">
+                <span>Error:</span>
+                {executionTime && <span className="patch-tool-time">{executionTime}</span>}
+              </div>
+              <pre className="patch-tool-error-message">{diff || "Patch failed"}</pre>
+            </div>
+          )}
+
+          {isRunning && (
+            <div className="patch-tool-section">
+              <div className="patch-tool-label">Applying patch...</div>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default PatchTool;

ui/src/components/ReadImageTool.tsx πŸ”—

@@ -0,0 +1,158 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface ReadImageToolProps {
+  toolInput?: unknown; // { path: string }
+  isRunning?: boolean;
+  toolResult?: LLMContent[];
+  hasError?: boolean;
+  executionTime?: string;
+  display?: unknown; // Display data from the tool_result Content
+}
+
+function ReadImageTool({
+  toolInput,
+  isRunning,
+  toolResult,
+  hasError,
+  executionTime,
+  display,
+}: ReadImageToolProps) {
+  const [isExpanded, setIsExpanded] = useState(true); // Default to expanded
+
+  // Extract display info from toolInput
+  const getPath = (input: unknown): string | undefined => {
+    if (
+      typeof input === "object" &&
+      input !== null &&
+      "path" in input &&
+      typeof input.path === "string"
+    ) {
+      return input.path;
+    }
+    return undefined;
+  };
+
+  const getId = (input: unknown): string | undefined => {
+    if (
+      typeof input === "object" &&
+      input !== null &&
+      "id" in input &&
+      typeof input.id === "string"
+    ) {
+      return input.id;
+    }
+    return undefined;
+  };
+
+  const filename = getPath(toolInput) || getId(toolInput) || "image";
+
+  // Use display data passed as prop (from tool_result Content.Display)
+  const displayData = display;
+
+  // Construct image URL
+  let imageUrl: string | undefined = undefined;
+  if (displayData && typeof displayData === "object" && displayData !== null) {
+    const url =
+      "url" in displayData && typeof displayData.url === "string" ? displayData.url : undefined;
+    const path =
+      "path" in displayData && typeof displayData.path === "string" ? displayData.path : undefined;
+    const id =
+      "id" in displayData && typeof displayData.id === "string" ? displayData.id : undefined;
+
+    imageUrl =
+      url ||
+      (path
+        ? `/api/read?path=${encodeURIComponent(path)}`
+        : id
+          ? `/api/read?path=${encodeURIComponent(id)}`
+          : undefined);
+  }
+
+  const isComplete = !isRunning && toolResult !== undefined;
+
+  return (
+    <div
+      className="screenshot-tool"
+      data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}
+    >
+      <div className="screenshot-tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+        <div className="screenshot-tool-summary">
+          <span className={`screenshot-tool-emoji ${isRunning ? "running" : ""}`}>πŸ–ΌοΈ</span>
+          <span className="screenshot-tool-filename">{filename}</span>
+          {isComplete && hasError && <span className="screenshot-tool-error">βœ—</span>}
+          {isComplete && !hasError && <span className="screenshot-tool-success">βœ“</span>}
+        </div>
+        <button
+          className="screenshot-tool-toggle"
+          aria-label={isExpanded ? "Collapse" : "Expand"}
+          aria-expanded={isExpanded}
+        >
+          <svg
+            width="12"
+            height="12"
+            viewBox="0 0 12 12"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+            style={{
+              transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+              transition: "transform 0.2s",
+            }}
+          >
+            <path
+              d="M4.5 3L7.5 6L4.5 9"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {isExpanded && (
+        <div className="screenshot-tool-details">
+          {isComplete && !hasError && imageUrl && (
+            <div className="screenshot-tool-section">
+              {executionTime && (
+                <div className="screenshot-tool-label">
+                  <span>Image:</span>
+                  <span className="screenshot-tool-time">{executionTime}</span>
+                </div>
+              )}
+              <div className="screenshot-tool-image-container">
+                <a href={imageUrl} target="_blank" rel="noopener noreferrer">
+                  <img
+                    src={imageUrl}
+                    alt={`Image: ${filename}`}
+                    style={{ maxWidth: "100%", height: "auto" }}
+                  />
+                </a>
+              </div>
+            </div>
+          )}
+
+          {isComplete && hasError && (
+            <div className="screenshot-tool-section">
+              <div className="screenshot-tool-label">
+                <span>Error:</span>
+                {executionTime && <span className="screenshot-tool-time">{executionTime}</span>}
+              </div>
+              <pre className="screenshot-tool-error-message">
+                {toolResult && toolResult[0]?.Text ? toolResult[0].Text : "Image read failed"}
+              </pre>
+            </div>
+          )}
+
+          {isRunning && (
+            <div className="screenshot-tool-section">
+              <div className="screenshot-tool-label">Reading image...</div>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default ReadImageTool;

ui/src/components/ScreenshotTool.tsx πŸ”—

@@ -0,0 +1,175 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface ScreenshotToolProps {
+  // For tool_use (pending state)
+  toolInput?: unknown;
+  isRunning?: boolean;
+
+  // For tool_result (completed state)
+  toolResult?: LLMContent[];
+  hasError?: boolean;
+  executionTime?: string;
+  display?: unknown; // Display data from the tool_result Content
+}
+
+function ScreenshotTool({
+  toolInput,
+  isRunning,
+  toolResult,
+  hasError,
+  executionTime,
+  display,
+}: ScreenshotToolProps) {
+  const [isExpanded, setIsExpanded] = useState(true); // Default to expanded
+
+  // Extract display info from toolInput
+  const getPath = (input: unknown): string | undefined => {
+    if (
+      typeof input === "object" &&
+      input !== null &&
+      "path" in input &&
+      typeof input.path === "string"
+    ) {
+      return input.path;
+    }
+    return undefined;
+  };
+
+  const getId = (input: unknown): string | undefined => {
+    if (
+      typeof input === "object" &&
+      input !== null &&
+      "id" in input &&
+      typeof input.id === "string"
+    ) {
+      return input.id;
+    }
+    return undefined;
+  };
+
+  const getSelector = (input: unknown): string | undefined => {
+    if (
+      typeof input === "object" &&
+      input !== null &&
+      "selector" in input &&
+      typeof input.selector === "string"
+    ) {
+      return input.selector;
+    }
+    return undefined;
+  };
+
+  const filename = getPath(toolInput) || getId(toolInput) || getSelector(toolInput) || "screenshot";
+
+  // Use display data passed as prop (from tool_result Content.Display)
+  const displayData = display;
+
+  // Construct image URL
+  let imageUrl: string | undefined = undefined;
+  if (displayData && typeof displayData === "object" && displayData !== null) {
+    const url =
+      "url" in displayData && typeof displayData.url === "string" ? displayData.url : undefined;
+    const path =
+      "path" in displayData && typeof displayData.path === "string" ? displayData.path : undefined;
+    const id =
+      "id" in displayData && typeof displayData.id === "string" ? displayData.id : undefined;
+
+    imageUrl =
+      url ||
+      (path
+        ? `/api/read?path=${encodeURIComponent(path)}`
+        : id
+          ? `/api/read?path=${encodeURIComponent(id)}`
+          : undefined);
+  }
+
+  const isComplete = !isRunning && toolResult !== undefined;
+
+  return (
+    <div
+      className="screenshot-tool"
+      data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}
+    >
+      <div className="screenshot-tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+        <div className="screenshot-tool-summary">
+          <span className={`screenshot-tool-emoji ${isRunning ? "running" : ""}`}>πŸ“·</span>
+          <span className="screenshot-tool-filename">{filename}</span>
+          {isComplete && hasError && <span className="screenshot-tool-error">βœ—</span>}
+          {isComplete && !hasError && <span className="screenshot-tool-success">βœ“</span>}
+        </div>
+        <button
+          className="screenshot-tool-toggle"
+          aria-label={isExpanded ? "Collapse" : "Expand"}
+          aria-expanded={isExpanded}
+        >
+          <svg
+            width="12"
+            height="12"
+            viewBox="0 0 12 12"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+            style={{
+              transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+              transition: "transform 0.2s",
+            }}
+          >
+            <path
+              d="M4.5 3L7.5 6L4.5 9"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {isExpanded && (
+        <div className="screenshot-tool-details">
+          {isComplete && !hasError && imageUrl && (
+            <div className="screenshot-tool-section">
+              {executionTime && (
+                <div className="screenshot-tool-label">
+                  <span>Screenshot:</span>
+                  <span className="screenshot-tool-time">{executionTime}</span>
+                </div>
+              )}
+              <div className="screenshot-tool-image-container">
+                <a href={imageUrl} target="_blank" rel="noopener noreferrer">
+                  <img
+                    src={imageUrl}
+                    alt={`Screenshot: ${filename}`}
+                    style={{ maxWidth: "100%", height: "auto" }}
+                  />
+                </a>
+              </div>
+            </div>
+          )}
+
+          {isComplete && hasError && (
+            <div className="screenshot-tool-section">
+              <div className="screenshot-tool-label">
+                <span>Error:</span>
+                {executionTime && <span className="screenshot-tool-time">{executionTime}</span>}
+              </div>
+              <pre className="screenshot-tool-error-message">
+                {toolResult && toolResult[0]?.Text
+                  ? toolResult[0].Text
+                  : "Screenshot capture failed"}
+              </pre>
+            </div>
+          )}
+
+          {isRunning && (
+            <div className="screenshot-tool-section">
+              <div className="screenshot-tool-label">Capturing screenshot...</div>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default ScreenshotTool;

ui/src/components/ThinkTool.tsx πŸ”—

@@ -0,0 +1,94 @@
+import React, { useState } from "react";
+import { LLMContent } from "../types";
+
+interface ThinkToolProps {
+  // For tool_use (pending state)
+  toolInput?: unknown; // { thoughts: string }
+  isRunning?: boolean;
+
+  // For tool_result (completed state)
+  toolResult?: LLMContent[];
+  hasError?: boolean;
+  executionTime?: string;
+}
+
+function ThinkTool({ toolInput, isRunning, toolResult, hasError, executionTime }: ThinkToolProps) {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  // Extract thoughts from toolInput
+  const thoughts =
+    typeof toolInput === "object" &&
+    toolInput !== null &&
+    "thoughts" in toolInput &&
+    typeof toolInput.thoughts === "string"
+      ? toolInput.thoughts
+      : typeof toolInput === "string"
+        ? toolInput
+        : "";
+
+  // Truncate thoughts for display - get first 50 chars
+  const truncateThoughts = (text: string, maxLen: number = 50) => {
+    if (!text) return "";
+    if (text.length <= maxLen) return text;
+    return text.substring(0, maxLen) + "...";
+  };
+
+  const displayThoughts = truncateThoughts(thoughts);
+  const isComplete = !isRunning && toolResult !== undefined;
+
+  return (
+    <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
+      <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
+        <div className="tool-summary">
+          <span className={`tool-emoji ${isRunning ? "running" : ""}`}>πŸ’­</span>
+          <span className="tool-command">
+            {displayThoughts || (isRunning ? "thinking..." : "thinking...")}
+          </span>
+          {isComplete && hasError && <span className="tool-error">βœ—</span>}
+          {isComplete && !hasError && <span className="tool-success">βœ“</span>}
+        </div>
+        <button
+          className="tool-toggle"
+          aria-label={isExpanded ? "Collapse" : "Expand"}
+          aria-expanded={isExpanded}
+        >
+          <svg
+            width="12"
+            height="12"
+            viewBox="0 0 12 12"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+            style={{
+              transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+              transition: "transform 0.2s",
+            }}
+          >
+            <path
+              d="M4.5 3L7.5 6L4.5 9"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </svg>
+        </button>
+      </div>
+
+      {isExpanded && (
+        <div className="tool-details">
+          <div className="tool-section">
+            <div className="tool-label">
+              Thoughts:
+              {executionTime && <span className="tool-time">{executionTime}</span>}
+            </div>
+            <div className={`tool-code ${hasError ? "error" : ""}`}>
+              {thoughts || "(no thoughts)"}
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default ThinkTool;

ui/src/components/UsageDetailModal.tsx πŸ”—

@@ -0,0 +1,166 @@
+import React from "react";
+import { Usage } from "../types";
+
+interface UsageDetailModalProps {
+  usage: Usage;
+  durationMs: number | null;
+  onClose: () => void;
+}
+
+function UsageDetailModal({ usage, durationMs, onClose }: UsageDetailModalProps) {
+  // Format duration in human-readable format
+  const formatDuration = (ms: number): string => {
+    if (ms < 1000) return `${ms}ms`;
+    if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`;
+    return `${(ms / 60000).toFixed(2)}m`;
+  };
+
+  // Format timestamp for display
+  const formatTimestamp = (isoString: string): string => {
+    const date = new Date(isoString);
+    return date.toLocaleString(undefined, {
+      year: "numeric",
+      month: "short",
+      day: "numeric",
+      hour: "2-digit",
+      minute: "2-digit",
+      second: "2-digit",
+    });
+  };
+
+  // Close on escape key
+  React.useEffect(() => {
+    const handleEscape = (e: KeyboardEvent) => {
+      if (e.key === "Escape") {
+        onClose();
+      }
+    };
+    document.addEventListener("keydown", handleEscape);
+    return () => document.removeEventListener("keydown", handleEscape);
+  }, [onClose]);
+
+  return (
+    <div
+      style={{
+        position: "fixed",
+        top: 0,
+        left: 0,
+        right: 0,
+        bottom: 0,
+        backgroundColor: "rgba(0, 0, 0, 0.5)",
+        display: "flex",
+        alignItems: "center",
+        justifyContent: "center",
+        zIndex: 10001,
+        padding: "16px",
+      }}
+      onClick={onClose}
+    >
+      <div
+        style={{
+          backgroundColor: "#ffffff",
+          borderRadius: "8px",
+          padding: "24px",
+          maxWidth: "500px",
+          width: "100%",
+          boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
+        }}
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div
+          style={{
+            display: "flex",
+            justifyContent: "space-between",
+            alignItems: "center",
+            marginBottom: "20px",
+          }}
+        >
+          <h2 style={{ fontSize: "18px", fontWeight: "600", color: "#1f2937", margin: 0 }}>
+            Usage Details
+          </h2>
+          <button
+            onClick={onClose}
+            style={{
+              background: "none",
+              border: "none",
+              fontSize: "24px",
+              color: "#6b7280",
+              cursor: "pointer",
+              padding: "0",
+              width: "32px",
+              height: "32px",
+              display: "flex",
+              alignItems: "center",
+              justifyContent: "center",
+              borderRadius: "4px",
+            }}
+            onMouseEnter={(e) => {
+              e.currentTarget.style.backgroundColor = "#f3f4f6";
+            }}
+            onMouseLeave={(e) => {
+              e.currentTarget.style.backgroundColor = "transparent";
+            }}
+            aria-label="Close"
+          >
+            Γ—
+          </button>
+        </div>
+        <div
+          style={{
+            display: "grid",
+            gridTemplateColumns: "auto 1fr",
+            gap: "12px 20px",
+            fontSize: "14px",
+          }}
+        >
+          {usage.model && (
+            <>
+              <div style={{ color: "#6b7280", fontWeight: "500" }}>Model:</div>
+              <div style={{ color: "#1f2937" }}>{usage.model}</div>
+            </>
+          )}
+          <div style={{ color: "#6b7280", fontWeight: "500" }}>Input Tokens:</div>
+          <div style={{ color: "#1f2937" }}>{usage.input_tokens.toLocaleString()}</div>
+          {usage.cache_read_input_tokens > 0 && (
+            <>
+              <div style={{ color: "#6b7280", fontWeight: "500" }}>Cache Read:</div>
+              <div style={{ color: "#1f2937" }}>
+                {usage.cache_read_input_tokens.toLocaleString()}
+              </div>
+            </>
+          )}
+          {usage.cache_creation_input_tokens > 0 && (
+            <>
+              <div style={{ color: "#6b7280", fontWeight: "500" }}>Cache Write:</div>
+              <div style={{ color: "#1f2937" }}>
+                {usage.cache_creation_input_tokens.toLocaleString()}
+              </div>
+            </>
+          )}
+          <div style={{ color: "#6b7280", fontWeight: "500" }}>Output Tokens:</div>
+          <div style={{ color: "#1f2937" }}>{usage.output_tokens.toLocaleString()}</div>
+          {usage.cost_usd > 0 && (
+            <>
+              <div style={{ color: "#6b7280", fontWeight: "500" }}>Cost:</div>
+              <div style={{ color: "#1f2937" }}>${usage.cost_usd.toFixed(4)}</div>
+            </>
+          )}
+          {durationMs !== null && (
+            <>
+              <div style={{ color: "#6b7280", fontWeight: "500" }}>Duration:</div>
+              <div style={{ color: "#1f2937" }}>{formatDuration(durationMs)}</div>
+            </>
+          )}
+          {usage.end_time && (
+            <>
+              <div style={{ color: "#6b7280", fontWeight: "500" }}>Timestamp:</div>
+              <div style={{ color: "#1f2937" }}>{formatTimestamp(usage.end_time)}</div>
+            </>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export default UsageDetailModal;

ui/src/generated-types.ts πŸ”—

@@ -0,0 +1,46 @@
+// Auto-generated by shelley.exe.dev/cmd/go2ts.go
+// Do not edit manually - regenerate with: go run ./cmd/go2ts -o ui/src/generated-types.ts
+
+// DO NOT EDIT. This file is automatically generated.
+
+export interface Conversation {
+  conversation_id: string;
+  slug: string | null;
+  user_initiated: boolean;
+  created_at: string;
+  updated_at: string;
+  cwd: string | null;
+  archived: boolean;
+}
+
+export interface Usage {
+  input_tokens: number;
+  cache_creation_input_tokens: number;
+  cache_read_input_tokens: number;
+  output_tokens: number;
+  cost_usd: number;
+  model?: string;
+  start_time?: string | null;
+  end_time?: string | null;
+}
+
+export interface ApiMessageForTS {
+  message_id: string;
+  conversation_id: string;
+  sequence_id: number;
+  type: string;
+  llm_data?: string | null;
+  user_data?: string | null;
+  usage_data?: string | null;
+  created_at: string;
+  display_data?: string | null;
+  end_of_turn?: boolean | null;
+}
+
+export interface StreamResponseForTS {
+  messages: ApiMessageForTS[] | null;
+  conversation: Conversation;
+  agent_working: boolean;
+}
+
+export type MessageType = "user" | "agent" | "tool" | "error" | "system";

ui/src/index.html πŸ”—

@@ -0,0 +1,22 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta
+      name="viewport"
+      content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
+    />
+    <meta name="theme-color" content="#1f2937" />
+    <meta name="apple-mobile-web-app-capable" content="yes" />
+    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+    <meta name="apple-mobile-web-app-title" content="Shelley" />
+    <link rel="manifest" href="/manifest.json" />
+    <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
+    <title>Shelley Agent</title>
+    <link rel="stylesheet" href="/styles.css" />
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/main.js"></script>
+  </body>
+</html>

ui/src/main.tsx πŸ”—

@@ -0,0 +1,10 @@
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App";
+
+// Render main app
+const rootContainer = document.getElementById("root");
+if (!rootContainer) throw new Error("Root container not found");
+
+const root = createRoot(rootContainer);
+root.render(<App />);

ui/src/services/api.ts πŸ”—

@@ -0,0 +1,192 @@
+import {
+  Conversation,
+  StreamResponse,
+  ChatRequest,
+  GitDiffInfo,
+  GitFileInfo,
+  GitFileDiff,
+} from "../types";
+
+class ApiService {
+  private baseUrl = "/api";
+
+  // Common headers for state-changing requests (CSRF protection)
+  private postHeaders = {
+    "Content-Type": "application/json",
+    "X-Shelley-Request": "1",
+  };
+
+  async getConversations(): Promise<Conversation[]> {
+    const response = await fetch(`${this.baseUrl}/conversations`);
+    if (!response.ok) {
+      throw new Error(`Failed to get conversations: ${response.statusText}`);
+    }
+    return response.json();
+  }
+
+  async sendMessageWithNewConversation(request: ChatRequest): Promise<{ conversation_id: string }> {
+    const response = await fetch(`${this.baseUrl}/conversations/new`, {
+      method: "POST",
+      headers: this.postHeaders,
+      body: JSON.stringify(request),
+    });
+    if (!response.ok) {
+      throw new Error(`Failed to send message: ${response.statusText}`);
+    }
+    return response.json();
+  }
+
+  async getConversation(conversationId: string): Promise<StreamResponse> {
+    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}`);
+    if (!response.ok) {
+      throw new Error(`Failed to get messages: ${response.statusText}`);
+    }
+    return response.json();
+  }
+
+  async sendMessage(conversationId: string, request: ChatRequest): Promise<void> {
+    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/chat`, {
+      method: "POST",
+      headers: this.postHeaders,
+      body: JSON.stringify(request),
+    });
+    if (!response.ok) {
+      throw new Error(`Failed to send message: ${response.statusText}`);
+    }
+  }
+
+  createMessageStream(conversationId: string): EventSource {
+    return new EventSource(`${this.baseUrl}/conversation/${conversationId}/stream`);
+  }
+
+  async cancelConversation(conversationId: string): Promise<void> {
+    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/cancel`, {
+      method: "POST",
+      headers: { "X-Shelley-Request": "1" },
+    });
+    if (!response.ok) {
+      throw new Error(`Failed to cancel conversation: ${response.statusText}`);
+    }
+  }
+
+  async validateCwd(path: string): Promise<{ valid: boolean; error?: string }> {
+    const response = await fetch(`${this.baseUrl}/validate-cwd?path=${encodeURIComponent(path)}`);
+    if (!response.ok) {
+      throw new Error(`Failed to validate cwd: ${response.statusText}`);
+    }
+    return response.json();
+  }
+
+  async listDirectory(path?: string): Promise<{
+    path: string;
+    parent: string;
+    entries: Array<{ name: string; is_dir: boolean }>;
+    error?: string;
+  }> {
+    const url = path
+      ? `${this.baseUrl}/list-directory?path=${encodeURIComponent(path)}`
+      : `${this.baseUrl}/list-directory`;
+    const response = await fetch(url);
+    if (!response.ok) {
+      throw new Error(`Failed to list directory: ${response.statusText}`);
+    }
+    return response.json();
+  }
+
+  async getArchivedConversations(): Promise<Conversation[]> {
+    const response = await fetch(`${this.baseUrl}/conversations/archived`);
+    if (!response.ok) {
+      throw new Error(`Failed to get archived conversations: ${response.statusText}`);
+    }
+    return response.json();
+  }
+
+  async archiveConversation(conversationId: string): Promise<Conversation> {
+    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/archive`, {
+      method: "POST",
+      headers: { "X-Shelley-Request": "1" },
+    });
+    if (!response.ok) {
+      throw new Error(`Failed to archive conversation: ${response.statusText}`);
+    }
+    return response.json();
+  }
+
+  async unarchiveConversation(conversationId: string): Promise<Conversation> {
+    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/unarchive`, {
+      method: "POST",
+      headers: { "X-Shelley-Request": "1" },
+    });
+    if (!response.ok) {
+      throw new Error(`Failed to unarchive conversation: ${response.statusText}`);
+    }
+    return response.json();
+  }
+
+  async deleteConversation(conversationId: string): Promise<void> {
+    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/delete`, {
+      method: "POST",
+      headers: { "X-Shelley-Request": "1" },
+    });
+    if (!response.ok) {
+      throw new Error(`Failed to delete conversation: ${response.statusText}`);
+    }
+  }
+
+  async getConversationBySlug(slug: string): Promise<Conversation | null> {
+    const response = await fetch(
+      `${this.baseUrl}/conversation-by-slug/${encodeURIComponent(slug)}`,
+    );
+    if (response.status === 404) {
+      return null;
+    }
+    if (!response.ok) {
+      throw new Error(`Failed to get conversation by slug: ${response.statusText}`);
+    }
+    return response.json();
+  }
+
+  // Git diff APIs
+  async getGitDiffs(cwd: string): Promise<{ diffs: GitDiffInfo[]; gitRoot: string }> {
+    const response = await fetch(`${this.baseUrl}/git/diffs?cwd=${encodeURIComponent(cwd)}`);
+    if (!response.ok) {
+      const text = await response.text();
+      throw new Error(text || response.statusText);
+    }
+    return response.json();
+  }
+
+  async getGitDiffFiles(diffId: string, cwd: string): Promise<GitFileInfo[]> {
+    const response = await fetch(
+      `${this.baseUrl}/git/diffs/${diffId}/files?cwd=${encodeURIComponent(cwd)}`,
+    );
+    if (!response.ok) {
+      throw new Error(`Failed to get diff files: ${response.statusText}`);
+    }
+    return response.json();
+  }
+
+  async getGitFileDiff(diffId: string, filePath: string, cwd: string): Promise<GitFileDiff> {
+    const response = await fetch(
+      `${this.baseUrl}/git/file-diff/${diffId}/${filePath}?cwd=${encodeURIComponent(cwd)}`,
+    );
+    if (!response.ok) {
+      throw new Error(`Failed to get file diff: ${response.statusText}`);
+    }
+    return response.json();
+  }
+
+  async renameConversation(conversationId: string, slug: string): Promise<Conversation> {
+    const response = await fetch(`${this.baseUrl}/conversation/${conversationId}/rename`, {
+      method: "POST",
+      headers: this.postHeaders,
+      body: JSON.stringify({ slug }),
+    });
+    if (!response.ok) {
+      throw new Error(`Failed to rename conversation: ${response.statusText}`);
+    }
+    return response.json();
+  }
+}
+
+export const api = new ApiService();

ui/src/styles.css πŸ”—

@@ -0,0 +1,3037 @@
+/* CSS Reset and Base Styles */
+* {
+  box-sizing: border-box;
+}
+
+html {
+  height: 100%;
+  height: 100dvh;
+  overflow: hidden;
+}
+
+body {
+  margin: 0;
+  height: 100%;
+  overflow: hidden;
+  font-family:
+    "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
+  line-height: 1.6;
+  -webkit-font-smoothing: antialiased;
+  letter-spacing: -0.01em;
+}
+
+/* CSS Variables */
+:root {
+  --font-mono:
+    "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
+  --primary: #2563eb;
+  --primary-dark: #1d4ed8;
+  --bg-base: #ffffff;
+  --bg-secondary: #f9fafb;
+  --bg-tertiary: #f3f4f6;
+  --border: #e5e7eb;
+  --text-primary: #111827;
+  --text-secondary: #6b7280;
+  --text-tertiary: #9ca3af;
+  --success-bg: #f0fdf4;
+  --success-border: #bbf7d0;
+  --success-text: #166534;
+  --error-bg: #fef2f2;
+  --error-border: #fecaca;
+  --error-text: #991b1b;
+  --blue-bg: #eff6ff;
+  --blue-border: #bfdbfe;
+  --blue-text: #1e40af;
+  --green-600: #16a34a;
+  --green-700: #15803d;
+  --gray-50: #f9fafb;
+  --gray-100: #f3f4f6;
+  --gray-200: #e5e7eb;
+  --gray-300: #d1d5db;
+  --gray-400: #9ca3af;
+  --gray-500: #6b7280;
+  --gray-600: #4b5563;
+  --gray-700: #374151;
+  --gray-800: #1f2937;
+  --gray-900: #111827;
+}
+
+.dark {
+  --bg-base: #1f2937;
+  --bg-secondary: #111827;
+  --bg-tertiary: #374151;
+  --border: #374151;
+  --text-primary: #f9fafb;
+  --text-secondary: #9ca3af;
+  --text-tertiary: #6b7280;
+  --success-bg: rgba(34, 197, 94, 0.1);
+  --success-border: rgba(34, 197, 94, 0.3);
+  --success-text: #86efac;
+  --error-bg: rgba(239, 68, 68, 0.1);
+  --error-border: rgba(239, 68, 68, 0.3);
+  --error-text: #fca5a5;
+  --blue-bg: rgba(59, 130, 246, 0.1);
+  --blue-border: rgba(59, 130, 246, 0.3);
+  --blue-text: #93c5fd;
+}
+
+/* Layout */
+.app-container {
+  display: flex;
+  height: 100vh; /* Fallback for older browsers */
+  height: 100dvh; /* Dynamic viewport height - adjusts when mobile browser chrome appears/hides */
+  background: var(--bg-secondary);
+  position: relative;
+  overflow: hidden;
+}
+
+.main-content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+}
+
+.screen-height {
+  height: 100vh; /* Fallback for older browsers */
+  height: 100dvh;
+}
+
+.full-height {
+  height: 100%;
+}
+
+/* Flexbox utilities */
+.flex {
+  display: flex;
+}
+
+.flex-col {
+  flex-direction: column;
+}
+
+.flex-1 {
+  flex: 1;
+}
+
+.items-center {
+  align-items: center;
+}
+
+.justify-center {
+  justify-content: center;
+}
+
+.justify-between {
+  justify-content: space-between;
+}
+
+.space-x-2 > * + * {
+  margin-left: 0.5rem;
+}
+
+.space-x-3 > * + * {
+  margin-left: 0.75rem;
+}
+
+.space-y-2 > * + * {
+  margin-top: 0.5rem;
+}
+
+.space-y-3 > * + * {
+  margin-top: 0.75rem;
+}
+
+.space-y-4 > * + * {
+  margin-top: 1rem;
+}
+
+/* Text */
+.text-center {
+  text-align: center;
+}
+
+.text-sm {
+  font-size: 0.875rem;
+  line-height: 1.5;
+}
+
+.text-xs {
+  font-size: 0.75rem;
+  line-height: 1.4;
+}
+
+.text-lg {
+  font-size: 1.125rem;
+  line-height: 1.6;
+}
+
+.font-medium {
+  font-weight: 500;
+}
+
+.font-semibold {
+  font-weight: 600;
+}
+
+.italic {
+  font-style: italic;
+}
+
+.truncate {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.break-words {
+  word-break: break-word;
+}
+
+.break-all {
+  word-break: break-all;
+}
+
+.whitespace-pre-wrap {
+  white-space: pre-wrap;
+}
+
+/* Colors */
+.text-primary {
+  color: var(--text-primary);
+}
+
+.text-secondary {
+  color: var(--text-secondary);
+}
+
+.text-tertiary {
+  color: var(--text-tertiary);
+}
+
+.text-white {
+  color: white;
+}
+
+.text-error {
+  color: var(--error-text);
+}
+
+.text-success {
+  color: var(--success-text);
+}
+
+.text-blue {
+  color: var(--blue-text);
+}
+
+.bg-primary {
+  background: var(--primary);
+}
+
+.bg-base {
+  background: var(--bg-base);
+}
+
+.bg-secondary {
+  background: var(--bg-secondary);
+}
+
+/* Buttons */
+button {
+  font-family: inherit;
+  cursor: pointer;
+  border: none;
+  background: none;
+  padding: 0;
+  color: inherit;
+}
+
+.btn {
+  padding: 0.5rem 0.75rem;
+  border-radius: 0.25rem;
+  transition: background-color 0.2s;
+}
+
+.btn:hover {
+  background: var(--bg-tertiary);
+}
+
+.btn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.btn-primary {
+  padding: 0.5rem 1rem;
+  background: var(--primary);
+  color: white;
+  border-radius: 0.25rem;
+  font-weight: 500;
+  transition: background-color 0.2s;
+  letter-spacing: -0.01em;
+}
+
+.btn-primary:hover:not(:disabled) {
+  background: var(--primary-dark);
+}
+
+.btn-primary:disabled {
+  background: var(--gray-300);
+  cursor: not-allowed;
+}
+
+.dark .btn-primary:disabled {
+  background: var(--gray-600);
+}
+
+.btn-secondary {
+  padding: 0.5rem 1rem;
+  background: var(--bg-secondary);
+  color: var(--text-primary);
+  border: 1px solid var(--border);
+  border-radius: 0.25rem;
+  font-weight: 500;
+  transition: background-color 0.2s;
+  letter-spacing: -0.01em;
+}
+
+.btn-secondary:hover {
+  background: var(--bg-tertiary);
+}
+
+.btn-icon {
+  padding: 0.5rem;
+  border-radius: 0.375rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: background-color 0.2s;
+}
+
+.btn-icon:hover {
+  background: var(--bg-tertiary);
+}
+
+.btn-icon-sm {
+  padding: 0.25rem;
+  border-radius: 0.25rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition:
+    background-color 0.2s,
+    opacity 0.2s;
+  opacity: 0.4;
+  color: var(--text-secondary);
+}
+
+.btn-icon-sm:hover {
+  background: var(--gray-200);
+  opacity: 1;
+}
+
+.dark .btn-icon-sm:hover {
+  background: var(--gray-600);
+}
+
+.conversation-item:hover .btn-icon-sm {
+  opacity: 0.7;
+}
+
+.conversation-item.active .btn-icon-sm {
+  color: rgba(255, 255, 255, 0.8);
+  opacity: 0.7;
+}
+
+.conversation-item.active .btn-icon-sm:hover {
+  background: rgba(255, 255, 255, 0.2);
+  color: white;
+  opacity: 1;
+}
+
+.btn-icon-sm.btn-danger:hover {
+  background: var(--red-600);
+  color: white;
+  opacity: 1;
+}
+
+.btn-new {
+  width: 2rem;
+  height: 2rem;
+  background: var(--green-600);
+  color: white;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: background-color 0.2s;
+}
+
+.btn-new:hover {
+  background: var(--green-700);
+}
+
+/* Header */
+.header {
+  flex: 0 0 auto;
+  background: var(--bg-base);
+  border-bottom: 1px solid var(--border);
+  padding: 0.5rem 1rem;
+  padding-top: calc(0.5rem + env(safe-area-inset-top, 0px));
+  padding-left: max(1rem, env(safe-area-inset-left));
+  padding-right: max(1rem, env(safe-area-inset-right));
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 0.5rem;
+}
+
+.header-left {
+  display: flex;
+  align-items: center;
+  gap: 0.75rem;
+  min-width: 0; /* Allow shrinking below content size */
+  flex: 1 1 auto;
+  overflow: hidden;
+}
+
+.header-title {
+  font-size: 1rem;
+  font-weight: 600;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  letter-spacing: -0.02em;
+  min-width: 0; /* Allow shrinking */
+}
+
+.header-actions {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  flex-shrink: 0; /* Never shrink the actions */
+}
+
+/* Drawer/Sidebar */
+.drawer {
+  position: fixed;
+  inset: 0 auto 0 0;
+  z-index: 50;
+  width: 20rem;
+  max-width: calc(100vw - 3rem); /* Ensure drawer doesn't fill entire screen on mobile */
+  background: var(--bg-base);
+  border-right: 1px solid var(--border);
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  height: 100dvh;
+  transform: translateX(-100%);
+  transition: transform 0.3s ease;
+  padding-top: env(safe-area-inset-top);
+  padding-left: env(safe-area-inset-left);
+  padding-bottom: env(safe-area-inset-bottom);
+}
+
+.drawer.open {
+  transform: translateX(0);
+}
+
+.drawer-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 1rem;
+  border-bottom: 1px solid var(--border);
+}
+
+.drawer-title {
+  font-size: 1.125rem;
+  font-weight: 600;
+}
+
+.drawer-body {
+  flex: 1;
+  overflow-y: auto;
+  min-height: 0;
+}
+
+.drawer-section {
+  padding: 1rem;
+  border-bottom: 1px solid var(--border);
+}
+
+.drawer-footer {
+  padding: 1rem;
+  border-top: 1px solid var(--border);
+}
+
+/* Backdrop */
+.backdrop {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.5);
+  z-index: 40;
+}
+
+/* Conversation List */
+.conversation-list {
+  padding: 0.5rem;
+}
+
+.conversation-item {
+  width: 100%;
+  text-align: left;
+  padding: 0.75rem;
+  border-radius: 0.5rem;
+  margin-bottom: 0.25rem;
+  transition: background-color 0.2s;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 0.25rem;
+}
+
+.conversation-item:hover {
+  background: var(--bg-tertiary);
+}
+
+.conversation-item.active {
+  background: var(--primary);
+  color: white;
+}
+
+.conversation-item .conversation-title {
+  font-weight: 500;
+  font-size: 0.875rem;
+  word-break: break-all;
+}
+
+.conversation-item .conversation-meta {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  font-size: 0.75rem;
+  color: var(--text-tertiary);
+  min-width: 0;
+}
+
+.conversation-item .conversation-date {
+  flex-shrink: 0;
+}
+
+.conversation-item.active .conversation-meta {
+  color: rgba(255, 255, 255, 0.8);
+}
+
+.conversation-item .conversation-cwd {
+  font-family: var(--font-mono);
+  font-size: 0.7rem;
+  opacity: 0.8;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  text-align: left;
+  min-width: 0;
+}
+
+/* Messages */
+.messages-container {
+  flex: 1 1 auto;
+  overflow-y: auto;
+  overflow-x: hidden;
+  -webkit-overflow-scrolling: touch;
+  overscroll-behavior: contain;
+  padding: 1rem;
+  padding-left: max(1rem, env(safe-area-inset-left));
+  padding-right: max(1rem, env(safe-area-inset-right));
+}
+
+.messages-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+
+.message {
+  display: flex;
+  flex-direction: column;
+}
+
+.message-content {
+  padding: 1rem;
+  border-radius: 0.5rem;
+  overflow-wrap: break-word;
+  word-wrap: break-word;
+  min-width: 0;
+}
+
+.message-user .message-content {
+  margin-left: auto;
+  max-width: 80%;
+  color: var(--primary);
+}
+
+.message-agent .message-content,
+.message-tool .message-content {
+  margin-right: auto;
+  max-width: 100%;
+}
+
+.thinking-indicator {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.25rem;
+  color: var(--text-secondary);
+  font-size: 0.875rem;
+  margin: 0.25rem 0 0.75rem 0;
+  padding-left: 0.5rem;
+}
+
+.thinking-dots {
+  display: inline-flex;
+  gap: 0.2rem;
+  font-size: 1.25rem;
+  letter-spacing: 0.15rem;
+  color: var(--blue-text);
+}
+
+.thinking-dots span {
+  animation: thinking-dot 1.2s infinite ease-in-out;
+  opacity: 0.25;
+}
+
+.thinking-dots span:nth-child(2) {
+  animation-delay: 0.2s;
+}
+
+.thinking-dots span:nth-child(3) {
+  animation-delay: 0.4s;
+}
+
+@keyframes thinking-dot {
+  0%,
+  80%,
+  100% {
+    opacity: 0.25;
+    transform: translateY(0);
+  }
+  40% {
+    opacity: 1;
+    transform: translateY(-0.2rem);
+  }
+}
+
+.message-error .message-content {
+  margin-right: auto;
+  max-width: 100%;
+  background: var(--error-bg);
+  border: 2px solid var(--error-border);
+  color: var(--error-text);
+}
+
+/* Tool Display */
+.tool-use {
+  background: var(--blue-bg);
+  border: 1px solid var(--blue-border);
+  border-radius: 0.5rem;
+  padding: 0.75rem;
+  margin: 0.5rem 0;
+}
+
+.tool-header {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  margin-bottom: 0.5rem;
+}
+
+.tool-name {
+  font-weight: 500;
+  color: var(--blue-text);
+}
+
+.tool-input {
+  font-size: 0.875rem;
+  font-family: monospace;
+  background: var(--gray-100);
+  border-radius: 0.25rem;
+  padding: 0.5rem;
+  overflow-wrap: break-word;
+  word-break: break-all;
+  white-space: pre-wrap;
+}
+
+.dark .tool-input {
+  background: var(--gray-800);
+}
+
+.tool-result-details {
+  border: 1px solid var(--border);
+  border-radius: 0.5rem;
+  margin: 0.5rem 0;
+}
+
+.tool-result-details.error {
+  border-color: var(--error-border);
+}
+
+.tool-result-summary {
+  cursor: pointer;
+  padding: 0.5rem 0.75rem;
+  border-radius: 0.5rem;
+  transition: background-color 0.2s;
+  background: var(--bg-tertiary);
+}
+
+.tool-result-summary:hover {
+  background: var(--bg-secondary);
+}
+
+.tool-result-details.error .tool-result-summary {
+  background: var(--error-bg);
+  color: var(--error-text);
+}
+
+.tool-result-content {
+  padding: 0.75rem;
+  padding-top: 0;
+}
+
+.tool-result-section {
+  background: var(--blue-bg);
+  border: 1px solid var(--blue-border);
+  border-radius: 0.25rem;
+  padding: 0.5rem;
+  margin-top: 0.75rem;
+}
+
+.tool-result-section.output {
+  background: var(--success-bg);
+  border-color: var(--success-border);
+}
+
+.tool-result-section.error.output {
+  background: var(--error-bg);
+  border-color: var(--error-border);
+}
+
+.tool-result-label {
+  font-size: 0.75rem;
+  font-weight: 500;
+  margin-bottom: 0.25rem;
+  color: var(--blue-text);
+}
+
+.tool-result-section.output .tool-result-label {
+  color: var(--success-text);
+}
+
+.tool-result-section.error.output .tool-result-label {
+  color: var(--error-text);
+}
+
+.tool-result-data {
+  font-size: 0.875rem;
+  font-family: monospace;
+  overflow-wrap: break-word;
+  word-break: break-all;
+  white-space: pre-wrap;
+}
+
+.tool-result-meta {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.tool-result-status {
+  font-size: 0.75rem;
+}
+
+.tool-result-status.success {
+  color: var(--success-text);
+}
+
+.tool-result-status.error {
+  color: var(--error-text);
+}
+
+.tool-result-time {
+  font-size: 0.75rem;
+  color: var(--text-tertiary);
+}
+
+/* Tool running state */
+.tool-running {
+  background: var(--blue-bg);
+  border: 1px solid var(--blue-border);
+  border-radius: 0.5rem;
+  padding: 0.75rem;
+  margin: 0.5rem 0;
+}
+
+.tool-running-header {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  margin-bottom: 0.5rem;
+}
+
+.tool-status-running {
+  font-size: 0.875rem;
+  color: var(--text-secondary);
+  font-style: italic;
+  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+@keyframes pulse {
+  0%,
+  100% {
+    opacity: 1;
+  }
+  50% {
+    opacity: 0.5;
+  }
+}
+
+/* Tool Component (shared styles for all tools) */
+.tool {
+  background: var(--gray-100);
+  border-radius: 0.5rem;
+  margin: 0.5rem 0;
+  width: 100%;
+}
+
+.dark .tool {
+  background: var(--gray-800);
+}
+
+.tool-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.75rem 1rem;
+  cursor: pointer;
+  user-select: none;
+}
+
+.tool-header:hover {
+  background: rgba(0, 0, 0, 0.02);
+  border-radius: 0.5rem;
+}
+
+.dark .tool-header:hover {
+  background: rgba(255, 255, 255, 0.02);
+}
+
+.tool-summary {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  flex: 1;
+  min-width: 0;
+}
+
+.tool-emoji {
+  font-size: 1rem;
+  flex-shrink: 0;
+}
+
+.tool-emoji.running {
+  animation: rotate 1s linear infinite;
+}
+
+.tool-command {
+  font-family: var(--font-mono);
+  font-size: 0.875rem;
+  color: var(--text-primary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.tool-running {
+  font-size: 0.75rem;
+  color: var(--text-secondary);
+  font-style: italic;
+  flex-shrink: 0;
+  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+.tool-success {
+  color: var(--success-text);
+  font-size: 0.875rem;
+  flex-shrink: 0;
+}
+
+.tool-error {
+  color: var(--error-text);
+  font-size: 0.875rem;
+  flex-shrink: 0;
+}
+
+.tool-toggle {
+  background: none;
+  border: none;
+  cursor: pointer;
+  padding: 0.25rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--text-secondary);
+  flex-shrink: 0;
+}
+
+.tool-toggle:hover {
+  color: var(--text-primary);
+}
+
+.tool-details {
+  padding: 0 1rem 0.75rem 1rem;
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+}
+
+.tool-section {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+
+.tool-label {
+  font-size: 0.75rem;
+  font-weight: 500;
+  color: var(--text-secondary);
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.tool-time {
+  font-size: 0.75rem;
+  color: var(--text-tertiary);
+  font-weight: normal;
+}
+
+.tool-code {
+  font-family: var(--font-mono);
+  font-size: 0.875rem;
+  background: var(--bg-base);
+  border: 1px solid var(--border);
+  border-radius: 0.25rem;
+  padding: 0.75rem;
+  margin: 0;
+  overflow-x: auto;
+  white-space: pre-wrap;
+  word-break: break-word;
+  color: var(--text-primary);
+}
+
+.tool-code.error {
+  background: var(--error-bg);
+  border-color: var(--error-border);
+  color: var(--error-text);
+}
+
+/* Bash Tool Component - uses shared tool styles */
+.bash-tool {
+  /* Alias for backwards compatibility */
+  background: var(--gray-100);
+  border-radius: 0.5rem;
+  margin: 0.5rem 0;
+  width: 100%;
+}
+
+.dark .bash-tool {
+  background: var(--gray-800);
+}
+
+.bash-tool-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.75rem 1rem;
+  cursor: pointer;
+  user-select: none;
+}
+
+.bash-tool-header:hover {
+  background: rgba(0, 0, 0, 0.02);
+  border-radius: 0.5rem;
+}
+
+.dark .bash-tool-header:hover {
+  background: rgba(255, 255, 255, 0.02);
+}
+
+.bash-tool-summary {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  flex: 1;
+  min-width: 0;
+}
+
+.bash-tool-emoji {
+  font-size: 1rem;
+  flex-shrink: 0;
+}
+
+.bash-tool-emoji.running {
+  animation: rotate 1s linear infinite;
+}
+
+.bash-tool-command {
+  font-family: var(--font-mono);
+  font-size: 0.875rem;
+  color: var(--text-primary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.bash-tool-running {
+  font-size: 0.75rem;
+  color: var(--text-secondary);
+  font-style: italic;
+  flex-shrink: 0;
+  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+.bash-tool-success {
+  color: var(--success-text);
+  font-size: 0.875rem;
+  flex-shrink: 0;
+}
+
+.bash-tool-error {
+  color: var(--error-text);
+  font-size: 0.875rem;
+  flex-shrink: 0;
+}
+
+.bash-tool-cancelled {
+  color: var(--error-text);
+  font-size: 0.875rem;
+  flex-shrink: 0;
+}
+
+.bash-tool-toggle {
+  background: none;
+  border: none;
+  cursor: pointer;
+  padding: 0.25rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--text-secondary);
+  flex-shrink: 0;
+}
+
+.bash-tool-toggle:hover {
+  color: var(--text-primary);
+}
+
+.bash-tool-details {
+  padding: 0 1rem 0.75rem 1rem;
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+}
+
+.bash-tool-section {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+
+.bash-tool-label {
+  font-size: 0.75rem;
+  font-weight: 500;
+  color: var(--text-secondary);
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.bash-tool-time {
+  font-size: 0.75rem;
+  color: var(--text-tertiary);
+  font-weight: normal;
+}
+
+.bash-tool-code {
+  font-family: var(--font-mono);
+  font-size: 0.875rem;
+  background: var(--bg-base);
+  border: 1px solid var(--border);
+  border-radius: 0.25rem;
+  padding: 0.75rem;
+  margin: 0;
+  overflow-x: auto;
+  white-space: pre-wrap;
+  word-break: break-word;
+  color: var(--text-primary);
+}
+
+.bash-tool-code.error {
+  background: var(--error-bg);
+  border-color: var(--error-border);
+  color: var(--error-text);
+}
+
+/* Patch Tool */
+.patch-tool {
+  background: var(--gray-100);
+  border-radius: 0.5rem;
+  margin: 0.5rem 0;
+  width: 100%;
+}
+
+.dark .patch-tool {
+  background: var(--gray-800);
+}
+
+.patch-tool-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.75rem 1rem;
+  cursor: pointer;
+  user-select: none;
+}
+
+.patch-tool-header:hover {
+  background: rgba(0, 0, 0, 0.02);
+  border-radius: 0.5rem;
+}
+
+.dark .patch-tool-header:hover {
+  background: rgba(255, 255, 255, 0.02);
+}
+
+.patch-tool-summary {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  flex: 1;
+  min-width: 0;
+}
+
+.patch-tool-emoji {
+  font-size: 1rem;
+  flex-shrink: 0;
+}
+
+.patch-tool-emoji.running {
+  animation: rotate 1s linear infinite;
+}
+
+.patch-tool-filename {
+  font-family: var(--font-mono);
+  font-size: 0.875rem;
+  color: var(--text-primary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-weight: 500;
+}
+
+.patch-tool-running {
+  font-size: 0.75rem;
+  color: var(--text-secondary);
+  font-style: italic;
+  flex-shrink: 0;
+  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+.patch-tool-success {
+  color: var(--success-text);
+  font-size: 0.875rem;
+  flex-shrink: 0;
+}
+
+.patch-tool-error {
+  color: var(--text-secondary);
+  font-size: 0.875rem;
+  flex-shrink: 0;
+}
+
+.patch-tool-toggle {
+  background: none;
+  border: none;
+  cursor: pointer;
+  padding: 0.25rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--text-secondary);
+  flex-shrink: 0;
+}
+
+.patch-tool-toggle:hover {
+  color: var(--text-primary);
+}
+
+.patch-tool-details {
+  padding: 0 1rem 0.75rem 1rem;
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+}
+
+.patch-tool-section {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+
+.patch-tool-label {
+  font-size: 0.75rem;
+  font-weight: 500;
+  color: var(--text-secondary);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.patch-tool-time {
+  font-size: 0.75rem;
+  color: var(--text-tertiary);
+  font-weight: normal;
+}
+
+.patch-tool-diff {
+  font-family: var(--font-mono);
+  font-size: 0.875rem;
+  background: var(--bg-base);
+  border: 1px solid var(--border);
+  border-radius: 0.25rem;
+  padding: 0.75rem;
+  margin: 0;
+  overflow-x: auto;
+  line-height: 1.4;
+}
+
+.patch-diff-line {
+  white-space: pre;
+  display: block;
+}
+
+.patch-diff-addition {
+  background: rgba(34, 197, 94, 0.1);
+  color: #16a34a;
+}
+
+.dark .patch-diff-addition {
+  background: rgba(34, 197, 94, 0.15);
+  color: #86efac;
+}
+
+.patch-diff-deletion {
+  background: rgba(239, 68, 68, 0.1);
+  color: #dc2626;
+}
+
+.dark .patch-diff-deletion {
+  background: rgba(239, 68, 68, 0.15);
+  color: #fca5a5;
+}
+
+.patch-diff-hunk {
+  color: var(--text-secondary);
+  background: var(--bg-tertiary);
+  font-weight: 500;
+}
+
+.patch-diff-header {
+  color: var(--text-tertiary);
+  font-style: italic;
+}
+
+.patch-tool-error-message {
+  font-family: var(--font-mono);
+  font-size: 0.875rem;
+  background: var(--bg-tertiary);
+  border: 1px solid var(--border);
+  border-radius: 0.25rem;
+  padding: 0.75rem;
+  margin: 0;
+  overflow-x: auto;
+  white-space: pre-wrap;
+  word-break: break-word;
+  color: var(--text-secondary);
+}
+
+/* Screenshot Tool */
+.screenshot-tool {
+  background: var(--gray-100);
+  border-radius: 0.5rem;
+  margin: 0.5rem 0;
+  width: 100%;
+}
+
+.dark .screenshot-tool {
+  background: var(--gray-800);
+}
+
+.screenshot-tool-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.75rem 1rem;
+  cursor: pointer;
+  user-select: none;
+}
+
+.screenshot-tool-header:hover {
+  background: rgba(0, 0, 0, 0.02);
+  border-radius: 0.5rem;
+}
+
+.dark .screenshot-tool-header:hover {
+  background: rgba(255, 255, 255, 0.02);
+}
+
+.screenshot-tool-summary {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  flex: 1;
+  min-width: 0;
+}
+
+.screenshot-tool-emoji {
+  font-size: 1rem;
+  flex-shrink: 0;
+}
+
+.screenshot-tool-emoji.running {
+  animation: rotate 1s linear infinite;
+}
+
+.screenshot-tool-filename {
+  font-family: var(--font-mono);
+  font-size: 0.875rem;
+  color: var(--text-primary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-weight: 500;
+}
+
+.screenshot-tool-running {
+  font-size: 0.75rem;
+  color: var(--text-secondary);
+  font-style: italic;
+  flex-shrink: 0;
+  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+.screenshot-tool-success {
+  color: var(--success-text);
+  font-size: 0.875rem;
+  flex-shrink: 0;
+}
+
+.screenshot-tool-error {
+  color: var(--error-text);
+  font-size: 0.875rem;
+  flex-shrink: 0;
+}
+
+.screenshot-tool-toggle {
+  background: none;
+  border: none;
+  cursor: pointer;
+  padding: 0.25rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--text-secondary);
+  flex-shrink: 0;
+}
+
+.screenshot-tool-toggle:hover {
+  color: var(--text-primary);
+}
+
+.screenshot-tool-details {
+  padding: 0 1rem 0.75rem 1rem;
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+}
+
+.screenshot-tool-section {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+
+.screenshot-tool-label {
+  font-size: 0.75rem;
+  font-weight: 500;
+  color: var(--text-secondary);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.screenshot-tool-time {
+  font-size: 0.75rem;
+  color: var(--text-tertiary);
+  font-weight: normal;
+}
+
+.screenshot-tool-image-container {
+  border: 1px solid var(--border);
+  border-radius: 0.25rem;
+  overflow: hidden;
+  background: var(--bg-base);
+}
+
+.screenshot-tool-image-container a {
+  display: block;
+}
+
+.screenshot-tool-image-container img {
+  display: block;
+  border-radius: 0.25rem;
+}
+
+.screenshot-tool-error-message {
+  font-family: var(--font-mono);
+  font-size: 0.875rem;
+  background: var(--error-bg);
+  border: 1px solid var(--error-border);
+  border-radius: 0.25rem;
+  padding: 0.75rem;
+  margin: 0;
+  overflow-x: auto;
+  white-space: pre-wrap;
+  word-break: break-word;
+  color: var(--error-text);
+}
+
+/* Message Input */
+.message-input-container {
+  flex: 0 0 auto;
+  background: var(--bg-base);
+  border-top: 1px solid var(--border);
+  padding: 1rem;
+  padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
+  padding-left: max(1rem, env(safe-area-inset-left));
+  padding-right: max(1rem, env(safe-area-inset-right));
+}
+
+.message-input-form {
+  display: flex;
+  align-items: flex-end;
+  gap: 0.5rem;
+  max-width: 800px;
+  margin: 0 auto;
+}
+
+.message-textarea {
+  flex: 1;
+  min-height: 44px;
+  max-height: 200px;
+  padding: 0.625rem 1rem;
+  border: 1.5px solid var(--border);
+  border-radius: 4px;
+  resize: none;
+  background: var(--bg-base);
+  color: var(--text-primary);
+  font-family: inherit;
+  font-size: 16px; /* Must be 16px+ to prevent iOS zoom on focus */
+  line-height: 1.5;
+  transition:
+    border-color 0.2s,
+    box-shadow 0.2s;
+}
+
+.message-textarea:focus {
+  outline: none;
+  border-color: var(--primary);
+  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
+}
+
+.message-textarea:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.message-send-btn svg {
+  transform: rotate(-90deg);
+}
+
+.message-textarea::placeholder {
+  color: var(--text-tertiary);
+}
+
+.message-send-btn {
+  flex-shrink: 0;
+  width: 36px;
+  height: 36px;
+  margin-bottom: 0.25rem;
+  padding: 0;
+  background: var(--primary);
+  color: white;
+  border: none;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition:
+    background-color 0.2s,
+    transform 0.1s;
+  cursor: pointer;
+}
+
+.message-send-btn:hover:not(:disabled) {
+  background: var(--primary-dark);
+  transform: scale(1.05);
+}
+
+.message-send-btn:active:not(:disabled) {
+  transform: scale(0.95);
+}
+
+.message-send-btn:disabled {
+  background: var(--gray-300);
+  cursor: not-allowed;
+  opacity: 0.6;
+}
+
+.dark .message-send-btn:disabled {
+  background: var(--gray-600);
+}
+
+/* Voice input button */
+.message-voice-btn {
+  flex-shrink: 0;
+  width: 36px;
+  height: 36px;
+  margin-bottom: 0.25rem;
+  padding: 0;
+  background: transparent;
+  color: var(--text-secondary);
+  border: none;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition:
+    background-color 0.2s,
+    color 0.2s,
+    transform 0.1s;
+  cursor: pointer;
+}
+
+.message-voice-btn:hover:not(:disabled):not(.listening) {
+  background: var(--gray-100);
+  color: var(--text-primary);
+}
+
+.dark .message-voice-btn:hover:not(:disabled):not(.listening) {
+  background: var(--gray-700);
+}
+
+.message-voice-btn:active:not(:disabled):not(.listening) {
+  transform: scale(0.95);
+}
+
+.message-voice-btn:disabled {
+  cursor: not-allowed;
+  opacity: 0.4;
+}
+
+.message-voice-btn.listening,
+.message-voice-btn.listening:hover {
+  background: #dc2626;
+  color: white;
+  animation: pulse-voice 1.5s ease-in-out infinite;
+}
+
+.dark .message-voice-btn.listening,
+.dark .message-voice-btn.listening:hover {
+  background: #ef4444;
+  color: white;
+}
+
+@keyframes pulse-voice {
+  0%,
+  100% {
+    transform: scale(1);
+  }
+  50% {
+    transform: scale(1.1);
+  }
+}
+
+/* Drag and drop styles */
+.message-input-container {
+  position: relative;
+}
+
+.message-input-container.drag-over {
+  border-color: var(--primary);
+}
+
+.drag-overlay {
+  position: absolute;
+  inset: 0;
+  background: rgba(37, 99, 235, 0.1);
+  border: 2px dashed var(--primary);
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 10;
+  pointer-events: none;
+}
+
+.drag-overlay-content {
+  background: var(--primary);
+  color: white;
+  padding: 0.5rem 1rem;
+  border-radius: 4px;
+  font-size: 0.875rem;
+  font-weight: 500;
+}
+
+/* Modal */
+.modal-overlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.5);
+  z-index: 50;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 1rem;
+}
+
+.modal {
+  background: var(--bg-base);
+  border-radius: 0.5rem;
+  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
+  max-width: 28rem;
+  width: 100%;
+  max-height: 80vh;
+  overflow: hidden;
+}
+
+.modal-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 1rem;
+  border-bottom: 1px solid var(--border);
+}
+
+.modal-title {
+  font-size: 1.125rem;
+  font-weight: 600;
+}
+
+.modal-body {
+  padding: 1rem;
+}
+
+/* Form Elements */
+label {
+  display: block;
+  font-size: 0.875rem;
+  font-weight: 500;
+  margin-bottom: 0.5rem;
+}
+
+select {
+  width: 100%;
+  background: var(--bg-tertiary);
+  border: 1px solid var(--border);
+  border-radius: 0.375rem;
+  padding: 0.5rem 0.75rem;
+  font-family: inherit;
+  font-size: 1rem;
+  color: var(--text-primary);
+}
+
+select:focus {
+  outline: none;
+  border-color: var(--primary);
+  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
+}
+
+select:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+/* Loading Spinner */
+.spinner {
+  width: 2rem;
+  height: 2rem;
+  border: 2px solid transparent;
+  border-top-color: var(--primary);
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+.spinner-small {
+  width: 1rem;
+  height: 1rem;
+  border-width: 2px;
+  border-top-color: currentColor;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+/* Loading/Error States */
+.loading-container,
+.error-container {
+  height: 100vh; /* Fallback for older browsers */
+  height: 100dvh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.loading-content,
+.error-content {
+  text-align: center;
+}
+
+/* Unified Status Bar */
+.status-bar {
+  flex: 0 0 auto;
+  background: var(--bg-base);
+  border-top: 1px solid var(--border);
+  padding: 0.5rem 1rem;
+  padding-left: max(1rem, env(safe-area-inset-left));
+  padding-right: max(1rem, env(safe-area-inset-right));
+  min-height: 2.5rem;
+  display: flex;
+  align-items: center;
+}
+
+.status-bar-content {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+  gap: 0.75rem;
+}
+
+.status-message {
+  color: var(--text-secondary);
+  font-size: 0.875rem;
+  flex: 1;
+  min-width: 0;
+}
+
+.status-message.status-ready {
+  color: var(--text-tertiary);
+}
+
+.status-message.status-warning {
+  color: var(--blue-text);
+  font-weight: 500;
+}
+
+.status-message.status-error {
+  color: var(--error-text);
+  font-weight: 500;
+}
+
+.animated-working {
+  display: inline;
+}
+
+.animated-working .bold-letter {
+  font-weight: 700;
+}
+
+.status-button {
+  padding: 0.375rem 0.875rem;
+  border-radius: 0.375rem;
+  font-size: 0.875rem;
+  font-weight: 500;
+  transition: all 0.2s;
+  display: flex;
+  align-items: center;
+  gap: 0.375rem;
+  white-space: nowrap;
+  border: none;
+  cursor: pointer;
+}
+
+.status-button svg {
+  width: 0.875rem;
+  height: 0.875rem;
+}
+
+.status-button-primary {
+  background: var(--primary);
+  color: white;
+}
+
+.status-button-primary:hover {
+  background: var(--primary-dark);
+}
+
+.status-button-cancel {
+  background: var(--error-bg);
+  color: var(--error-text);
+  border: 1px solid var(--error-border);
+}
+
+.status-button-cancel:hover:not(:disabled) {
+  background: var(--error-text);
+  color: white;
+}
+
+.status-button-cancel:disabled {
+  opacity: 0.6;
+  cursor: not-allowed;
+}
+
+.status-button-text {
+  background: transparent;
+  color: var(--text-secondary);
+  padding: 0.25rem;
+}
+
+.status-button-text:hover {
+  background: var(--bg-tertiary);
+  color: var(--text-primary);
+}
+
+/* Legacy disconnect/error banner classes (kept for compatibility but unused) */
+.disconnect-banner {
+  background: var(--gray-100);
+  border-top: 1px solid var(--border);
+  padding: 0.5rem 1rem;
+}
+
+.dark .disconnect-banner {
+  background: var(--gray-800);
+}
+
+.disconnect-banner-content {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 0.75rem;
+}
+
+.disconnect-message {
+  color: var(--text-secondary);
+  font-size: 0.875rem;
+  margin: 0;
+}
+
+.btn-reconnect {
+  padding: 0.25rem 0.75rem;
+  background: var(--primary);
+  color: white;
+  border-radius: 0.375rem;
+  font-size: 0.875rem;
+  font-weight: 500;
+  transition: background-color 0.2s;
+}
+
+.btn-reconnect:hover {
+  background: var(--primary-dark);
+}
+
+.error-banner {
+  background: var(--error-bg);
+  border-top: 1px solid var(--error-border);
+  padding: 0.75rem 1rem;
+}
+
+.error-banner-content {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.error-message {
+  color: var(--error-text);
+  font-size: 0.875rem;
+}
+
+/* Empty State */
+.empty-state {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  color: var(--text-secondary);
+}
+
+.empty-state-content {
+  text-align: center;
+}
+
+/* Scrollbar */
+.scrollable {
+  scrollbar-width: thin;
+  scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
+}
+
+.scrollable::-webkit-scrollbar {
+  width: 6px;
+}
+
+.scrollable::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+.scrollable::-webkit-scrollbar-thumb {
+  background-color: rgba(156, 163, 175, 0.5);
+  border-radius: 3px;
+}
+
+.scrollable::-webkit-scrollbar-thumb:hover {
+  background-color: rgba(156, 163, 175, 0.8);
+}
+
+/* SVG Icons */
+svg {
+  width: 1.25rem;
+  height: 1.25rem;
+  display: block;
+}
+
+.icon-sm svg {
+  width: 1rem;
+  height: 1rem;
+}
+
+/* Utility Classes */
+.relative {
+  position: relative;
+}
+
+.overflow-auto {
+  overflow: auto;
+}
+
+.overflow-hidden {
+  overflow: hidden;
+}
+
+.rounded {
+  border-radius: 0.25rem;
+}
+
+.rounded-lg {
+  border-radius: 0.5rem;
+}
+
+.border {
+  border: 1px solid var(--border);
+}
+
+.min-h-0 {
+  min-height: 0;
+}
+
+/* Overflow Menu */
+.overflow-menu {
+  position: absolute;
+  top: calc(100% + 0.5rem);
+  right: 0;
+  background: var(--bg-secondary);
+  border: 1px solid var(--border);
+  border-radius: 0.5rem;
+  box-shadow:
+    0 4px 6px -1px rgba(0, 0, 0, 0.1),
+    0 2px 4px -1px rgba(0, 0, 0, 0.06);
+  min-width: 10rem;
+  z-index: 50;
+}
+
+.overflow-menu-item {
+  display: flex;
+  align-items: center;
+  width: 100%;
+  padding: 0.75rem 1rem;
+  text-align: left;
+  background: transparent;
+  border: none;
+  color: var(--text-primary);
+  font-size: 0.875rem;
+  cursor: pointer;
+  transition: background-color 0.2s;
+}
+
+.overflow-menu-item:hover {
+  background: var(--bg-hover);
+}
+
+.overflow-menu-item:first-child {
+  border-top-left-radius: 0.5rem;
+  border-top-right-radius: 0.5rem;
+}
+
+.overflow-menu-item:last-child {
+  border-bottom-left-radius: 0.5rem;
+  border-bottom-right-radius: 0.5rem;
+}
+
+/* Diff display styling */
+.diff-display {
+  font-family: var(--font-mono);
+  font-size: 0.875rem;
+  line-height: 1.5;
+  background: var(--bg-base);
+  padding: 1rem;
+  border-radius: 0.25rem;
+  overflow-x: auto;
+  white-space: pre;
+  color: var(--text-primary);
+}
+
+/* Responsive adjustments */
+@media (min-width: 768px) {
+  .drawer {
+    position: static;
+    transform: translateX(0) !important;
+  }
+
+  .hide-on-desktop {
+    display: none !important;
+  }
+
+  .backdrop {
+    display: none !important;
+  }
+}
+
+/* Rotation animation for running tools */
+@keyframes rotate {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+/* Scroll to bottom button */
+.scroll-to-bottom-button {
+  position: absolute;
+  bottom: 1rem;
+  left: 50%;
+  transform: translateX(-50%);
+  background: var(--bg-elevated);
+  border: 1px solid var(--border);
+  border-radius: 2rem;
+  padding: 0.5rem 1rem;
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  cursor: pointer;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+  transition: all 0.2s ease;
+  color: var(--text-primary);
+  font-size: 0.875rem;
+  z-index: 10;
+}
+
+.scroll-to-bottom-button:hover {
+  background: var(--bg-hover);
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+.scroll-to-bottom-button:active {
+  transform: translateX(-50%) scale(0.95);
+}
+
+/* Wrapper for messages area to position scroll-to-bottom button */
+.messages-area-wrapper {
+  position: relative;
+  flex: 1 1 auto;
+  min-height: 0; /* Important for flex children to shrink properly */
+  overflow: hidden;
+}
+
+.messages-container {
+  height: 100%;
+}
+
+/* Status bar configuration controls for empty conversations */
+.status-bar-config {
+  display: flex;
+  align-items: center;
+  gap: 0.75rem;
+  width: 100%;
+}
+
+.status-bar-new-conversation {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+  flex-wrap: wrap;
+  gap: 0.5rem;
+}
+
+/* On smaller screens, allow the fields to stack vertically */
+@media (max-width: 600px) {
+  .status-bar-new-conversation {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .status-field {
+    width: 100%;
+  }
+}
+
+.status-bar-controls {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+/* Flexible containers for status fields that adapt to content */
+.status-field {
+  display: flex;
+  align-items: center;
+  gap: 0.25rem;
+  min-width: 0; /* Allow flex items to shrink below content size */
+}
+
+.status-field-label {
+  font-size: 0.7rem;
+  color: var(--text-secondary);
+  flex-shrink: 0;
+}
+
+.status-field-model {
+  flex: 1 1 auto;
+  min-width: 150px;
+  max-width: 300px;
+}
+
+.status-field-cwd {
+  flex: 1 1 auto;
+  min-width: 180px;
+  max-width: 400px;
+}
+
+/* Compact clickable chips for model and cwd */
+.status-chip {
+  padding: 0.25rem 0.5rem;
+  border: 1px solid var(--border);
+  border-radius: 0.25rem;
+  background: var(--bg-tertiary);
+  color: var(--text-primary);
+  font-size: 0.75rem;
+  font-family: var(--font-mono);
+  cursor: pointer;
+  transition: all 0.2s;
+  white-space: normal; /* Allow text to wrap */
+  word-break: break-word; /* Break long words if needed */
+  overflow-wrap: break-word;
+  width: 100%;
+  text-align: left;
+  box-sizing: border-box;
+  line-height: 1.3; /* Better line height for multi-line text */
+  min-height: 1.75rem; /* Minimum height to accommodate wrapped text */
+}
+
+.status-chip:hover:not(:disabled) {
+  background: var(--bg-secondary);
+  border-color: var(--blue-text);
+}
+
+.status-chip:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.status-chip-error {
+  border-color: var(--red-text, #dc2626);
+  background: rgba(220, 38, 38, 0.1);
+}
+
+.status-field-error {
+  position: relative;
+}
+
+.status-input-error {
+  border-color: var(--red-text, #dc2626);
+}
+
+.status-select {
+  padding: 0.25rem 0.5rem;
+  border: 1px solid var(--blue-text);
+  border-radius: 0.25rem;
+  background: var(--bg-base);
+  color: var(--text-primary);
+  font-size: 0.75rem;
+  font-family: var(--font-mono);
+  cursor: pointer;
+  transition: border-color 0.2s;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+.status-select:hover:not(:disabled) {
+  border-color: var(--blue-text);
+}
+
+.status-select:focus {
+  outline: none;
+  border-color: var(--blue-text);
+  box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
+}
+
+.status-select:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.status-input {
+  padding: 0.25rem 0.5rem;
+  border: 1px solid var(--blue-text);
+  border-radius: 0.25rem;
+  background: var(--bg-base);
+  color: var(--text-primary);
+  font-size: 0.75rem;
+  font-family: var(--font-mono);
+  transition: border-color 0.2s;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+.status-input::placeholder {
+  color: var(--text-secondary);
+  opacity: 0.6;
+}
+
+.status-input:hover:not(:disabled) {
+  border-color: var(--blue-text);
+}
+
+.status-input:focus {
+  outline: none;
+  border-color: var(--blue-text);
+  box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
+}
+
+.status-input:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+/* Responsive adjustments for status bar controls */
+@media (max-width: 768px) {
+  .status-bar-config {
+    flex-wrap: wrap;
+    gap: 0.5rem;
+  }
+
+  .status-bar-controls {
+    flex-wrap: wrap;
+    gap: 0.5rem;
+  }
+
+  .status-field-model {
+    min-width: 120px;
+    max-width: 200px;
+  }
+
+  .status-field-cwd {
+    min-width: 140px;
+    max-width: 250px;
+  }
+}
+
+/* Directory Picker Modal */
+.directory-picker-modal {
+  max-width: 32rem;
+  max-height: 70vh;
+  display: flex;
+  flex-direction: column;
+}
+
+.directory-picker-body {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  overflow: hidden;
+}
+
+.directory-picker-input-container {
+  margin-bottom: 0.75rem;
+}
+
+.directory-picker-input {
+  width: 100%;
+  padding: 0.5rem 0.75rem;
+  border: 1px solid var(--border);
+  border-radius: 0.25rem;
+  background: var(--bg-base);
+  color: var(--text-primary);
+  font-family: var(--font-mono);
+  font-size: 0.875rem;
+}
+
+.directory-picker-input:focus {
+  outline: none;
+  border-color: var(--primary);
+  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
+}
+
+.directory-picker-current {
+  font-size: 0.75rem;
+  color: var(--text-secondary);
+  font-family: var(--font-mono);
+  margin-bottom: 0.5rem;
+  padding: 0.25rem 0;
+}
+
+.directory-picker-filter {
+  color: var(--primary);
+  font-weight: 500;
+}
+
+.directory-picker-error {
+  padding: 0.5rem 0.75rem;
+  background: var(--error-bg);
+  border: 1px solid var(--error-border);
+  border-radius: 0.25rem;
+  color: var(--error-text);
+  font-size: 0.875rem;
+  margin-bottom: 0.75rem;
+}
+
+.directory-picker-loading {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  padding: 1rem;
+  color: var(--text-secondary);
+  font-size: 0.875rem;
+}
+
+.directory-picker-list {
+  flex: 1;
+  overflow-y: auto;
+  border: 1px solid var(--border);
+  border-radius: 0.25rem;
+  min-height: 200px;
+  max-height: 300px;
+}
+
+.directory-picker-entry {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  width: 100%;
+  padding: 0.5rem 0.75rem;
+  text-align: left;
+  background: transparent;
+  border: none;
+  border-bottom: 1px solid var(--border);
+  color: var(--text-primary);
+  font-size: 0.875rem;
+  font-family: var(--font-mono);
+  cursor: pointer;
+  transition: background-color 0.15s;
+}
+
+.directory-picker-entry:last-child {
+  border-bottom: none;
+}
+
+.directory-picker-entry:hover {
+  background: var(--bg-tertiary);
+}
+
+.directory-picker-entry-parent {
+  color: var(--text-secondary);
+  font-weight: 500;
+}
+
+.directory-picker-icon {
+  width: 1rem;
+  height: 1rem;
+  flex-shrink: 0;
+  color: var(--text-secondary);
+}
+
+.directory-picker-empty {
+  padding: 2rem;
+  text-align: center;
+  color: var(--text-tertiary);
+  font-size: 0.875rem;
+}
+
+.directory-picker-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 0.5rem;
+  padding: 1rem;
+  border-top: 1px solid var(--border);
+}
+
+/* Context Usage Bar */
+/* Status bar for active conversation */
+.status-bar-active {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+}
+
+.status-working-group {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.status-stop-button {
+  display: flex;
+  align-items: center;
+  gap: 0.25rem;
+  padding: 0.25rem 0.5rem;
+  border: none;
+  border-radius: 0.25rem;
+  background: var(--error-bg);
+  color: var(--error-text);
+  cursor: pointer;
+  transition: all 0.15s ease;
+  font-size: 0.75rem;
+  font-weight: 500;
+}
+
+.status-stop-button:hover:not(:disabled) {
+  background: var(--error-text);
+  color: white;
+}
+
+.status-stop-button:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.status-stop-button svg {
+  width: 0.75rem;
+  height: 0.75rem;
+  flex-shrink: 0;
+}
+
+.status-stop-label {
+  white-space: nowrap;
+}
+
+/* Hide stop label on small screens */
+@media (max-width: 500px) {
+  .status-stop-label {
+    display: none;
+  }
+  .status-stop-button {
+    padding: 0.25rem;
+  }
+}
+
+.context-usage-bar {
+  width: 60px;
+  height: 6px;
+  background: var(--bg-tertiary);
+  border-radius: 3px;
+  overflow: hidden;
+  cursor: pointer;
+}
+
+.context-usage-fill {
+  height: 100%;
+  border-radius: 3px;
+  transition:
+    width 0.3s ease,
+    background-color 0.3s ease;
+}
+
+/* Mobile optimizations for tighter spacing */
+@media (max-width: 767px) {
+  .header {
+    padding: 0.375rem 0.75rem;
+    padding-top: calc(0.375rem + env(safe-area-inset-top, 0px));
+  }
+
+  .status-bar {
+    padding: 0.375rem 0.75rem;
+    min-height: 2rem;
+  }
+
+  .message-input-container {
+    padding: 0.5rem 0.75rem;
+    padding-bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px));
+  }
+}
+
+/* Print styles - looks like the chat window, just without chrome */
+@media print {
+  /* Reset page layout */
+  html,
+  body {
+    height: auto !important;
+    overflow: visible !important;
+    background: white !important;
+  }
+
+  /* Hide interactive chrome but keep the header title */
+  .status-bar,
+  .message-input-container,
+  .drawer,
+  .backdrop,
+  .scroll-to-bottom-button,
+  .overflow-menu,
+  .context-menu,
+  .modal-overlay {
+    display: none !important;
+  }
+
+  /* Hide header buttons but keep the title */
+  .header-actions,
+  .header .btn-icon {
+    display: none !important;
+  }
+
+  /* Simplify header for print */
+  .header {
+    background: white !important;
+    border-bottom: 1px solid #e5e7eb !important;
+    padding: 0.75rem 1rem !important;
+  }
+
+  .header-title {
+    font-size: 1rem !important;
+  }
+
+  /* Reset app container for print */
+  .app-container {
+    display: block !important;
+    height: auto !important;
+    background: white !important;
+  }
+
+  .main-content {
+    display: block !important;
+    height: auto !important;
+  }
+
+  /* Reset messages area to flow naturally */
+  .messages-area-wrapper {
+    height: auto !important;
+    overflow: visible !important;
+    position: static !important;
+    background: white !important;
+  }
+
+  .messages-container {
+    height: auto !important;
+    overflow: visible !important;
+    position: static !important;
+    background: white !important;
+  }
+
+  .messages-list {
+    background: white !important;
+  }
+
+  /* Hide empty state in print */
+  .empty-state {
+    display: none !important;
+  }
+
+  /* Keep message styling, just add page break hints */
+  .message {
+    page-break-inside: avoid;
+  }
+
+  /* Tool results */
+  .tool-result-details {
+    page-break-inside: avoid;
+  }
+
+  /* Code blocks */
+  pre,
+  code {
+    page-break-inside: avoid;
+  }
+
+  /* Screenshots and images - ensure they fit */
+  .screenshot-preview,
+  .screenshot-image,
+  .read-image-preview {
+    max-width: 100% !important;
+    height: auto !important;
+    page-break-inside: avoid;
+  }
+}
+/* Diff Viewer Overlay */
+.diff-viewer-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  z-index: 1000;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 1rem;
+  padding-top: max(1rem, env(safe-area-inset-top));
+  padding-bottom: max(1rem, env(safe-area-inset-bottom));
+}
+
+.diff-viewer-container {
+  background: var(--bg-base);
+  border-radius: 0.5rem;
+  width: calc(100% - 2rem);
+  max-width: none;
+  height: calc(100% - 2rem);
+  max-height: none;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
+}
+
+.diff-viewer-header {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+  padding: 0.5rem 0.75rem;
+  background: var(--bg-secondary);
+  border-bottom: 1px solid var(--border-color);
+}
+
+.diff-viewer-header-row {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  width: 100%;
+}
+
+.diff-viewer-header-left {
+  display: flex;
+  flex-direction: column;
+  gap: 0.125rem;
+}
+
+.diff-viewer-title {
+  margin: 0;
+  font-size: 1rem;
+  font-weight: 600;
+  color: var(--text-primary);
+}
+
+.diff-viewer-mode-toggle {
+  display: flex;
+  border: 1px solid var(--border-color);
+  border-radius: 0.25rem;
+  overflow: hidden;
+}
+
+.diff-viewer-mode-btn {
+  padding: 0.25rem 0.5rem;
+  border: none;
+  background: var(--bg-base);
+  color: var(--text-secondary);
+  cursor: pointer;
+  font-size: 0.875rem;
+}
+
+.diff-viewer-mode-btn:first-child {
+  border-right: 1px solid var(--border-color);
+}
+
+.diff-viewer-mode-btn.active {
+  background: var(--blue-bg);
+  color: var(--blue-text);
+}
+
+.diff-viewer-mode-btn:hover:not(.active) {
+  background: var(--bg-tertiary);
+}
+
+.diff-viewer-nav-buttons {
+  display: flex;
+  gap: 0.125rem;
+}
+
+.diff-viewer-nav-btn {
+  padding: 0.25rem 0.5rem;
+  border: 1px solid var(--border-color);
+  background: var(--bg-base);
+  color: var(--text-primary);
+  cursor: pointer;
+  border-radius: 0.25rem;
+  font-size: 0.875rem;
+}
+
+.diff-viewer-nav-btn:disabled {
+  opacity: 0.4;
+  cursor: not-allowed;
+}
+
+.diff-viewer-nav-btn:hover:not(:disabled) {
+  background: var(--bg-tertiary);
+}
+
+.diff-viewer-expand-btn {
+  display: flex;
+  align-items: center;
+  gap: 0.375rem;
+  padding: 0.25rem 0.5rem;
+  border: 1px solid var(--border-color);
+  background: var(--bg-base);
+  color: var(--text-primary);
+  cursor: pointer;
+  border-radius: 0.25rem;
+  font-size: 0.75rem;
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+}
+
+.diff-viewer-expand-btn:hover {
+  background: var(--bg-tertiary);
+}
+
+.diff-viewer-expand-label {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  flex: 1;
+  text-align: left;
+}
+
+.diff-viewer-selectors {
+  display: flex;
+  gap: 0.5rem;
+  width: 100%;
+  padding-top: 0.25rem;
+}
+
+.diff-viewer-select {
+  flex: 1;
+  min-width: 120px;
+  max-width: 300px;
+  padding: 0.5rem;
+  border: 1px solid var(--border-color);
+  border-radius: 0.25rem;
+  background: var(--bg-base);
+  color: var(--text-primary);
+  font-size: 0.875rem;
+}
+
+.diff-viewer-close {
+  width: 2rem;
+  height: 2rem;
+  border: none;
+  background: transparent;
+  color: var(--text-secondary);
+  font-size: 1.5rem;
+  cursor: pointer;
+  border-radius: 0.25rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.diff-viewer-close:hover {
+  background: var(--bg-tertiary);
+  color: var(--text-primary);
+}
+
+.diff-viewer-error {
+  padding: 0.5rem 1rem;
+  background: var(--error-bg);
+  color: var(--error-text);
+  font-size: 0.875rem;
+}
+
+.diff-viewer-toast {
+  position: fixed;
+  bottom: 24px;
+  right: 24px;
+  padding: 12px 16px;
+  border-radius: 8px;
+  font-size: 14px;
+  font-weight: 500;
+  z-index: 9999;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+  animation: toast-fade-in 0.2s ease;
+  color: #ffffff;
+}
+
+.diff-viewer-toast-saving {
+  background: #1976d2;
+}
+
+.diff-viewer-toast-saved {
+  background: #2e7d32;
+}
+
+.diff-viewer-toast-error {
+  background: #d32f2f;
+}
+
+@keyframes toast-fade-in {
+  from {
+    opacity: 0;
+    transform: translateY(10px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+.diff-viewer-content {
+  flex: 1;
+  overflow: hidden;
+  position: relative;
+}
+
+.diff-viewer-loading,
+.diff-viewer-empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  color: var(--text-secondary);
+  gap: 0.5rem;
+}
+
+.diff-viewer-hint {
+  font-size: 0.875rem;
+  opacity: 0.7;
+}
+
+.diff-viewer-editor {
+  height: 100%;
+  width: 100%;
+}
+
+.diff-viewer-comment-badge {
+  position: absolute;
+  bottom: 1rem;
+  left: 1rem;
+  padding: 0.5rem 1rem;
+  background: var(--blue-bg);
+  color: var(--blue-text);
+  border-radius: 1rem;
+  font-size: 0.875rem;
+  font-weight: 500;
+}
+
+/* Comment dialog */
+.diff-viewer-comment-dialog {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  width: 90%;
+  max-width: 500px;
+  background: var(--bg-base);
+  border: 1px solid var(--border-color);
+  border-radius: 0.5rem;
+  padding: 1rem;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+  z-index: 1001;
+}
+
+.diff-viewer-comment-dialog h4 {
+  margin: 0 0 0.75rem 0;
+  color: var(--text-primary);
+}
+
+.diff-viewer-selected-text {
+  margin: 0 0 0.75rem 0;
+  padding: 0.5rem;
+  background: var(--bg-tertiary);
+  border: 1px solid var(--border-color);
+  border-radius: 0.25rem;
+  font-size: 0.75rem;
+  font-family: monospace;
+  max-height: 150px;
+  overflow: auto;
+  white-space: pre;
+}
+
+.diff-viewer-comment-input {
+  width: 100%;
+  min-height: 100px;
+  padding: 0.5rem;
+  border: 1px solid var(--border-color);
+  border-radius: 0.25rem;
+  background: var(--bg-base);
+  color: var(--text-primary);
+  font-size: 0.875rem;
+  resize: vertical;
+}
+
+.diff-viewer-comment-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 0.5rem;
+  margin-top: 0.75rem;
+}
+
+.diff-viewer-btn {
+  padding: 0.5rem 1rem;
+  border: none;
+  border-radius: 0.25rem;
+  font-size: 0.875rem;
+  cursor: pointer;
+}
+
+.diff-viewer-btn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.diff-viewer-btn-primary {
+  background: var(--blue-bg);
+  color: var(--blue-text);
+}
+
+.diff-viewer-btn-primary:hover:not(:disabled) {
+  filter: brightness(0.95);
+}
+
+.diff-viewer-btn-secondary {
+  background: var(--bg-tertiary);
+  color: var(--text-primary);
+}
+
+.diff-viewer-btn-secondary:hover:not(:disabled) {
+  background: var(--bg-secondary);
+}
+
+/* Monaco comment glyph decoration */
+/* Mobile optimizations for diff viewer */
+@media (max-width: 767px) {
+  .diff-viewer-overlay {
+    padding: 0;
+  }
+
+  .diff-viewer-container {
+    border-radius: 0;
+    max-height: 100%;
+    height: 100%;
+  }
+
+  .diff-viewer-header {
+    padding: 0.375rem 0.5rem;
+    gap: 0.375rem;
+  }
+
+  .diff-viewer-header-row {
+    gap: 0.375rem;
+  }
+
+  .diff-viewer-selectors {
+    flex-direction: column;
+    width: 100%;
+  }
+
+  .diff-viewer-select {
+    max-width: none;
+    width: 100%;
+  }
+
+  .diff-viewer-title {
+    font-size: 0.875rem;
+  }
+
+  .diff-viewer-mode-btn {
+    padding: 0.25rem 0.375rem;
+    font-size: 0.75rem;
+  }
+
+  .diff-viewer-nav-btn {
+    padding: 0.25rem 0.375rem;
+    font-size: 0.75rem;
+  }
+
+  .diff-viewer-expand-btn {
+    font-size: 0.6875rem;
+  }
+
+  .diff-viewer-close {
+    width: 1.75rem;
+    height: 1.75rem;
+    font-size: 1.25rem;
+  }
+
+  /* Monaco editor mobile styles - hide gutters */
+  .diff-viewer-editor .monaco-editor .margin {
+    display: none !important;
+  }
+
+  .diff-viewer-editor .monaco-editor .editor-scrollable {
+    left: 0 !important;
+  }
+}
+
+/* Injected text indicator (for diff comments) */
+.injected-text-indicator {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.5rem 0.75rem;
+  background: var(--blue-bg);
+  border-bottom: 1px solid var(--border-color);
+  gap: 0.5rem;
+}
+
+.injected-text-label {
+  font-size: 0.875rem;
+  color: var(--blue-text);
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.injected-text-insert-btn {
+  padding: 0.375rem 0.75rem;
+  background: var(--blue-text);
+  color: white;
+  border: none;
+  border-radius: 0.25rem;
+  font-size: 0.75rem;
+  font-weight: 500;
+  cursor: pointer;
+  white-space: nowrap;
+  flex-shrink: 0;
+}
+
+.injected-text-insert-btn:hover {
+  filter: brightness(1.1);
+}

ui/src/types.ts πŸ”—

@@ -0,0 +1,125 @@
+// Types for Shelley UI
+import {
+  Conversation as GeneratedConversation,
+  ApiMessageForTS,
+  StreamResponseForTS,
+  Usage as GeneratedUsage,
+  MessageType as GeneratedMessageType,
+} from "./generated-types";
+
+// Re-export generated types
+export type Conversation = GeneratedConversation;
+export type Usage = GeneratedUsage;
+export type MessageType = GeneratedMessageType;
+
+// Extend the generated Message type with parsed data
+export interface Message extends Omit<ApiMessageForTS, "type"> {
+  type: MessageType;
+}
+
+// Go backend LLM struct format (capitalized field names)
+export interface LLMMessage {
+  Role: number; // 0 = user, 1 = assistant
+  Content: LLMContent[];
+  ToolUse?: unknown;
+}
+
+export interface LLMContent {
+  ID: string;
+  Type: number; // 2 = text, 3 = tool_use, 4 = tool_result, 5 = thinking
+  Text?: string;
+  ToolName?: string;
+  ToolInput?: unknown;
+  ToolResult?: LLMContent[];
+  ToolError?: boolean;
+  // Other fields from Go struct
+  MediaType?: string;
+  Thinking?: string;
+  Data?: string;
+  Signature?: string;
+  ToolUseID?: string;
+  ToolUseStartTime?: string | null;
+  ToolUseEndTime?: string | null;
+  Display?: unknown;
+  Cache?: boolean;
+}
+
+// API types
+export interface Model {
+  id: string;
+  ready: boolean;
+  max_context_tokens?: number;
+}
+
+export interface ChatRequest {
+  message: string;
+  model?: string;
+  cwd?: string;
+}
+// StreamResponse represents the streaming response format
+export interface StreamResponse extends Omit<StreamResponseForTS, "messages"> {
+  messages: Message[];
+  context_window_size?: number;
+}
+
+// Link represents a custom link that can be added to the UI
+export interface Link {
+  title: string;
+  icon_svg?: string; // SVG path data for the icon
+  url: string;
+}
+
+// InitData is injected into window by the server
+export interface InitData {
+  models: Model[];
+  default_model: string;
+  default_cwd?: string;
+  home_dir?: string;
+  hostname?: string;
+  terminal_url?: string;
+  links?: Link[];
+}
+
+// Extend Window interface to include our init data
+declare global {
+  interface Window {
+    __SHELLEY_INIT__?: InitData;
+  }
+}
+
+// Git diff types
+export interface GitDiffInfo {
+  id: string;
+  message: string;
+  author: string;
+  timestamp: string;
+  filesCount: number;
+  additions: number;
+  deletions: number;
+}
+
+export interface GitFileInfo {
+  path: string;
+  status: "added" | "modified" | "deleted";
+  additions: number;
+  deletions: number;
+}
+
+export interface GitFileDiff {
+  path: string;
+  oldContent: string;
+  newContent: string;
+}
+
+// Comment for diff viewer
+export interface DiffComment {
+  id: string;
+  line: number;
+  side: "left" | "right";
+  text: string;
+  selectedText?: string;
+  startLine?: number;
+  endLine?: number;
+  filePath: string;
+  diffId: string;
+}

ui/tsconfig.json πŸ”—

@@ -0,0 +1,20 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "allowJs": false,
+    "skipLibCheck": true,
+    "esModuleInterop": false,
+    "allowSyntheticDefaultImports": true,
+    "strict": true,
+    "forceConsistentCasingInFileNames": true,
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "react-jsx"
+  },
+  "include": ["src"],
+  "references": [{ "path": "./tsconfig.node.json" }]
+}

ui/tsconfig.node.json πŸ”—

@@ -0,0 +1,10 @@
+{
+  "compilerOptions": {
+    "composite": true,
+    "skipLibCheck": true,
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowSyntheticDefaultImports": true
+  },
+  "include": ["build.ts"]
+}

version/version.go πŸ”—

@@ -0,0 +1,35 @@
+package version
+
+import (
+	"runtime/debug"
+)
+
+// Info holds build information from runtime/debug.ReadBuildInfo
+type Info struct {
+	Commit     string `json:"commit,omitempty"`
+	CommitTime string `json:"commit_time,omitempty"`
+	Modified   bool   `json:"modified,omitempty"`
+}
+
+// GetInfo returns build information using runtime/debug.ReadBuildInfo
+func GetInfo() Info {
+	var info Info
+
+	buildInfo, ok := debug.ReadBuildInfo()
+	if !ok {
+		return info
+	}
+
+	for _, setting := range buildInfo.Settings {
+		switch setting.Key {
+		case "vcs.revision":
+			info.Commit = setting.Value
+		case "vcs.time":
+			info.CommitTime = setting.Value
+		case "vcs.modified":
+			info.Modified = setting.Value == "true"
+		}
+	}
+
+	return info
+}