# Ticket 1.4 — REST API Client

**Phase**: 1 — OpenCode Bridge (Core)
**Priority**: Critical (blocking)
**Depends on**: 0.2

## Description

HTTP client wrapper for OpenCode's REST API. Provides typed methods for all API operations the relay needs: session management, messaging, permission replies, question replies, agent/model/provider queries, file operations, and search.

OpenCode's API is documented via OpenAPI 3.1 spec at `GET /doc`. The server uses Hono with Zod validation. Default port is 4096.

## Acceptance Criteria

### AC1: Session CRUD operations

```gherkin
Given the OpenCode server is running
When I call createSession({ title? })
Then it POSTs to /session/ with the body
And it returns a session object with id, projectID, directory, title, parentID?, time

When I call listSessions({ directory?, start?, search?, limit?, roots? })
Then it GETs /session/ with query parameters
And it returns an array of Session.Info objects

When I call getSession(sessionID)
Then it GETs /session/{sessionID}
And it returns the full session object

When I call getMessages(sessionID, { limit? })
Then it GETs /session/{sessionID}/message with query parameters
And it returns an ordered array of MessageV2.WithParts (messages with their parts)

When I call deleteSession(sessionID)
Then it DELETEs /session/{sessionID}
And it cascades to child sessions

When I call updateSession(sessionID, { title?, archived? })
Then it PATCHes /session/{sessionID} with the provided fields
And it returns the updated session object
```

### AC2: Sending messages (prompts)

```gherkin
Given a valid session ID
When I call sendMessage(sessionID, { parts, agent?, model?, format? })
Then it POSTs to /session/{sessionID}/prompt_async
And the request body includes:
  - parts: array of TextPart, FilePart, etc.
  - agent: optional agent name
  - model: optional { providerID, modelID }
And the response is 204 (processing happens async, events via SSE)

Note: Use prompt_async (not /message) so we receive streaming via SSE rather than
      needing to consume a JSON stream response.
```

### AC3: Permission replies

```gherkin
Given a pending permission with requestID
When I call replyPermission(requestID, "once")
Then it POSTs to /permission/{requestID}/reply with { reply: "once" }
And the response confirms the permission was resolved

When I call replyPermission(requestID, "reject")
Then the reply is "reject"

When I call replyPermission(requestID, "always")
Then the reply is "always"

Note: OpenCode uses "once"/"always"/"reject", NOT "allow"/"deny"/"allow_always".
```

### AC4: Question/ask-user replies

```gherkin
Given a pending question with requestID
When I call replyQuestion(requestID, answers)
Then it POSTs to /question/{requestID}/reply with { answers }
And answers is Array<Array<string>> (array of selected labels per question)

When I call rejectQuestion(requestID)
Then it POSTs to /question/{requestID}/reject
And the question is dismissed
```

### AC5: List pending permissions and questions

```gherkin
When I call listPermissions()
Then it GETs /permission/
And it returns an array of PermissionNext.Request objects

When I call listQuestions()
Then it GETs /question/
And it returns an array of Question.Request objects

Note: These are needed on startup to replay pending requests to newly connected browsers.
```

### AC6: Agent, provider, and model discovery

```gherkin
When I call listAgents()
Then it GETs /agent/
And it returns an array of agents with name, description, tools, permissions, model

When I call listProviders()
Then it GETs /provider/
And it returns { all: Provider[], default: { [providerID]: modelID }, connected: string[] }

When I call listCommands()
Then it GETs /command
And it returns an array of command definitions

When I call listSkills()
Then it GETs /skill
And it returns an array of Skill.Info objects
```

### AC7: Session advanced operations

```gherkin
When I call forkSession(sessionID, { atMessageID?, title? })
Then it POSTs to /session/{sessionID}/fork
And it returns a new session with parentID referencing the original

When I call revertSession(sessionID, input)
Then it POSTs to /session/{sessionID}/revert with { messageID, partID?, snapshot?, diff? }
And it returns the reverted session state

When I call unrevertSession(sessionID)
Then it POSTs to /session/{sessionID}/unrevert
And it restores the session to pre-revert state

When I call abortSession(sessionID)
Then it POSTs to /session/{sessionID}/abort
And the running session is stopped

When I call shareSession(sessionID)
Then it POSTs to /session/{sessionID}/share
And it returns the updated session with share info

When I call summarizeSession(sessionID, { providerID, modelID })
Then it POSTs to /session/{sessionID}/summarize
And it returns a summary of the session

When I call getSessionDiff(sessionID, messageID)
Then it GETs /session/{sessionID}/diff?messageID={messageID}
And it returns git-style diffs of file changes
```

### AC8: File operations

```gherkin
When I call listFiles(path)
Then it GETs /file?path={path}
And it returns directory entries with names, types, and sizes

When I call readFile(path)
Then it GETs /file/content?path={path}
And it returns the file content

When I call getFileStatus()
Then it GETs /file/status
And it returns git status information for tracked files
```

### AC9: Search operations

```gherkin
When I call findText(pattern)
Then it GETs /find?pattern={pattern}
And it returns matching lines with file paths and line numbers (Ripgrep format)

When I call findFiles(query)
Then it GETs /find/file?query={query}
And it returns matching file paths

When I call findSymbols(query)
Then it GETs /find/symbol?query={query}
And it returns symbol definitions with locations
```

### AC10: Error handling

