---
phase: 08-http-control-api
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - plugins/claude-tts-companion/Package.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/SettingsStore.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/HTTPControlServer.swift
autonomous: true
requirements: [API-01, API-02, API-03, API-04, API-05, API-06, API-07]

must_haves:
  truths:
    - "GET /health returns JSON with bot, tts, subtitle status plus rss_mb and uptime_seconds"
    - "GET /settings returns all current subtitle and TTS settings as JSON"
    - "POST /settings/subtitle updates fontSize, position, opacity, karaokeEnabled and persists to disk"
    - "POST /settings/tts updates enabled, voice, speed and persists to disk"
    - "POST /subtitle/show displays text on screen"
    - "POST /subtitle/hide dismisses the subtitle"
    - "Settings survive binary restart via JSON file at ~/.config/claude-tts-companion/settings.json"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/SettingsStore.swift"
      provides: "Thread-safe settings persistence with JSON disk storage"
      exports: ["SettingsStore", "SubtitleSettings", "TTSSettings"]
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/HTTPControlServer.swift"
      provides: "FlyingFox HTTP server with all API endpoints"
      exports: ["HTTPControlServer"]
  key_links:
    - from: "HTTPControlServer.swift"
      to: "SettingsStore.swift"
      via: "reads/writes settings for GET/POST endpoints"
      pattern: "settingsStore\\."
    - from: "SettingsStore.swift"
      to: "~/.config/claude-tts-companion/settings.json"
      via: "JSON file persistence"
      pattern: "JSONEncoder.*write|JSONDecoder.*read"
---

<objective>
Create the HTTP control API server with settings persistence, health endpoint, and subtitle/TTS control routes.

Purpose: Enable external programs (SwiftBar in Phase 9) to query and control all subsystems via HTTP.
Output: SettingsStore.swift (disk-persisted settings), HTTPControlServer.swift (FlyingFox routes), updated Package.swift with FlyingFox dependency.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md

@plugins/claude-tts-companion/Package.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/Config.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitlePanel.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleStyle.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift

<interfaces>
<!-- Key types and contracts the executor needs -->

From SubtitlePanel.swift (MainActor):

```swift
@MainActor
final class SubtitlePanel: NSPanel {
    func show(text: String)
    func hide()
    func showUtterance(_ text: String, wordTimings: [TimeInterval])
    func positionOnScreen()
}
```

From SubtitleStyle.swift (MainActor):

```swift
@MainActor
enum SubtitleStyle {
    enum FontSize: CGFloat { case small = 22; case medium = 28; case large = 36 }
    static let bottomOffset: CGFloat = 80
    static let widthRatio: CGFloat = 0.7
    static let backgroundColor  // NSColor at 30% opacity
}
```

From TTSEngine.swift (@unchecked Sendable):

```swift
final class TTSEngine: @unchecked Sendable {
    func synthesize(text:speakerId:speed:completion:)
    func play(wavPath:completion:)
    func stopPlayback()
}
```

From Config.swift:

