diff --git a/docs/superpowers/plans/2026-03-17-wraith-phase1-foundation.md b/docs/superpowers/plans/2026-03-17-wraith-phase1-foundation.md
new file mode 100644
index 0000000..8e98ad2
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-wraith-phase1-foundation.md
@@ -0,0 +1,2690 @@
+# Wraith Desktop — Phase 1: Foundation Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Stand up the Wraith desktop application skeleton — Wails v3 + Vue 3 + SQLite + vault encryption + connection CRUD + dark UI shell — with validated spikes for multi-window and RDP frame transport.
+
+**Architecture:** Go backend exposes services (vault, connections, settings, themes) via Wails v3 bindings to a Vue 3 frontend running in WebView2. SQLite (WAL mode) stores all data in `%APPDATA%\Wraith\wraith.db`. Master password derives a 256-bit AES key via Argon2id.
+
+**Tech Stack:** Go 1.22+, Wails v3 (alpha), Vue 3 (Composition API), Pinia, Tailwind CSS, Naive UI, SQLite (modernc.org/sqlite), Argon2id, AES-256-GCM
+
+**Spec:** `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md`
+
+---
+
+## File Structure
+
+```
+wraith/
+ main.go # Wails app entry point, service registration
+ go.mod # Go module
+ go.sum
+ Taskfile.yml # Wails v3 build tasks
+ README.md # Developer documentation
+ LICENSE # MIT license
+ .gitignore
+ build/
+ config.yml # Wails dev mode config
+ windows/
+ Taskfile.yml # Windows build tasks
+ internal/
+ db/
+ sqlite.go # SQLite open, WAL mode, busy_timeout
+ sqlite_test.go
+ migrations.go # Embedded SQL migrations runner
+ migrations/
+ 001_initial.sql # Full schema
+ vault/
+ service.go # Argon2id key derivation, AES-256-GCM encrypt/decrypt
+ service_test.go
+ connections/
+ service.go # Connection + group CRUD
+ service_test.go
+ search.go # Full-text search + tag filtering
+ search_test.go
+ settings/
+ service.go # Key-value settings CRUD
+ service_test.go
+ theme/
+ service.go # Theme CRUD
+ service_test.go
+ builtins.go # Dracula, Nord, Monokai, etc.
+ session/
+ manager.go # Session manager (window-agnostic)
+ session.go # Session types + interfaces
+ plugin/
+ interfaces.go # ProtocolHandler, Importer interfaces
+ registry.go # Plugin registration
+ frontend/
+ index.html
+ package.json
+ vite.config.ts
+ tsconfig.json
+ tailwind.config.ts
+ postcss.config.js
+ src/
+ main.ts # Vue app bootstrap + Pinia + Naive UI
+ App.vue # Root: unlock gate → main layout
+ router.ts # Vue Router (/, /settings, /vault)
+ layouts/
+ MainLayout.vue # Sidebar + tabs + status bar
+ UnlockLayout.vue # Master password prompt
+ components/
+ sidebar/
+ ConnectionTree.vue # Group tree with connection entries
+ SidebarToggle.vue # Connections ↔ SFTP toggle
+ session/
+ SessionContainer.vue # Holds active sessions (v-show)
+ TabBar.vue # Draggable tab bar
+ common/
+ StatusBar.vue # Bottom status bar
+ stores/
+ app.store.ts # Unlocked state, settings, active theme
+ connection.store.ts # Connections, groups, search
+ session.store.ts # Active sessions, tab order
+ composables/
+ useConnections.ts # Connection CRUD wrappers
+ useVault.ts # Vault lock/unlock wrappers
+ assets/
+ css/
+ main.css # Tailwind imports + dark theme vars
+```
+
+---
+
+## Task 1: Project Scaffold
+
+**Files:**
+- Create: `go.mod`, `main.go`, `Taskfile.yml`, `.gitignore`, `LICENSE`
+- Create: `frontend/package.json`, `frontend/vite.config.ts`, `frontend/tsconfig.json`, `frontend/index.html`
+- Create: `frontend/src/main.ts`, `frontend/src/App.vue`
+
+- [ ] **Step 1: Initialize Go module**
+
+```bash
+cd /Users/vstockwell/repos/wraith
+go mod init github.com/vstockwell/wraith
+```
+
+- [ ] **Step 2: Install Wails v3 CLI (if not already installed)**
+
+```bash
+go install github.com/wailsapp/wails/v3/cmd/wails3@latest
+```
+
+- [ ] **Step 3: Create `.gitignore`**
+
+```gitignore
+# Go
+bin/
+dist/
+*.exe
+
+# Frontend
+frontend/node_modules/
+frontend/dist/
+frontend/bindings/
+
+# Wails
+build/bin/
+
+# IDE
+.vscode/
+.idea/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# App data
+*.db
+*.db-wal
+*.db-shm
+
+# Superpowers
+.superpowers/
+```
+
+- [ ] **Step 4: Create `LICENSE` (MIT)**
+
+```
+MIT License
+
+Copyright (c) 2026 Vantz Stockwell
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+- [ ] **Step 5: Create `main.go` — minimal Wails v3 app**
+
+```go
+package main
+
+import (
+ "embed"
+ "log"
+
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+//go:embed all:frontend/dist
+var assets embed.FS
+
+func main() {
+ app := application.New(application.Options{
+ Name: "Wraith",
+ Description: "SSH + RDP + SFTP Desktop Client",
+ Services: []application.Service{},
+ Assets: application.AssetOptions{
+ Handler: application.BundledAssetFileServer(assets),
+ },
+ })
+
+ app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{
+ Title: "Wraith",
+ Width: 1400,
+ Height: 900,
+ URL: "/",
+ Windows: application.WindowsWindowOptions{
+ BackdropType: application.Mica,
+ },
+ BackgroundColour: application.NewRGBA(13, 17, 23, 255),
+ })
+
+ if err := app.Run(); err != nil {
+ log.Fatal(err)
+ }
+}
+```
+
+- [ ] **Step 6: Create `frontend/package.json`**
+
+```json
+{
+ "name": "wraith-frontend",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vue-tsc --noEmit && vite build",
+ "build:dev": "vue-tsc --noEmit && vite build --minify false --mode development",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@wailsio/runtime": "latest",
+ "vue": "^3.5.0",
+ "vue-router": "^4.4.0",
+ "pinia": "^2.2.0",
+ "naive-ui": "^2.40.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.0.0",
+ "typescript": "^5.5.0",
+ "vite": "^6.0.0",
+ "vue-tsc": "^2.0.0",
+ "tailwindcss": "^4.0.0",
+ "@tailwindcss/vite": "^4.0.0"
+ }
+}
+```
+
+- [ ] **Step 7: Create `frontend/vite.config.ts`**
+
+```ts
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue";
+import tailwindcss from "@tailwindcss/vite";
+
+export default defineConfig({
+ plugins: [vue(), tailwindcss()],
+});
+```
+
+- [ ] **Step 8: Create `frontend/tsconfig.json`**
+
+```json
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "jsx": "preserve",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "noEmit": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
+}
+```
+
+- [ ] **Step 9: Create `frontend/index.html`**
+
+```html
+
+
+
+
+
+ Wraith
+
+
+
+
+
+
+```
+
+- [ ] **Step 10: Create `frontend/src/assets/css/main.css`**
+
+```css
+@import "tailwindcss";
+
+:root {
+ --wraith-bg-primary: #0d1117;
+ --wraith-bg-secondary: #161b22;
+ --wraith-bg-tertiary: #21262d;
+ --wraith-border: #30363d;
+ --wraith-text-primary: #e0e0e0;
+ --wraith-text-secondary: #8b949e;
+ --wraith-text-muted: #484f58;
+ --wraith-accent-blue: #58a6ff;
+ --wraith-accent-green: #3fb950;
+ --wraith-accent-red: #f85149;
+ --wraith-accent-yellow: #e3b341;
+}
+
+body {
+ margin: 0;
+ font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
+ background: var(--wraith-bg-primary);
+ color: var(--wraith-text-primary);
+ overflow: hidden;
+ user-select: none;
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar { width: 8px; }
+::-webkit-scrollbar-track { background: var(--wraith-bg-primary); }
+::-webkit-scrollbar-thumb { background: var(--wraith-border); border-radius: 4px; }
+::-webkit-scrollbar-thumb:hover { background: var(--wraith-text-muted); }
+```
+
+- [ ] **Step 11: Create `frontend/src/main.ts`**
+
+```ts
+import { createApp } from "vue";
+import { createPinia } from "pinia";
+import App from "./App.vue";
+import "./assets/css/main.css";
+
+const app = createApp(App);
+app.use(createPinia());
+app.mount("#app");
+```
+
+- [ ] **Step 12: Create `frontend/src/App.vue` — placeholder**
+
+```vue
+
+
+
+
WRAITH
+
Desktop shell loading...
+
+
+
+```
+
+- [ ] **Step 13: Install frontend dependencies and verify build**
+
+```bash
+cd frontend && npm install && npm run build && cd ..
+```
+
+- [ ] **Step 14: Install Go dependencies and verify compilation**
+
+```bash
+go mod tidy
+go build -o bin/wraith.exe .
+```
+
+- [ ] **Step 15: Commit scaffold**
+
+```bash
+git add .gitignore LICENSE main.go go.mod go.sum Taskfile.yml frontend/
+git commit -m "feat: Wails v3 + Vue 3 project scaffold with Tailwind dark theme"
+```
+
+---
+
+## Task 2: SQLite Database Layer
+
+**Files:**
+- Create: `internal/db/sqlite.go`, `internal/db/sqlite_test.go`
+- Create: `internal/db/migrations.go`, `internal/db/migrations/001_initial.sql`
+
+- [ ] **Step 1: Write the test — SQLite opens with WAL mode**
+
+```go
+// internal/db/sqlite_test.go
+package db
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestOpenCreatesDatabase(t *testing.T) {
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "test.db")
+
+ db, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("Open() error: %v", err)
+ }
+ defer db.Close()
+
+ // Verify file exists
+ if _, err := os.Stat(dbPath); os.IsNotExist(err) {
+ t.Fatal("database file was not created")
+ }
+}
+
+func TestOpenSetsWALMode(t *testing.T) {
+ dir := t.TempDir()
+ db, err := Open(filepath.Join(dir, "test.db"))
+ if err != nil {
+ t.Fatalf("Open() error: %v", err)
+ }
+ defer db.Close()
+
+ var mode string
+ err = db.QueryRow("PRAGMA journal_mode").Scan(&mode)
+ if err != nil {
+ t.Fatalf("PRAGMA query error: %v", err)
+ }
+ if mode != "wal" {
+ t.Errorf("journal_mode = %q, want %q", mode, "wal")
+ }
+}
+
+func TestOpenSetsBusyTimeout(t *testing.T) {
+ dir := t.TempDir()
+ db, err := Open(filepath.Join(dir, "test.db"))
+ if err != nil {
+ t.Fatalf("Open() error: %v", err)
+ }
+ defer db.Close()
+
+ var timeout int
+ err = db.QueryRow("PRAGMA busy_timeout").Scan(&timeout)
+ if err != nil {
+ t.Fatalf("PRAGMA query error: %v", err)
+ }
+ if timeout != 5000 {
+ t.Errorf("busy_timeout = %d, want %d", timeout, 5000)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+go test ./internal/db/ -v
+```
+
+Expected: FAIL — `Open` not defined
+
+- [ ] **Step 3: Implement `internal/db/sqlite.go`**
+
+```go
+package db
+
+import (
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ _ "modernc.org/sqlite"
+)
+
+func Open(dbPath string) (*sql.DB, error) {
+ // Ensure parent directory exists
+ dir := filepath.Dir(dbPath)
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return nil, fmt.Errorf("create db directory: %w", err)
+ }
+
+ db, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ return nil, fmt.Errorf("open database: %w", err)
+ }
+
+ // Enable WAL mode for concurrent read/write
+ if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("set WAL mode: %w", err)
+ }
+
+ // Set busy timeout to prevent "database is locked" errors
+ if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("set busy_timeout: %w", err)
+ }
+
+ // Enable foreign keys
+ if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("enable foreign keys: %w", err)
+ }
+
+ return db, nil
+}
+```
+
+- [ ] **Step 4: Add `modernc.org/sqlite` dependency**
+
+```bash
+go get modernc.org/sqlite
+go mod tidy
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+```bash
+go test ./internal/db/ -v
+```
+
+Expected: 3 PASS
+
+- [ ] **Step 6: Create migration SQL — `internal/db/migrations/001_initial.sql`**
+
+Full schema from spec (all tables: groups, connections, credentials, ssh_keys, themes, connection_history, host_keys, settings).
+
+```sql
+-- 001_initial.sql
+CREATE TABLE IF NOT EXISTS groups (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ parent_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
+ sort_order INTEGER DEFAULT 0,
+ icon TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS ssh_keys (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ key_type TEXT,
+ fingerprint TEXT,
+ public_key TEXT,
+ encrypted_private_key TEXT NOT NULL,
+ passphrase_encrypted TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS credentials (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ username TEXT,
+ domain TEXT,
+ type TEXT NOT NULL CHECK(type IN ('password','ssh_key')),
+ encrypted_value TEXT,
+ ssh_key_id INTEGER REFERENCES ssh_keys(id) ON DELETE SET NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS connections (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ hostname TEXT NOT NULL,
+ port INTEGER NOT NULL DEFAULT 22,
+ protocol TEXT NOT NULL CHECK(protocol IN ('ssh','rdp')),
+ group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
+ credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL,
+ color TEXT,
+ tags TEXT DEFAULT '[]',
+ notes TEXT,
+ options TEXT DEFAULT '{}',
+ sort_order INTEGER DEFAULT 0,
+ last_connected DATETIME,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS themes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ foreground TEXT NOT NULL,
+ background TEXT NOT NULL,
+ cursor TEXT NOT NULL,
+ black TEXT NOT NULL,
+ red TEXT NOT NULL,
+ green TEXT NOT NULL,
+ yellow TEXT NOT NULL,
+ blue TEXT NOT NULL,
+ magenta TEXT NOT NULL,
+ cyan TEXT NOT NULL,
+ white TEXT NOT NULL,
+ bright_black TEXT NOT NULL,
+ bright_red TEXT NOT NULL,
+ bright_green TEXT NOT NULL,
+ bright_yellow TEXT NOT NULL,
+ bright_blue TEXT NOT NULL,
+ bright_magenta TEXT NOT NULL,
+ bright_cyan TEXT NOT NULL,
+ bright_white TEXT NOT NULL,
+ selection_bg TEXT,
+ selection_fg TEXT,
+ is_builtin BOOLEAN DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS connection_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
+ protocol TEXT NOT NULL,
+ connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ disconnected_at DATETIME,
+ duration_secs INTEGER
+);
+
+CREATE TABLE IF NOT EXISTS host_keys (
+ hostname TEXT NOT NULL,
+ port INTEGER NOT NULL,
+ key_type TEXT NOT NULL,
+ fingerprint TEXT NOT NULL,
+ raw_key TEXT,
+ first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (hostname, port, key_type)
+);
+
+CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+);
+```
+
+- [ ] **Step 7: Write migration runner test**
+
+```go
+// Add to internal/db/sqlite_test.go
+func TestMigrateCreatesAllTables(t *testing.T) {
+ dir := t.TempDir()
+ db, err := Open(filepath.Join(dir, "test.db"))
+ if err != nil {
+ t.Fatalf("Open() error: %v", err)
+ }
+ defer db.Close()
+
+ if err := Migrate(db); err != nil {
+ t.Fatalf("Migrate() error: %v", err)
+ }
+
+ expectedTables := []string{
+ "groups", "connections", "credentials", "ssh_keys",
+ "themes", "connection_history", "host_keys", "settings",
+ }
+ for _, table := range expectedTables {
+ var name string
+ err := db.QueryRow(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?", table,
+ ).Scan(&name)
+ if err != nil {
+ t.Errorf("table %q not found: %v", table, err)
+ }
+ }
+}
+```
+
+- [ ] **Step 8: Implement migration runner — `internal/db/migrations.go`**
+
+```go
+package db
+
+import (
+ "database/sql"
+ "embed"
+ "fmt"
+ "sort"
+)
+
+//go:embed migrations/*.sql
+var migrationFiles embed.FS
+
+func Migrate(db *sql.DB) error {
+ entries, err := migrationFiles.ReadDir("migrations")
+ if err != nil {
+ return fmt.Errorf("read migrations: %w", err)
+ }
+
+ sort.Slice(entries, func(i, j int) bool {
+ return entries[i].Name() < entries[j].Name()
+ })
+
+ for _, entry := range entries {
+ content, err := migrationFiles.ReadFile("migrations/" + entry.Name())
+ if err != nil {
+ return fmt.Errorf("read migration %s: %w", entry.Name(), err)
+ }
+ if _, err := db.Exec(string(content)); err != nil {
+ return fmt.Errorf("execute migration %s: %w", entry.Name(), err)
+ }
+ }
+
+ return nil
+}
+```
+
+- [ ] **Step 9: Run all db tests**
+
+```bash
+go test ./internal/db/ -v
+```
+
+Expected: 4 PASS
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add internal/db/
+git commit -m "feat: SQLite database layer with WAL mode and schema migrations"
+```
+
+---
+
+## Task 3: Vault Service — Encryption
+
+**Files:**
+- Create: `internal/vault/service.go`, `internal/vault/service_test.go`
+
+- [ ] **Step 1: Write test — Argon2id key derivation produces consistent keys**
+
+```go
+// internal/vault/service_test.go
+package vault
+
+import (
+ "testing"
+)
+
+func TestDeriveKeyConsistent(t *testing.T) {
+ salt := []byte("test-salt-exactly-32-bytes-long!")
+ key1 := DeriveKey("mypassword", salt)
+ key2 := DeriveKey("mypassword", salt)
+
+ if len(key1) != 32 {
+ t.Errorf("key length = %d, want 32", len(key1))
+ }
+ if string(key1) != string(key2) {
+ t.Error("same password+salt produced different keys")
+ }
+}
+
+func TestDeriveKeyDifferentPasswords(t *testing.T) {
+ salt := []byte("test-salt-exactly-32-bytes-long!")
+ key1 := DeriveKey("password1", salt)
+ key2 := DeriveKey("password2", salt)
+
+ if string(key1) == string(key2) {
+ t.Error("different passwords produced same key")
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+go test ./internal/vault/ -v
+```
+
+Expected: FAIL — `DeriveKey` not defined
+
+- [ ] **Step 3: Implement key derivation**
+
+```go
+// internal/vault/service.go
+package vault
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+
+ "golang.org/x/crypto/argon2"
+)
+
+// Argon2id parameters (OWASP recommended)
+const (
+ argonTime = 3
+ argonMemory = 64 * 1024 // 64MB
+ argonThreads = 4
+ argonKeyLen = 32
+)
+
+// DeriveKey derives a 256-bit key from password and salt using Argon2id.
+func DeriveKey(password string, salt []byte) []byte {
+ return argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+```bash
+go get golang.org/x/crypto
+go test ./internal/vault/ -v
+```
+
+Expected: 2 PASS
+
+- [ ] **Step 5: Write test — Encrypt/Decrypt round-trip**
+
+```go
+// Add to internal/vault/service_test.go
+func TestEncryptDecryptRoundTrip(t *testing.T) {
+ key := DeriveKey("testpassword", []byte("test-salt-32-bytes-long-exactly!"))
+ vs := NewVaultService(key)
+
+ plaintext := "super-secret-ssh-key-data"
+ encrypted, err := vs.Encrypt(plaintext)
+ if err != nil {
+ t.Fatalf("Encrypt() error: %v", err)
+ }
+
+ // Must start with v1: prefix
+ if !strings.HasPrefix(encrypted, "v1:") {
+ t.Errorf("encrypted does not start with v1: prefix: %q", encrypted[:10])
+ }
+
+ decrypted, err := vs.Decrypt(encrypted)
+ if err != nil {
+ t.Fatalf("Decrypt() error: %v", err)
+ }
+
+ if decrypted != plaintext {
+ t.Errorf("Decrypt() = %q, want %q", decrypted, plaintext)
+ }
+}
+
+func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
+ key := DeriveKey("testpassword", []byte("test-salt-32-bytes-long-exactly!"))
+ vs := NewVaultService(key)
+
+ enc1, _ := vs.Encrypt("same-data")
+ enc2, _ := vs.Encrypt("same-data")
+
+ if enc1 == enc2 {
+ t.Error("two encryptions of same data produced identical ciphertext (IV reuse)")
+ }
+}
+
+func TestDecryptWrongKey(t *testing.T) {
+ key1 := DeriveKey("password1", []byte("test-salt-32-bytes-long-exactly!"))
+ key2 := DeriveKey("password2", []byte("test-salt-32-bytes-long-exactly!"))
+
+ vs1 := NewVaultService(key1)
+ vs2 := NewVaultService(key2)
+
+ encrypted, _ := vs1.Encrypt("secret")
+ _, err := vs2.Decrypt(encrypted)
+ if err == nil {
+ t.Error("Decrypt() with wrong key should return error")
+ }
+}
+
+func TestDecryptInvalidFormat(t *testing.T) {
+ key := DeriveKey("test", []byte("test-salt-32-bytes-long-exactly!"))
+ vs := NewVaultService(key)
+
+ _, err := vs.Decrypt("not-valid-format")
+ if err == nil {
+ t.Error("Decrypt() with invalid format should return error")
+ }
+}
+```
+
+- [ ] **Step 6: Implement Encrypt/Decrypt**
+
+```go
+// Add to internal/vault/service.go
+
+// VaultService handles encryption and decryption of sensitive data.
+type VaultService struct {
+ key []byte
+}
+
+// NewVaultService creates a vault with the given AES-256 key.
+func NewVaultService(key []byte) *VaultService {
+ return &VaultService{key: key}
+}
+
+// Encrypt encrypts plaintext using AES-256-GCM.
+// Returns "v1:{iv_hex}:{sealed_hex}" where sealed = ciphertext || authTag.
+func (v *VaultService) Encrypt(plaintext string) (string, error) {
+ block, err := aes.NewCipher(v.key)
+ if err != nil {
+ return "", fmt.Errorf("create cipher: %w", err)
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", fmt.Errorf("create GCM: %w", err)
+ }
+
+ iv := make([]byte, gcm.NonceSize()) // 12 bytes
+ if _, err := rand.Read(iv); err != nil {
+ return "", fmt.Errorf("generate IV: %w", err)
+ }
+
+ sealed := gcm.Seal(nil, iv, []byte(plaintext), nil)
+
+ return fmt.Sprintf("v1:%s:%s", hex.EncodeToString(iv), hex.EncodeToString(sealed)), nil
+}
+
+// Decrypt decrypts a "v1:{iv_hex}:{sealed_hex}" string.
+func (v *VaultService) Decrypt(encrypted string) (string, error) {
+ parts := strings.SplitN(encrypted, ":", 3)
+ if len(parts) != 3 || parts[0] != "v1" {
+ return "", errors.New("invalid encrypted format: expected v1:{iv}:{sealed}")
+ }
+
+ iv, err := hex.DecodeString(parts[1])
+ if err != nil {
+ return "", fmt.Errorf("decode IV: %w", err)
+ }
+
+ sealed, err := hex.DecodeString(parts[2])
+ if err != nil {
+ return "", fmt.Errorf("decode sealed data: %w", err)
+ }
+
+ block, err := aes.NewCipher(v.key)
+ if err != nil {
+ return "", fmt.Errorf("create cipher: %w", err)
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", fmt.Errorf("create GCM: %w", err)
+ }
+
+ plaintext, err := gcm.Open(nil, iv, sealed, nil)
+ if err != nil {
+ return "", fmt.Errorf("decrypt: %w", err)
+ }
+
+ return string(plaintext), nil
+}
+```
+
+- [ ] **Step 7: Run all vault tests**
+
+```bash
+go test ./internal/vault/ -v
+```
+
+Expected: 6 PASS
+
+- [ ] **Step 8: Write test — GenerateSalt produces 32 random bytes**
+
+```go
+// Add to internal/vault/service_test.go
+func TestGenerateSalt(t *testing.T) {
+ salt1, err := GenerateSalt()
+ if err != nil {
+ t.Fatalf("GenerateSalt() error: %v", err)
+ }
+ if len(salt1) != 32 {
+ t.Errorf("salt length = %d, want 32", len(salt1))
+ }
+
+ salt2, _ := GenerateSalt()
+ if string(salt1) == string(salt2) {
+ t.Error("two calls to GenerateSalt produced identical salt")
+ }
+}
+```
+
+- [ ] **Step 9: Implement GenerateSalt**
+
+```go
+// Add to internal/vault/service.go
+
+// GenerateSalt generates a 32-byte random salt for Argon2id.
+func GenerateSalt() ([]byte, error) {
+ salt := make([]byte, 32)
+ if _, err := rand.Read(salt); err != nil {
+ return nil, fmt.Errorf("generate salt: %w", err)
+ }
+ return salt, nil
+}
+```
+
+- [ ] **Step 10: Run all vault tests**
+
+```bash
+go test ./internal/vault/ -v
+```
+
+Expected: 7 PASS
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add internal/vault/
+git commit -m "feat: vault service — Argon2id key derivation + AES-256-GCM encrypt/decrypt"
+```
+
+---
+
+## Task 4: Settings Service
+
+**Files:**
+- Create: `internal/settings/service.go`, `internal/settings/service_test.go`
+
+- [ ] **Step 1: Write tests**
+
+```go
+// internal/settings/service_test.go
+package settings
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/vstockwell/wraith/internal/db"
+)
+
+func setupTestDB(t *testing.T) *SettingsService {
+ t.Helper()
+ d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Migrate(d); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { d.Close() })
+ return NewSettingsService(d)
+}
+
+func TestSetAndGet(t *testing.T) {
+ s := setupTestDB(t)
+
+ if err := s.Set("theme", "dracula"); err != nil {
+ t.Fatalf("Set() error: %v", err)
+ }
+
+ val, err := s.Get("theme")
+ if err != nil {
+ t.Fatalf("Get() error: %v", err)
+ }
+ if val != "dracula" {
+ t.Errorf("Get() = %q, want %q", val, "dracula")
+ }
+}
+
+func TestGetMissing(t *testing.T) {
+ s := setupTestDB(t)
+
+ val, err := s.Get("nonexistent")
+ if err != nil {
+ t.Fatalf("Get() error: %v", err)
+ }
+ if val != "" {
+ t.Errorf("Get() = %q, want empty string", val)
+ }
+}
+
+func TestSetOverwrites(t *testing.T) {
+ s := setupTestDB(t)
+
+ s.Set("key", "value1")
+ s.Set("key", "value2")
+
+ val, _ := s.Get("key")
+ if val != "value2" {
+ t.Errorf("Get() = %q, want %q", val, "value2")
+ }
+}
+
+func TestGetWithDefault(t *testing.T) {
+ s := setupTestDB(t)
+
+ val := s.GetDefault("missing", "fallback")
+ if val != "fallback" {
+ t.Errorf("GetDefault() = %q, want %q", val, "fallback")
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+go test ./internal/settings/ -v
+```
+
+- [ ] **Step 3: Implement settings service**
+
+```go
+// internal/settings/service.go
+package settings
+
+import "database/sql"
+
+type SettingsService struct {
+ db *sql.DB
+}
+
+func NewSettingsService(db *sql.DB) *SettingsService {
+ return &SettingsService{db: db}
+}
+
+func (s *SettingsService) Get(key string) (string, error) {
+ var value string
+ err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
+ if err == sql.ErrNoRows {
+ return "", nil
+ }
+ return value, err
+}
+
+func (s *SettingsService) GetDefault(key, defaultValue string) string {
+ val, err := s.Get(key)
+ if err != nil || val == "" {
+ return defaultValue
+ }
+ return val
+}
+
+func (s *SettingsService) Set(key, value string) error {
+ _, err := s.db.Exec(
+ "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
+ key, value, value,
+ )
+ return err
+}
+
+func (s *SettingsService) Delete(key string) error {
+ _, err := s.db.Exec("DELETE FROM settings WHERE key = ?", key)
+ return err
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+```bash
+go test ./internal/settings/ -v
+```
+
+Expected: 4 PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/settings/
+git commit -m "feat: settings service — key-value store with upsert"
+```
+
+---
+
+## Task 5: Connection + Group CRUD
+
+**Files:**
+- Create: `internal/connections/service.go`, `internal/connections/service_test.go`
+
+- [ ] **Step 1: Write connection model types**
+
+```go
+// internal/connections/service.go
+package connections
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "time"
+)
+
+type Group struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ ParentID *int64 `json:"parentId"`
+ SortOrder int `json:"sortOrder"`
+ Icon string `json:"icon"`
+ CreatedAt time.Time `json:"createdAt"`
+ Children []Group `json:"children,omitempty"`
+}
+
+type Connection struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Hostname string `json:"hostname"`
+ Port int `json:"port"`
+ Protocol string `json:"protocol"`
+ GroupID *int64 `json:"groupId"`
+ CredentialID *int64 `json:"credentialId"`
+ Color string `json:"color"`
+ Tags []string `json:"tags"`
+ Notes string `json:"notes"`
+ Options string `json:"options"`
+ SortOrder int `json:"sortOrder"`
+ LastConnected *time.Time `json:"lastConnected"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type ConnectionService struct {
+ db *sql.DB
+}
+
+func NewConnectionService(db *sql.DB) *ConnectionService {
+ return &ConnectionService{db: db}
+}
+```
+
+- [ ] **Step 2: Write tests for group CRUD**
+
+```go
+// internal/connections/service_test.go
+package connections
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/vstockwell/wraith/internal/db"
+)
+
+func setupTestDB(t *testing.T) *ConnectionService {
+ t.Helper()
+ d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Migrate(d); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { d.Close() })
+ return NewConnectionService(d)
+}
+
+func TestCreateGroup(t *testing.T) {
+ svc := setupTestDB(t)
+
+ g, err := svc.CreateGroup("Vantz's Stuff", nil)
+ if err != nil {
+ t.Fatalf("CreateGroup() error: %v", err)
+ }
+ if g.ID == 0 {
+ t.Error("group ID should not be zero")
+ }
+ if g.Name != "Vantz's Stuff" {
+ t.Errorf("Name = %q, want %q", g.Name, "Vantz's Stuff")
+ }
+}
+
+func TestCreateSubGroup(t *testing.T) {
+ svc := setupTestDB(t)
+
+ parent, _ := svc.CreateGroup("Parent", nil)
+ parentID := parent.ID
+ child, err := svc.CreateGroup("Child", &parentID)
+ if err != nil {
+ t.Fatalf("CreateGroup() error: %v", err)
+ }
+ if child.ParentID == nil || *child.ParentID != parent.ID {
+ t.Error("child ParentID should match parent ID")
+ }
+}
+
+func TestListGroups(t *testing.T) {
+ svc := setupTestDB(t)
+ svc.CreateGroup("Group A", nil)
+ svc.CreateGroup("Group B", nil)
+
+ groups, err := svc.ListGroups()
+ if err != nil {
+ t.Fatalf("ListGroups() error: %v", err)
+ }
+ if len(groups) != 2 {
+ t.Errorf("len(groups) = %d, want 2", len(groups))
+ }
+}
+
+func TestDeleteGroup(t *testing.T) {
+ svc := setupTestDB(t)
+ g, _ := svc.CreateGroup("ToDelete", nil)
+
+ if err := svc.DeleteGroup(g.ID); err != nil {
+ t.Fatalf("DeleteGroup() error: %v", err)
+ }
+
+ groups, _ := svc.ListGroups()
+ if len(groups) != 0 {
+ t.Error("group should have been deleted")
+ }
+}
+```
+
+- [ ] **Step 3: Implement group CRUD methods**
+
+```go
+// Add to internal/connections/service.go
+
+func (s *ConnectionService) CreateGroup(name string, parentID *int64) (*Group, error) {
+ result, err := s.db.Exec(
+ "INSERT INTO groups (name, parent_id) VALUES (?, ?)",
+ name, parentID,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("create group: %w", err)
+ }
+ id, _ := result.LastInsertId()
+ return &Group{ID: id, Name: name, ParentID: parentID}, nil
+}
+
+func (s *ConnectionService) ListGroups() ([]Group, error) {
+ rows, err := s.db.Query("SELECT id, name, parent_id, sort_order, COALESCE(icon,''), created_at FROM groups ORDER BY sort_order, name")
+ if err != nil {
+ return nil, fmt.Errorf("list groups: %w", err)
+ }
+ defer rows.Close()
+
+ var groups []Group
+ for rows.Next() {
+ var g Group
+ if err := rows.Scan(&g.ID, &g.Name, &g.ParentID, &g.SortOrder, &g.Icon, &g.CreatedAt); err != nil {
+ return nil, err
+ }
+ groups = append(groups, g)
+ }
+ return groups, nil
+}
+
+func (s *ConnectionService) DeleteGroup(id int64) error {
+ _, err := s.db.Exec("DELETE FROM groups WHERE id = ?", id)
+ return err
+}
+```
+
+- [ ] **Step 4: Run group tests**
+
+```bash
+go test ./internal/connections/ -v -run "TestCreateGroup|TestCreateSubGroup|TestListGroups|TestDeleteGroup"
+```
+
+Expected: 4 PASS
+
+- [ ] **Step 5: Write tests for connection CRUD**
+
+```go
+// Add to internal/connections/service_test.go
+
+func TestCreateConnection(t *testing.T) {
+ svc := setupTestDB(t)
+
+ conn, err := svc.CreateConnection(CreateConnectionInput{
+ Name: "Asgard",
+ Hostname: "192.168.1.4",
+ Port: 22,
+ Protocol: "ssh",
+ Tags: []string{"Prod", "Linux"},
+ })
+ if err != nil {
+ t.Fatalf("CreateConnection() error: %v", err)
+ }
+ if conn.Name != "Asgard" {
+ t.Errorf("Name = %q, want %q", conn.Name, "Asgard")
+ }
+ if len(conn.Tags) != 2 {
+ t.Errorf("len(Tags) = %d, want 2", len(conn.Tags))
+ }
+}
+
+func TestListConnections(t *testing.T) {
+ svc := setupTestDB(t)
+ svc.CreateConnection(CreateConnectionInput{Name: "Host1", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh"})
+ svc.CreateConnection(CreateConnectionInput{Name: "Host2", Hostname: "10.0.0.2", Port: 3389, Protocol: "rdp"})
+
+ conns, err := svc.ListConnections()
+ if err != nil {
+ t.Fatalf("ListConnections() error: %v", err)
+ }
+ if len(conns) != 2 {
+ t.Errorf("len(conns) = %d, want 2", len(conns))
+ }
+}
+
+func TestUpdateConnection(t *testing.T) {
+ svc := setupTestDB(t)
+ conn, _ := svc.CreateConnection(CreateConnectionInput{Name: "Old", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh"})
+
+ updated, err := svc.UpdateConnection(conn.ID, UpdateConnectionInput{Name: strPtr("New")})
+ if err != nil {
+ t.Fatalf("UpdateConnection() error: %v", err)
+ }
+ if updated.Name != "New" {
+ t.Errorf("Name = %q, want %q", updated.Name, "New")
+ }
+}
+
+func TestDeleteConnection(t *testing.T) {
+ svc := setupTestDB(t)
+ conn, _ := svc.CreateConnection(CreateConnectionInput{Name: "Del", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh"})
+
+ if err := svc.DeleteConnection(conn.ID); err != nil {
+ t.Fatalf("DeleteConnection() error: %v", err)
+ }
+ conns, _ := svc.ListConnections()
+ if len(conns) != 0 {
+ t.Error("connection should have been deleted")
+ }
+}
+
+func strPtr(s string) *string { return &s }
+```
+
+- [ ] **Step 6: Implement connection CRUD**
+
+```go
+// Add to internal/connections/service.go
+
+type CreateConnectionInput struct {
+ Name string `json:"name"`
+ Hostname string `json:"hostname"`
+ Port int `json:"port"`
+ Protocol string `json:"protocol"`
+ GroupID *int64 `json:"groupId"`
+ CredentialID *int64 `json:"credentialId"`
+ Color string `json:"color"`
+ Tags []string `json:"tags"`
+ Notes string `json:"notes"`
+ Options string `json:"options"`
+}
+
+type UpdateConnectionInput struct {
+ Name *string `json:"name"`
+ Hostname *string `json:"hostname"`
+ Port *int `json:"port"`
+ GroupID *int64 `json:"groupId"`
+ CredentialID *int64 `json:"credentialId"`
+ Color *string `json:"color"`
+ Tags []string `json:"tags"`
+ Notes *string `json:"notes"`
+ Options *string `json:"options"`
+}
+
+func (s *ConnectionService) CreateConnection(input CreateConnectionInput) (*Connection, error) {
+ tags, _ := json.Marshal(input.Tags)
+ if input.Tags == nil {
+ tags = []byte("[]")
+ }
+ options := input.Options
+ if options == "" {
+ options = "{}"
+ }
+
+ result, err := s.db.Exec(
+ `INSERT INTO connections (name, hostname, port, protocol, group_id, credential_id, color, tags, notes, options)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ input.Name, input.Hostname, input.Port, input.Protocol,
+ input.GroupID, input.CredentialID, input.Color, string(tags), input.Notes, options,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("create connection: %w", err)
+ }
+
+ id, _ := result.LastInsertId()
+ return s.GetConnection(id)
+}
+
+func (s *ConnectionService) GetConnection(id int64) (*Connection, error) {
+ var conn Connection
+ var tagsStr string
+ err := s.db.QueryRow(
+ `SELECT id, name, hostname, port, protocol, group_id, credential_id,
+ COALESCE(color,''), tags, COALESCE(notes,''), COALESCE(options,'{}'),
+ sort_order, last_connected, created_at, updated_at
+ FROM connections WHERE id = ?`, id,
+ ).Scan(&conn.ID, &conn.Name, &conn.Hostname, &conn.Port, &conn.Protocol,
+ &conn.GroupID, &conn.CredentialID, &conn.Color, &tagsStr, &conn.Notes,
+ &conn.Options, &conn.SortOrder, &conn.LastConnected, &conn.CreatedAt, &conn.UpdatedAt)
+ if err != nil {
+ return nil, fmt.Errorf("get connection: %w", err)
+ }
+ json.Unmarshal([]byte(tagsStr), &conn.Tags)
+ return &conn, nil
+}
+
+func (s *ConnectionService) ListConnections() ([]Connection, error) {
+ rows, err := s.db.Query(
+ `SELECT id, name, hostname, port, protocol, group_id, credential_id,
+ COALESCE(color,''), tags, COALESCE(notes,''), COALESCE(options,'{}'),
+ sort_order, last_connected, created_at, updated_at
+ FROM connections ORDER BY sort_order, name`)
+ if err != nil {
+ return nil, fmt.Errorf("list connections: %w", err)
+ }
+ defer rows.Close()
+
+ var conns []Connection
+ for rows.Next() {
+ var c Connection
+ var tagsStr string
+ if err := rows.Scan(&c.ID, &c.Name, &c.Hostname, &c.Port, &c.Protocol,
+ &c.GroupID, &c.CredentialID, &c.Color, &tagsStr, &c.Notes, &c.Options,
+ &c.SortOrder, &c.LastConnected, &c.CreatedAt, &c.UpdatedAt); err != nil {
+ return nil, err
+ }
+ json.Unmarshal([]byte(tagsStr), &c.Tags)
+ conns = append(conns, c)
+ }
+ return conns, nil
+}
+
+func (s *ConnectionService) UpdateConnection(id int64, input UpdateConnectionInput) (*Connection, error) {
+ setClauses := []string{"updated_at = CURRENT_TIMESTAMP"}
+ args := []interface{}{}
+
+ if input.Name != nil {
+ setClauses = append(setClauses, "name = ?")
+ args = append(args, *input.Name)
+ }
+ if input.Hostname != nil {
+ setClauses = append(setClauses, "hostname = ?")
+ args = append(args, *input.Hostname)
+ }
+ if input.Port != nil {
+ setClauses = append(setClauses, "port = ?")
+ args = append(args, *input.Port)
+ }
+ if input.Tags != nil {
+ tags, _ := json.Marshal(input.Tags)
+ setClauses = append(setClauses, "tags = ?")
+ args = append(args, string(tags))
+ }
+ if input.Notes != nil {
+ setClauses = append(setClauses, "notes = ?")
+ args = append(args, *input.Notes)
+ }
+ if input.Color != nil {
+ setClauses = append(setClauses, "color = ?")
+ args = append(args, *input.Color)
+ }
+ if input.Options != nil {
+ setClauses = append(setClauses, "options = ?")
+ args = append(args, *input.Options)
+ }
+
+ args = append(args, id)
+ query := fmt.Sprintf("UPDATE connections SET %s WHERE id = ?", strings.Join(setClauses, ", "))
+ if _, err := s.db.Exec(query, args...); err != nil {
+ return nil, fmt.Errorf("update connection: %w", err)
+ }
+ return s.GetConnection(id)
+}
+
+func (s *ConnectionService) DeleteConnection(id int64) error {
+ _, err := s.db.Exec("DELETE FROM connections WHERE id = ?", id)
+ return err
+}
+```
+
+- [ ] **Step 7: Run all connection tests**
+
+```bash
+go test ./internal/connections/ -v
+```
+
+Expected: 8 PASS (4 group + 4 connection)
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add internal/connections/
+git commit -m "feat: connection + group CRUD with JSON tags and options"
+```
+
+---
+
+## Task 6: Connection Search + Tag Filtering
+
+**Files:**
+- Create: `internal/connections/search.go`, `internal/connections/search_test.go`
+
+- [ ] **Step 1: Write search tests**
+
+```go
+// internal/connections/search_test.go
+package connections
+
+import "testing"
+
+func TestSearchByName(t *testing.T) {
+ svc := setupTestDB(t)
+ svc.CreateConnection(CreateConnectionInput{Name: "Asgard", Hostname: "192.168.1.4", Port: 22, Protocol: "ssh"})
+ svc.CreateConnection(CreateConnectionInput{Name: "Docker", Hostname: "155.254.29.221", Port: 22, Protocol: "ssh"})
+
+ results, err := svc.Search("asg")
+ if err != nil {
+ t.Fatalf("Search() error: %v", err)
+ }
+ if len(results) != 1 {
+ t.Fatalf("len(results) = %d, want 1", len(results))
+ }
+ if results[0].Name != "Asgard" {
+ t.Errorf("Name = %q, want %q", results[0].Name, "Asgard")
+ }
+}
+
+func TestSearchByHostname(t *testing.T) {
+ svc := setupTestDB(t)
+ svc.CreateConnection(CreateConnectionInput{Name: "Asgard", Hostname: "192.168.1.4", Port: 22, Protocol: "ssh"})
+
+ results, _ := svc.Search("192.168")
+ if len(results) != 1 {
+ t.Errorf("len(results) = %d, want 1", len(results))
+ }
+}
+
+func TestSearchByTag(t *testing.T) {
+ svc := setupTestDB(t)
+ svc.CreateConnection(CreateConnectionInput{Name: "ProdServer", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh", Tags: []string{"Prod", "Linux"}})
+ svc.CreateConnection(CreateConnectionInput{Name: "DevServer", Hostname: "10.0.0.2", Port: 22, Protocol: "ssh", Tags: []string{"Dev", "Linux"}})
+
+ results, _ := svc.Search("Prod")
+ if len(results) != 1 {
+ t.Errorf("len(results) = %d, want 1", len(results))
+ }
+}
+
+func TestFilterByTag(t *testing.T) {
+ svc := setupTestDB(t)
+ svc.CreateConnection(CreateConnectionInput{Name: "A", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh", Tags: []string{"Prod"}})
+ svc.CreateConnection(CreateConnectionInput{Name: "B", Hostname: "10.0.0.2", Port: 22, Protocol: "ssh", Tags: []string{"Dev"}})
+ svc.CreateConnection(CreateConnectionInput{Name: "C", Hostname: "10.0.0.3", Port: 22, Protocol: "ssh", Tags: []string{"Prod", "Linux"}})
+
+ results, _ := svc.FilterByTag("Prod")
+ if len(results) != 2 {
+ t.Errorf("len(results) = %d, want 2", len(results))
+ }
+}
+```
+
+- [ ] **Step 2: Implement search**
+
+```go
+// internal/connections/search.go
+package connections
+
+import "fmt"
+
+func (s *ConnectionService) Search(query string) ([]Connection, error) {
+ like := "%" + query + "%"
+ rows, err := s.db.Query(
+ `SELECT id, name, hostname, port, protocol, group_id, credential_id,
+ COALESCE(color,''), tags, COALESCE(notes,''), COALESCE(options,'{}'),
+ sort_order, last_connected, created_at, updated_at
+ FROM connections
+ WHERE name LIKE ? COLLATE NOCASE
+ OR hostname LIKE ? COLLATE NOCASE
+ OR tags LIKE ? COLLATE NOCASE
+ OR notes LIKE ? COLLATE NOCASE
+ ORDER BY last_connected DESC NULLS LAST, name`,
+ like, like, like, like,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("search connections: %w", err)
+ }
+ defer rows.Close()
+ return scanConnections(rows)
+}
+
+func (s *ConnectionService) FilterByTag(tag string) ([]Connection, error) {
+ rows, err := s.db.Query(
+ `SELECT c.id, c.name, c.hostname, c.port, c.protocol, c.group_id, c.credential_id,
+ COALESCE(c.color,''), c.tags, COALESCE(c.notes,''), COALESCE(c.options,'{}'),
+ c.sort_order, c.last_connected, c.created_at, c.updated_at
+ FROM connections c, json_each(c.tags) AS t
+ WHERE t.value = ?
+ ORDER BY c.name`, tag,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("filter by tag: %w", err)
+ }
+ defer rows.Close()
+ return scanConnections(rows)
+}
+```
+
+- [ ] **Step 3: Extract `scanConnections` helper to `service.go`**
+
+```go
+// Add to internal/connections/service.go
+import "database/sql"
+
+func scanConnections(rows *sql.Rows) ([]Connection, error) {
+ var conns []Connection
+ for rows.Next() {
+ var c Connection
+ var tagsStr string
+ if err := rows.Scan(&c.ID, &c.Name, &c.Hostname, &c.Port, &c.Protocol,
+ &c.GroupID, &c.CredentialID, &c.Color, &tagsStr, &c.Notes, &c.Options,
+ &c.SortOrder, &c.LastConnected, &c.CreatedAt, &c.UpdatedAt); err != nil {
+ return nil, err
+ }
+ json.Unmarshal([]byte(tagsStr), &c.Tags)
+ conns = append(conns, c)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("scan connections: %w", err)
+ }
+ return conns, nil
+}
+```
+
+Refactor `ListConnections` to use `scanConnections`.
+
+- [ ] **Step 4: Run search tests**
+
+```bash
+go test ./internal/connections/ -v
+```
+
+Expected: 12 PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/connections/
+git commit -m "feat: connection search by name/hostname/tag with json_each filtering"
+```
+
+---
+
+## Task 7: Theme Service + Built-in Themes
+
+**Files:**
+- Create: `internal/theme/service.go`, `internal/theme/service_test.go`, `internal/theme/builtins.go`
+
+- [ ] **Step 1: Define theme type and built-in themes**
+
+```go
+// internal/theme/builtins.go
+package theme
+
+type Theme struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Foreground string `json:"foreground"`
+ Background string `json:"background"`
+ Cursor string `json:"cursor"`
+ Black string `json:"black"`
+ Red string `json:"red"`
+ Green string `json:"green"`
+ Yellow string `json:"yellow"`
+ Blue string `json:"blue"`
+ Magenta string `json:"magenta"`
+ Cyan string `json:"cyan"`
+ White string `json:"white"`
+ BrightBlack string `json:"brightBlack"`
+ BrightRed string `json:"brightRed"`
+ BrightGreen string `json:"brightGreen"`
+ BrightYellow string `json:"brightYellow"`
+ BrightBlue string `json:"brightBlue"`
+ BrightMagenta string `json:"brightMagenta"`
+ BrightCyan string `json:"brightCyan"`
+ BrightWhite string `json:"brightWhite"`
+ SelectionBg string `json:"selectionBg,omitempty"`
+ SelectionFg string `json:"selectionFg,omitempty"`
+ IsBuiltin bool `json:"isBuiltin"`
+}
+
+var BuiltinThemes = []Theme{
+ {
+ Name: "Dracula", IsBuiltin: true,
+ Foreground: "#f8f8f2", Background: "#282a36", Cursor: "#f8f8f2",
+ Black: "#21222c", Red: "#ff5555", Green: "#50fa7b", Yellow: "#f1fa8c",
+ Blue: "#bd93f9", Magenta: "#ff79c6", Cyan: "#8be9fd", White: "#f8f8f2",
+ BrightBlack: "#6272a4", BrightRed: "#ff6e6e", BrightGreen: "#69ff94",
+ BrightYellow: "#ffffa5", BrightBlue: "#d6acff", BrightMagenta: "#ff92df",
+ BrightCyan: "#a4ffff", BrightWhite: "#ffffff",
+ },
+ {
+ Name: "Nord", IsBuiltin: true,
+ Foreground: "#d8dee9", Background: "#2e3440", Cursor: "#d8dee9",
+ Black: "#3b4252", Red: "#bf616a", Green: "#a3be8c", Yellow: "#ebcb8b",
+ Blue: "#81a1c1", Magenta: "#b48ead", Cyan: "#88c0d0", White: "#e5e9f0",
+ BrightBlack: "#4c566a", BrightRed: "#bf616a", BrightGreen: "#a3be8c",
+ BrightYellow: "#ebcb8b", BrightBlue: "#81a1c1", BrightMagenta: "#b48ead",
+ BrightCyan: "#8fbcbb", BrightWhite: "#eceff4",
+ },
+ {
+ Name: "Monokai", IsBuiltin: true,
+ Foreground: "#f8f8f2", Background: "#272822", Cursor: "#f8f8f0",
+ Black: "#272822", Red: "#f92672", Green: "#a6e22e", Yellow: "#f4bf75",
+ Blue: "#66d9ef", Magenta: "#ae81ff", Cyan: "#a1efe4", White: "#f8f8f2",
+ BrightBlack: "#75715e", BrightRed: "#f92672", BrightGreen: "#a6e22e",
+ BrightYellow: "#f4bf75", BrightBlue: "#66d9ef", BrightMagenta: "#ae81ff",
+ BrightCyan: "#a1efe4", BrightWhite: "#f9f8f5",
+ },
+ {
+ Name: "One Dark", IsBuiltin: true,
+ Foreground: "#abb2bf", Background: "#282c34", Cursor: "#528bff",
+ Black: "#282c34", Red: "#e06c75", Green: "#98c379", Yellow: "#e5c07b",
+ Blue: "#61afef", Magenta: "#c678dd", Cyan: "#56b6c2", White: "#abb2bf",
+ BrightBlack: "#545862", BrightRed: "#e06c75", BrightGreen: "#98c379",
+ BrightYellow: "#e5c07b", BrightBlue: "#61afef", BrightMagenta: "#c678dd",
+ BrightCyan: "#56b6c2", BrightWhite: "#c8ccd4",
+ },
+ {
+ Name: "Solarized Dark", IsBuiltin: true,
+ Foreground: "#839496", Background: "#002b36", Cursor: "#839496",
+ Black: "#073642", Red: "#dc322f", Green: "#859900", Yellow: "#b58900",
+ Blue: "#268bd2", Magenta: "#d33682", Cyan: "#2aa198", White: "#eee8d5",
+ BrightBlack: "#002b36", BrightRed: "#cb4b16", BrightGreen: "#586e75",
+ BrightYellow: "#657b83", BrightBlue: "#839496", BrightMagenta: "#6c71c4",
+ BrightCyan: "#93a1a1", BrightWhite: "#fdf6e3",
+ },
+ {
+ Name: "Gruvbox Dark", IsBuiltin: true,
+ Foreground: "#ebdbb2", Background: "#282828", Cursor: "#ebdbb2",
+ Black: "#282828", Red: "#cc241d", Green: "#98971a", Yellow: "#d79921",
+ Blue: "#458588", Magenta: "#b16286", Cyan: "#689d6a", White: "#a89984",
+ BrightBlack: "#928374", BrightRed: "#fb4934", BrightGreen: "#b8bb26",
+ BrightYellow: "#fabd2f", BrightBlue: "#83a598", BrightMagenta: "#d3869b",
+ BrightCyan: "#8ec07c", BrightWhite: "#ebdbb2",
+ },
+ {
+ Name: "MobaXTerm Classic", IsBuiltin: true,
+ Foreground: "#ececec", Background: "#242424", Cursor: "#b4b4c0",
+ Black: "#000000", Red: "#aa4244", Green: "#7e8d53", Yellow: "#e4b46d",
+ Blue: "#6e9aba", Magenta: "#9e5085", Cyan: "#80d5cf", White: "#cccccc",
+ BrightBlack: "#808080", BrightRed: "#cc7b7d", BrightGreen: "#a5b17c",
+ BrightYellow: "#ecc995", BrightBlue: "#96b6cd", BrightMagenta: "#c083ac",
+ BrightCyan: "#a9e2de", BrightWhite: "#cccccc",
+ },
+}
+```
+
+- [ ] **Step 2: Write test — seed built-in themes and list them**
+
+```go
+// internal/theme/service_test.go
+package theme
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/vstockwell/wraith/internal/db"
+)
+
+func setupTestDB(t *testing.T) *ThemeService {
+ t.Helper()
+ d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Migrate(d); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { d.Close() })
+ return NewThemeService(d)
+}
+
+func TestSeedBuiltins(t *testing.T) {
+ svc := setupTestDB(t)
+ if err := svc.SeedBuiltins(); err != nil {
+ t.Fatalf("SeedBuiltins() error: %v", err)
+ }
+
+ themes, err := svc.List()
+ if err != nil {
+ t.Fatalf("List() error: %v", err)
+ }
+ if len(themes) != len(BuiltinThemes) {
+ t.Errorf("len(themes) = %d, want %d", len(themes), len(BuiltinThemes))
+ }
+}
+
+func TestSeedBuiltinsIdempotent(t *testing.T) {
+ svc := setupTestDB(t)
+ svc.SeedBuiltins()
+ svc.SeedBuiltins() // Second call should not duplicate
+
+ themes, _ := svc.List()
+ if len(themes) != len(BuiltinThemes) {
+ t.Errorf("len(themes) = %d after double seed, want %d", len(themes), len(BuiltinThemes))
+ }
+}
+
+func TestGetByName(t *testing.T) {
+ svc := setupTestDB(t)
+ svc.SeedBuiltins()
+
+ theme, err := svc.GetByName("Dracula")
+ if err != nil {
+ t.Fatalf("GetByName() error: %v", err)
+ }
+ if theme.Background != "#282a36" {
+ t.Errorf("Background = %q, want %q", theme.Background, "#282a36")
+ }
+}
+```
+
+- [ ] **Step 3: Implement theme service**
+
+```go
+// internal/theme/service.go
+package theme
+
+import (
+ "database/sql"
+ "fmt"
+)
+
+type ThemeService struct {
+ db *sql.DB
+}
+
+func NewThemeService(db *sql.DB) *ThemeService {
+ return &ThemeService{db: db}
+}
+
+func (s *ThemeService) SeedBuiltins() error {
+ for _, t := range BuiltinThemes {
+ _, err := s.db.Exec(
+ `INSERT OR IGNORE INTO themes (name, foreground, background, cursor,
+ black, red, green, yellow, blue, magenta, cyan, white,
+ bright_black, bright_red, bright_green, bright_yellow, bright_blue,
+ bright_magenta, bright_cyan, bright_white, selection_bg, selection_fg, is_builtin)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)`,
+ t.Name, t.Foreground, t.Background, t.Cursor,
+ t.Black, t.Red, t.Green, t.Yellow, t.Blue, t.Magenta, t.Cyan, t.White,
+ t.BrightBlack, t.BrightRed, t.BrightGreen, t.BrightYellow, t.BrightBlue,
+ t.BrightMagenta, t.BrightCyan, t.BrightWhite, t.SelectionBg, t.SelectionFg,
+ )
+ if err != nil {
+ return fmt.Errorf("seed theme %s: %w", t.Name, err)
+ }
+ }
+ return nil
+}
+
+func (s *ThemeService) List() ([]Theme, error) {
+ rows, err := s.db.Query(
+ `SELECT id, name, foreground, background, cursor,
+ black, red, green, yellow, blue, magenta, cyan, white,
+ bright_black, bright_red, bright_green, bright_yellow, bright_blue,
+ bright_magenta, bright_cyan, bright_white,
+ COALESCE(selection_bg,''), COALESCE(selection_fg,''), is_builtin
+ FROM themes ORDER BY is_builtin DESC, name`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var themes []Theme
+ for rows.Next() {
+ var t Theme
+ if err := rows.Scan(&t.ID, &t.Name, &t.Foreground, &t.Background, &t.Cursor,
+ &t.Black, &t.Red, &t.Green, &t.Yellow, &t.Blue, &t.Magenta, &t.Cyan, &t.White,
+ &t.BrightBlack, &t.BrightRed, &t.BrightGreen, &t.BrightYellow, &t.BrightBlue,
+ &t.BrightMagenta, &t.BrightCyan, &t.BrightWhite,
+ &t.SelectionBg, &t.SelectionFg, &t.IsBuiltin); err != nil {
+ return nil, err
+ }
+ themes = append(themes, t)
+ }
+ return themes, nil
+}
+
+func (s *ThemeService) GetByName(name string) (*Theme, error) {
+ var t Theme
+ err := s.db.QueryRow(
+ `SELECT id, name, foreground, background, cursor,
+ black, red, green, yellow, blue, magenta, cyan, white,
+ bright_black, bright_red, bright_green, bright_yellow, bright_blue,
+ bright_magenta, bright_cyan, bright_white,
+ COALESCE(selection_bg,''), COALESCE(selection_fg,''), is_builtin
+ FROM themes WHERE name = ?`, name,
+ ).Scan(&t.ID, &t.Name, &t.Foreground, &t.Background, &t.Cursor,
+ &t.Black, &t.Red, &t.Green, &t.Yellow, &t.Blue, &t.Magenta, &t.Cyan, &t.White,
+ &t.BrightBlack, &t.BrightRed, &t.BrightGreen, &t.BrightYellow, &t.BrightBlue,
+ &t.BrightMagenta, &t.BrightCyan, &t.BrightWhite,
+ &t.SelectionBg, &t.SelectionFg, &t.IsBuiltin)
+ if err != nil {
+ return nil, fmt.Errorf("get theme %s: %w", name, err)
+ }
+ return &t, nil
+}
+```
+
+- [ ] **Step 4: Run theme tests**
+
+```bash
+go test ./internal/theme/ -v
+```
+
+Expected: 3 PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/theme/
+git commit -m "feat: theme service with 7 built-in terminal color schemes"
+```
+
+---
+
+## Task 8: Plugin Interfaces + Session Manager Skeleton
+
+**Files:**
+- Create: `internal/plugin/interfaces.go`, `internal/plugin/registry.go`
+- Create: `internal/session/session.go`, `internal/session/manager.go`, `internal/session/manager_test.go`
+
+- [ ] **Step 1: Define plugin interfaces**
+
+```go
+// internal/plugin/interfaces.go
+package plugin
+
+// ProtocolHandler defines how a protocol plugin connects and manages sessions.
+type ProtocolHandler interface {
+ Name() string
+ Connect(config map[string]interface{}) (Session, error)
+ Disconnect(sessionID string) error
+}
+
+// Session represents an active protocol session.
+type Session interface {
+ ID() string
+ Protocol() string
+ Write(data []byte) error
+ Close() error
+}
+
+// Importer parses configuration files from other tools.
+type Importer interface {
+ Name() string
+ FileExtensions() []string
+ Parse(data []byte) (*ImportResult, error)
+}
+
+// ImportResult holds parsed data from an imported config file.
+type ImportResult struct {
+ Groups []ImportGroup `json:"groups"`
+ Connections []ImportConnection `json:"connections"`
+ HostKeys []ImportHostKey `json:"hostKeys"`
+ Theme *ImportTheme `json:"theme,omitempty"`
+}
+
+type ImportGroup struct {
+ Name string `json:"name"`
+ ParentName string `json:"parentName,omitempty"`
+}
+
+type ImportConnection struct {
+ Name string `json:"name"`
+ Hostname string `json:"hostname"`
+ Port int `json:"port"`
+ Protocol string `json:"protocol"`
+ Username string `json:"username"`
+ GroupName string `json:"groupName"`
+ Notes string `json:"notes"`
+}
+
+type ImportHostKey struct {
+ Hostname string `json:"hostname"`
+ Port int `json:"port"`
+ KeyType string `json:"keyType"`
+ Fingerprint string `json:"fingerprint"`
+}
+
+type ImportTheme struct {
+ Name string `json:"name"`
+ Foreground string `json:"foreground"`
+ Background string `json:"background"`
+ Cursor string `json:"cursor"`
+ Colors [16]string `json:"colors"` // ANSI 0-15
+}
+```
+
+- [ ] **Step 2: Define plugin registry**
+
+```go
+// internal/plugin/registry.go
+package plugin
+
+import "fmt"
+
+type Registry struct {
+ protocols map[string]ProtocolHandler
+ importers map[string]Importer
+}
+
+func NewRegistry() *Registry {
+ return &Registry{
+ protocols: make(map[string]ProtocolHandler),
+ importers: make(map[string]Importer),
+ }
+}
+
+func (r *Registry) RegisterProtocol(handler ProtocolHandler) {
+ r.protocols[handler.Name()] = handler
+}
+
+func (r *Registry) RegisterImporter(imp Importer) {
+ r.importers[imp.Name()] = imp
+}
+
+func (r *Registry) GetProtocol(name string) (ProtocolHandler, error) {
+ h, ok := r.protocols[name]
+ if !ok {
+ return nil, fmt.Errorf("protocol handler %q not registered", name)
+ }
+ return h, nil
+}
+
+func (r *Registry) GetImporter(name string) (Importer, error) {
+ imp, ok := r.importers[name]
+ if !ok {
+ return nil, fmt.Errorf("importer %q not registered", name)
+ }
+ return imp, nil
+}
+
+func (r *Registry) ListProtocols() []string {
+ names := make([]string, 0, len(r.protocols))
+ for name := range r.protocols {
+ names = append(names, name)
+ }
+ return names
+}
+```
+
+- [ ] **Step 3: Define session types (data only — no behavior to test)**
+
+```go
+// internal/session/session.go
+package session
+
+import "time"
+
+type SessionState string
+
+const (
+ StateConnecting SessionState = "connecting"
+ StateConnected SessionState = "connected"
+ StateDisconnected SessionState = "disconnected"
+ StateDetached SessionState = "detached"
+)
+
+type SessionInfo struct {
+ ID string `json:"id"`
+ ConnectionID int64 `json:"connectionId"`
+ Protocol string `json:"protocol"`
+ State SessionState `json:"state"`
+ WindowID string `json:"windowId"`
+ TabPosition int `json:"tabPosition"`
+ ConnectedAt time.Time `json:"connectedAt"`
+}
+```
+
+```go
+// internal/session/manager.go
+package session
+
+import (
+ "fmt"
+ "sync"
+
+ "github.com/google/uuid"
+)
+
+const MaxSessions = 32
+
+type Manager struct {
+ mu sync.RWMutex
+ sessions map[string]*SessionInfo
+}
+
+func NewManager() *Manager {
+ return &Manager{
+ sessions: make(map[string]*SessionInfo),
+ }
+}
+
+func (m *Manager) Create(connectionID int64, protocol string) (*SessionInfo, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if len(m.sessions) >= MaxSessions {
+ return nil, fmt.Errorf("maximum sessions (%d) reached", MaxSessions)
+ }
+
+ s := &SessionInfo{
+ ID: uuid.NewString(),
+ ConnectionID: connectionID,
+ Protocol: protocol,
+ State: StateConnecting,
+ TabPosition: len(m.sessions),
+ }
+ m.sessions[s.ID] = s
+ return s, nil
+}
+
+func (m *Manager) Get(id string) (*SessionInfo, bool) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ s, ok := m.sessions[id]
+ return s, ok
+}
+
+func (m *Manager) List() []*SessionInfo {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ list := make([]*SessionInfo, 0, len(m.sessions))
+ for _, s := range m.sessions {
+ list = append(list, s)
+ }
+ return list
+}
+
+func (m *Manager) SetState(id string, state SessionState) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ s, ok := m.sessions[id]
+ if !ok {
+ return fmt.Errorf("session %s not found", id)
+ }
+ s.State = state
+ return nil
+}
+
+func (m *Manager) Detach(id string) error {
+ return m.SetState(id, StateDetached)
+}
+
+func (m *Manager) Reattach(id, windowID string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ s, ok := m.sessions[id]
+ if !ok {
+ return fmt.Errorf("session %s not found", id)
+ }
+ s.State = StateConnected
+ s.WindowID = windowID
+ return nil
+}
+
+func (m *Manager) Remove(id string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ delete(m.sessions, id)
+}
+
+func (m *Manager) Count() int {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return len(m.sessions)
+}
+```
+
+- [ ] **Step 4: Write session manager tests (TDD — tests before manager implementation)**
+
+Add `github.com/google/uuid` before running tests:
+```bash
+go get github.com/google/uuid
+```
+
+```go
+// internal/session/manager_test.go
+package session
+
+import "testing"
+
+func TestCreateSession(t *testing.T) {
+ m := NewManager()
+ s, err := m.Create(1, "ssh")
+ if err != nil {
+ t.Fatalf("Create() error: %v", err)
+ }
+ if s.ID == "" {
+ t.Error("session ID should not be empty")
+ }
+ if s.State != StateConnecting {
+ t.Errorf("State = %q, want %q", s.State, StateConnecting)
+ }
+}
+
+func TestMaxSessions(t *testing.T) {
+ m := NewManager()
+ for i := 0; i < MaxSessions; i++ {
+ _, err := m.Create(int64(i), "ssh")
+ if err != nil {
+ t.Fatalf("Create() error at %d: %v", i, err)
+ }
+ }
+ _, err := m.Create(999, "ssh")
+ if err == nil {
+ t.Error("Create() should fail at max sessions")
+ }
+}
+
+func TestDetachReattach(t *testing.T) {
+ m := NewManager()
+ s, _ := m.Create(1, "ssh")
+ m.SetState(s.ID, StateConnected)
+
+ if err := m.Detach(s.ID); err != nil {
+ t.Fatalf("Detach() error: %v", err)
+ }
+
+ got, _ := m.Get(s.ID)
+ if got.State != StateDetached {
+ t.Errorf("State = %q, want %q", got.State, StateDetached)
+ }
+
+ if err := m.Reattach(s.ID, "window-1"); err != nil {
+ t.Fatalf("Reattach() error: %v", err)
+ }
+
+ got, _ = m.Get(s.ID)
+ if got.State != StateConnected {
+ t.Errorf("State = %q, want %q", got.State, StateConnected)
+ }
+}
+
+func TestRemoveSession(t *testing.T) {
+ m := NewManager()
+ s, _ := m.Create(1, "ssh")
+ m.Remove(s.ID)
+ if m.Count() != 0 {
+ t.Error("session should have been removed")
+ }
+}
+```
+
+- [ ] **Step 5: Run session tests to verify they FAIL (manager not implemented yet)**
+
+```bash
+go test ./internal/session/ -v
+```
+
+Expected: FAIL — `NewManager` not defined
+
+- [ ] **Step 5b: Implement session manager (manager.go — the code from Step 3's second block)**
+
+Move the `Manager` struct and methods into `manager.go` now that tests exist.
+
+- [ ] **Step 5c: Run session tests to verify they PASS**
+
+```bash
+go test ./internal/session/ -v
+```
+
+Expected: 4 PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/plugin/ internal/session/
+git commit -m "feat: plugin interfaces + window-agnostic session manager with detach/reattach"
+```
+
+---
+
+## Task 9: Wire Services into Wails App
+
+**Files:**
+- Modify: `main.go`
+
+- [ ] **Step 1: Update `main.go` to initialize DB, vault, and register all services**
+
+Wire up SQLite, vault unlock flow, and register ConnectionService, SettingsService, ThemeService, and SessionManager as Wails services. The vault unlock happens via a frontend prompt (implemented in Task 10).
+
+Create a `WraithApp` struct that holds all services and exposes methods the frontend needs:
+- `Unlock(password string) error`
+- `IsFirstRun() bool`
+- `CreateVault(password string) error`
+
+Register it as a Wails service alongside ConnectionService, ThemeService, and SettingsService.
+
+- [ ] **Step 2: Verify compilation**
+
+```bash
+go build -o bin/wraith.exe .
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add main.go
+git commit -m "feat: wire all services into Wails app entry point"
+```
+
+---
+
+## Task 10: Master Password Unlock UI
+
+**Files:**
+- Create: `frontend/src/layouts/UnlockLayout.vue`
+- Create: `frontend/src/stores/app.store.ts`
+- Create: `frontend/src/composables/useVault.ts`
+- Modify: `frontend/src/App.vue`
+
+- [ ] **Step 1: Create app store with unlock state**
+
+```ts
+// frontend/src/stores/app.store.ts
+import { defineStore } from "pinia";
+import { ref } from "vue";
+
+export const useAppStore = defineStore("app", () => {
+ const unlocked = ref(false);
+ const isFirstRun = ref(false);
+
+ function setUnlocked(val: boolean) { unlocked.value = val; }
+ function setFirstRun(val: boolean) { isFirstRun.value = val; }
+
+ return { unlocked, isFirstRun, setUnlocked, setFirstRun };
+});
+```
+
+- [ ] **Step 2: Create unlock layout with master password form**
+
+Dark-themed centered card with the Wraith logo, master password input, unlock button. First-run mode shows "Create Master Password" with confirm field.
+
+- [ ] **Step 3: Update `App.vue` to gate on unlock state**
+
+```vue
+
+
+
+
Vault unlocked — MainLayout coming in Task 11
+
+
+```
+
+Note: `MainLayout` is created in Task 11. This placeholder will be replaced there.
+
+- [ ] **Step 4: Generate Wails bindings and verify the unlock flow renders**
+
+```bash
+wails3 generate bindings
+cd frontend && npm run build && cd ..
+go run .
+```
+
+Note: `wails3 generate bindings` scans registered Go services and generates TypeScript bindings into `frontend/bindings/`. Frontend code imports from these bindings to call Go methods. Re-run this command whenever Go service methods change.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/src/
+git commit -m "feat: master password unlock UI with first-run vault creation"
+```
+
+---
+
+## Task 11: Main Layout — Sidebar + Tab Container + Status Bar
+
+**Files:**
+- Create: `frontend/src/layouts/MainLayout.vue`
+- Create: `frontend/src/components/sidebar/ConnectionTree.vue`
+- Create: `frontend/src/components/sidebar/SidebarToggle.vue`
+- Create: `frontend/src/components/session/TabBar.vue`
+- Create: `frontend/src/components/session/SessionContainer.vue`
+- Create: `frontend/src/components/common/StatusBar.vue`
+- Create: `frontend/src/stores/connection.store.ts`
+- Create: `frontend/src/stores/session.store.ts`
+- Create: `frontend/src/composables/useConnections.ts`
+
+- [ ] **Step 1: Create connection store**
+
+Pinia store wrapping Wails bindings for ConnectionService: `groups`, `connections`, `searchQuery`, `activeTag`, computed filtered lists.
+
+- [ ] **Step 2: Create session store**
+
+Pinia store wrapping SessionManager bindings: `sessions`, `activeSessionId`, `tabOrder`.
+
+- [ ] **Step 3: Create `MainLayout.vue`**
+
+Three-panel layout: resizable sidebar (240px default), tab bar + session area, status bar. Uses CSS Grid.
+
+- [ ] **Step 4: Create `ConnectionTree.vue`**
+
+Renders hierarchical group tree with connection entries. Search bar at top, tag filter pills, recent connections section. Uses Naive UI `NTree` component. Green dots for SSH, blue for RDP.
+
+- [ ] **Step 5: Create `SidebarToggle.vue`**
+
+Toggle buttons: Connections | SFTP. Switches the sidebar content area.
+
+- [ ] **Step 6: Create `TabBar.vue`**
+
+Horizontal tab bar with color-coded dots, close button, "+" button. Active tab highlighted with blue bottom border and 0.5s CSS transition.
+
+- [ ] **Step 7: Create `SessionContainer.vue`**
+
+Holds active session placeholders (empty state for Phase 1 — "Connect to a host to start a session"). Uses `v-show` for tab switching.
+
+- [ ] **Step 8: Create `StatusBar.vue`**
+
+Bottom bar with connection info (left) and app info (right): theme name, encoding, terminal dimensions placeholder.
+
+- [ ] **Step 9: Build and verify layout renders**
+
+```bash
+cd frontend && npm run build && cd ..
+go run .
+```
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add frontend/src/
+git commit -m "feat: main layout — sidebar connection tree, tab bar, status bar"
+```
+
+---
+
+## Task 12: Multi-Window Spike (Plan A Validation)
+
+**Files:**
+- Create: `spike/multiwindow/main.go` (temporary)
+
+- [ ] **Step 1: Write a minimal two-window Wails v3 test**
+
+Create a spike app that:
+1. Opens a main window
+2. Has a button that opens a second window via `app.NewWebviewWindowWithOptions()`
+3. Both windows can call the same Go service method
+4. Closing the second window doesn't crash the app
+
+- [ ] **Step 2: Run the spike on Windows (or via cross-compile check)**
+
+```bash
+cd spike/multiwindow && go run .
+```
+
+Document results: does `NewWebviewWindow` work? Can both windows access the same service? Any crashes?
+
+- [ ] **Step 3: Record findings**
+
+Create `docs/spikes/multi-window-results.md` with:
+- Plan A status: WORKS / PARTIAL / FAILED
+- Any issues found
+- Fallback recommendation if needed
+
+- [ ] **Step 4: Clean up spike**
+
+```bash
+rm -rf spike/
+```
+
+- [ ] **Step 5: Commit findings**
+
+```bash
+git add docs/spikes/
+git commit -m "spike: Wails v3 multi-window validation — Plan A results"
+```
+
+---
+
+## Task 13: RDP Frame Transport Spike
+
+**Files:**
+- Create: `spike/frametransport/main.go` (temporary)
+
+- [ ] **Step 1: Write a spike that benchmarks frame delivery**
+
+Create a Go app that:
+1. Generates a 1920x1080 RGBA test frame (solid color + timestamp)
+2. Serves it via local HTTP endpoint (`/frame`)
+3. Also exposes it via a Wails binding (base64-encoded PNG)
+4. Frontend renders both approaches on a `