```gherkin
Given the OpenCode server returns a 4xx error
When the client receives it
Then it throws a typed error with the status code and error message
And the error includes the endpoint that failed

Given the OpenCode server returns a 5xx error
When the client receives it
Then it retries once after 1 second
And it throws if the retry also fails

Given the OpenCode server is unreachable
When the client attempts a request
Then it throws a connection error within 5 seconds
And the error message indicates the server may not be running
```

### AC11: System endpoints

```gherkin
When I call getHealth()
Then it GETs /global/health
And it returns { healthy: boolean, version: string }

When I call getPath()
Then it GETs /path
And it returns { home, state, config, worktree, directory }

When I call getVcsInfo()
Then it GETs /vcs
And it returns { branch: string }

When I call getConfig()
Then it GETs /config/
And it returns the current configuration

When I call getProject()
Then it GETs /project/current
And it returns the current project info
```

### AC12: PTY management

```gherkin
When I call listPtys()
Then it GETs /pty/
And it returns an array of Pty.Info objects

When I call createPty(options)
Then it POSTs to /pty/
And it returns a new Pty.Info

When I call deletePty(ptyID)
Then it DELETEs /pty/{ptyID}
And the PTY is removed

Note: PTY I/O is via WebSocket at GET /pty/{ptyID}/connect, handled separately.
```

## Testable Components

| Component | Test Type | What to Assert |
|-----------|-----------|----------------|
| `createSession(opts)` | Unit | Correct POST body, returns parsed response |
| `listSessions(opts)` | Unit | Correct query params, returns array |
| `getMessages(id)` | Unit | Correct URL, returns ordered messages with parts |
| `sendMessage(id, msg)` | Unit | POSTs to prompt_async, returns 204 |
| `replyPermission(id, reply)` | Unit | Correct endpoint and body (once/always/reject) |
| `replyQuestion(id, answers)` | Unit | Correct endpoint, answers as Array<Array<string>> |
| `rejectQuestion(id)` | Unit | POSTs to /question/{id}/reject |
| `listPermissions()` | Unit | Returns pending permissions array |
| `listQuestions()` | Unit | Returns pending questions array |
| `listAgents()` | Unit | Returns agents array |
| `listProviders()` | Unit | Returns providers with connected status |
| `forkSession(id)` | Unit | Returns new session with parentID |
| `revertSession(id, input)` | Unit | Correct body, returns reverted state |
| `updateSession(id, opts)` | Unit | PATCHes /session/{id}, returns updated session |
| `abortSession(id)` | Unit | POSTs to /session/{id}/abort |
| `listFiles(path)` | Unit | Correct query param, returns entries |
| `readFile(path)` | Unit | Returns file content |
| `findText(pattern)` | Unit | Returns Ripgrep matches |
| `getHealth()` | Unit | Returns { healthy, version } |
| `listPtys()` | Unit | Returns PTY info array |
| Error handling (4xx) | Unit | Throws typed error with status |
| Error handling (5xx) | Unit | Retries once, then throws |
| Error handling (network) | Unit | Throws connection error with helpful message |
| Full CRUD flow | Fixture | Create → list → get → delete session |
| Event translation contract | Fixture | Translator output for each sample-events fixture matches snapshot |

## API Shape

```ts
// src/lib/opencode-client.ts
export class OpenCodeClient {
  constructor(options = {})                          // { baseUrl, auth?, timeout, retries }
                                                     // auth: { username, password } — HTTP Basic Auth
                                                     // from OPENCODE_SERVER_USERNAME / OPENCODE_SERVER_PASSWORD env vars

  // Health
  async getHealth()

  // Sessions
  async createSession(opts)
  async listSessions(opts)
  async getSession(id)
  async getMessages(id, opts)
  async deleteSession(id)
  async updateSession(id, opts)                      // PATCH /session/{id} — update title, archived
  async sendMessage(id, message)                     // Uses prompt_async
  async forkSession(id, opts)
  async revertSession(id, input)
  async unrevertSession(id)
  async abortSession(id)
  async shareSession(id)
  async summarizeSession(id, opts)
  async getSessionDiff(id, messageID)

  // Permissions & Questions
  async replyPermission(requestId, reply)            // reply: "once"|"always"|"reject"
  async listPermissions()
  async replyQuestion(requestId, answers)            // answers: Array<Array<string>>
  async rejectQuestion(requestId)
  async listQuestions()

  // Discovery
  async listAgents()
  async listProviders()
  async listCommands()
  async listSkills()

  // Files
  async listFiles(path)
  async readFile(path)
  async getFileStatus()

  // Search
  async findText(pattern)
  async findFiles(query)
  async findSymbols(query)

  // System
  async getPath()
  async getVcsInfo()
  async getConfig()
  async getProject()

  // PTY
  async listPtys()
  async createPty(opts)
  async deletePty(id)
  // PTY I/O is via WebSocket, not REST
}
```

## Reference

- OpenCode server routes: `../opencode/packages/opencode/src/server/server.ts`
- OpenCode route handlers: `../opencode/packages/opencode/src/server/routes/*.ts`
- OpenCode OpenAPI spec: available at GET /doc on running server
- All endpoints use JSON request/response bodies
- OpenCode uses Hono with OpenAPI — schemas are discoverable
- Default port: 4096
- Permission reply values: "once", "always", "reject" (NOT allow/deny/allow_always)
- Question answers format: Array<Array<string>> (NOT { [idx]: "label" })