```swift
enum Config {
    static let appName = "claude-tts-companion"
    static let serviceLabel = "com.terryli.claude-tts-companion"
    static let defaultSpeakerId: Int32 = 3
}
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add FlyingFox dependency and create SettingsStore with disk persistence</name>
  <files>plugins/claude-tts-companion/Package.swift, plugins/claude-tts-companion/Sources/claude-tts-companion/SettingsStore.swift</files>
  <read_first>
    - plugins/claude-tts-companion/Package.swift
    - plugins/claude-tts-companion/Sources/claude-tts-companion/Config.swift
    - plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleStyle.swift
  </read_first>
  <action>
**Package.swift changes:**
Add FlyingFox dependency to the `dependencies` array:
```swift
.package(url: "https://github.com/swhitty/FlyingFox", from: "0.26.0"),
```
Add to the executableTarget dependencies:
```swift
.product(name: "FlyingFox", package: "FlyingFox"),
```

**SettingsStore.swift:**
Create a thread-safe settings manager that persists to `~/.config/claude-tts-companion/settings.json` (API-07).

Define two Codable structs:

- `SubtitleSettings`: fontSize (String enum: "small"/"medium"/"large", default "medium"), position (String: "bottom", default "bottom"), opacity (Double, default 0.3), karaokeEnabled (Bool, default true), screen (String: "builtin", default "builtin")
- `TTSSettings`: enabled (Bool, default true), voice (String, default "af_heart"), speed (Double, default 1.0)

Define a top-level `AppSettings` Codable struct containing `subtitle: SubtitleSettings` and `tts: TTSSettings`.

Create `final class SettingsStore: @unchecked Sendable`:

- Private NSLock for thread safety (consistent with TTSEngine, CircuitBreaker patterns in this codebase)
- Private `settings: AppSettings` property
- `settingsFilePath`: computed from `~/.config/claude-tts-companion/settings.json`
- `init()`: loads from disk if file exists, otherwise uses defaults. Create parent directory if missing via `FileManager.default.createDirectory(withIntermediateDirectories: true)`.
- `func getSettings() -> AppSettings`: lock, copy, unlock, return
- `func updateSubtitle(_ update: (inout SubtitleSettings) -> Void)`: lock, mutate, unlock, save
- `func updateTTS(_ update: (inout TTSSettings) -> Void)`: lock, mutate, unlock, save
- Private `func save()`: encode to JSON with `.prettyPrinted` and `.sortedKeys`, write atomically to disk. Log errors but do not throw (settings loss is non-fatal).
- Private `func load() -> AppSettings?`: read file, decode, return nil on failure.

Use `import Foundation` and `import Logging`. Add a logger with label "settings-store".
</action>
<verify>
<automated>cd plugins/claude-tts-companion && swift package resolve 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>FlyingFox resolves without conflicts. SettingsStore.swift compiles with Codable settings structs, NSLock thread safety, and JSON file persistence at ~/.config/claude-tts-companion/settings.json.</acceptance_criteria>
<done>Package.swift includes FlyingFox v0.26.0+. SettingsStore has getSettings, updateSubtitle, updateTTS methods with disk persistence.</done>
</task>

<task type="auto">
  <name>Task 2: Create HTTPControlServer with all API endpoints</name>
  <files>plugins/claude-tts-companion/Sources/claude-tts-companion/HTTPControlServer.swift, plugins/claude-tts-companion/Sources/claude-tts-companion/Config.swift</files>
  <read_first>
    - plugins/claude-tts-companion/Sources/claude-tts-companion/SettingsStore.swift (just created)
    - plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitlePanel.swift
    - plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift
    - plugins/claude-tts-companion/Sources/claude-tts-companion/Config.swift
  </read_first>
  <action>
**Config.swift:** Add one constant:
```swift
// MARK: - HTTP Control API
static let httpPort: UInt16 = 8780
```

**HTTPControlServer.swift:**
Create `final class HTTPControlServer` that wraps FlyingFox's HTTPServer.

```swift
import FlyingFox
import Foundation
import Logging
```

The class stores references to subsystems it controls:

- `settingsStore: SettingsStore`
- `subtitlePanel: SubtitlePanel` (MainActor-isolated, so dispatch to main for calls)
- `ttsEngine: TTSEngine`
- `startTime: Date` (set in init, used for uptime calculation)
- `logger: Logger` with label "http-api"

Constructor: `init(settingsStore:subtitlePanel:ttsEngine:)` -- stores references, records `startTime = Date()`.

**`func start() async throws`:**
Create `HTTPServer(address: .loopback(port: Config.httpPort))`. Register route handlers using `server.appendRoute(...)` for each endpoint. Then `try await server.start()`.

**Endpoint handlers (all return `HTTPResponse`):**

1. **GET /health** (API-01):
   Return JSON:

   ```json
   {
     "status": "ok",
     "uptime_seconds": <Int>,
     "rss_mb": <Double>,
     "subsystems": {
       "bot": "unknown",
       "tts": "ready",
       "subtitle": "ready"
     }
   }
   ```

   RSS: use `mach_task_basic_info` via `task_info()` to get `resident_size`, divide by 1048576.
   Uptime: `Date().timeIntervalSince(startTime)`.
   Bot status is "unknown" (no bot reference yet -- Phase 9 can wire it).

2. **GET /settings** (API-02):
   Return `settingsStore.getSettings()` encoded as JSON.

3. **POST /settings/subtitle** (API-03):
   Decode request body as `SubtitleSettings` (partial -- use optional fields in a separate `SubtitleSettingsUpdate` struct where all fields are Optional). Merge non-nil fields into current settings via `settingsStore.updateSubtitle`. Return updated settings.

   Define `SubtitleSettingsUpdate: Codable` with optional fields: `fontSize: String?`, `position: String?`, `opacity: Double?`, `karaokeEnabled: Bool?`, `screen: String?`.

4. **POST /settings/tts** (API-04):
   Same pattern with `TTSSettingsUpdate: Codable` (optional `enabled: Bool?`, `voice: String?`, `speed: Double?`). Merge into `settingsStore.updateTTS`. Return updated settings.

5. **POST /subtitle/show** (API-05):
   Decode body as `{"text": "...", "duration": 5.0}` (duration optional). Dispatch to MainActor:

   ```swift
   await MainActor.run { subtitlePanel.show(text: text) }
   ```

   If duration is provided, schedule hide after duration seconds:

   ```swift
   if let duration = request.duration {
       Task { @MainActor in
           try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
           subtitlePanel.hide()
       }
   }
   ```

   Return `{"ok": true}`.

6. **POST /subtitle/hide** (API-06):
   Dispatch `await MainActor.run { subtitlePanel.hide() }`. Return `{"ok": true}`.

**Error handling:** Wrap each handler in do/catch. On decode errors return 400 with `{"error": "..."}`. On unexpected errors return 500.

**Helper:** Private `func jsonResponse(_ encodable: some Encodable, status: HTTPStatusCode = .ok) -> HTTPResponse` that encodes to JSON and returns with `Content-Type: application/json`.

**IMPORTANT Swift 6 concurrency notes:**

- SubtitlePanel is @MainActor. Use `await MainActor.run { ... }` to call its methods from async handler context.
- FlyingFox route handlers are `@Sendable (HTTPRequest) async throws -> HTTPResponse`. The class must be careful about capturing self -- mark `HTTPControlServer` as `final class HTTPControlServer: @unchecked Sendable` and use NSLock if needed (though startTime and references are set once in init and never mutated).
- Use `[self]` capture in route closures since self is Sendable.
  </action>
  <verify>
  <automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -10</automated>
  </verify>
  <acceptance_criteria>swift build succeeds. HTTPControlServer has 6 route handlers covering all API-01 through API-06 requirements. FlyingFox HTTPServer binds to localhost:8780.</acceptance_criteria>
  <done>HTTPControlServer.swift compiles with GET /health, GET /settings, POST /settings/subtitle, POST /settings/tts, POST /subtitle/show, POST /subtitle/hide. All handlers use SettingsStore for persistence and dispatch to MainActor for SubtitlePanel calls.</done>
  </task>

</tasks>

<verification>
- `swift build` succeeds in plugins/claude-tts-companion/
- SettingsStore.swift and HTTPControlServer.swift exist with correct exports
- FlyingFox resolves as dependency
</verification>

<success_criteria>
All 7 API requirements (API-01 through API-07) are implemented across SettingsStore (persistence) and HTTPControlServer (endpoints). Build compiles cleanly with FlyingFox integrated.
</success_criteria>

<output>
After completion, create `.planning/phases/08-http-control-api/08-01-SUMMARY.md`
</output>
