1package soy.iko.opencode.data.network
3import soy.iko.opencode.data.model.Agent
4import soy.iko.opencode.data.model.Command
5import soy.iko.opencode.data.model.CommandRequest
6import soy.iko.opencode.data.model.CreateSessionRequest
7import soy.iko.opencode.data.model.FileContent
8import soy.iko.opencode.data.model.FileNode
9import soy.iko.opencode.data.model.FileStatusEntry
10import soy.iko.opencode.data.model.MessageWithParts
11import soy.iko.opencode.data.model.ModelRef
12import soy.iko.opencode.data.model.PathInfo
13import soy.iko.opencode.data.model.FilePromptPart
14import soy.iko.opencode.data.model.FindMatch
15import soy.iko.opencode.data.model.PermissionReplyBody
16import soy.iko.opencode.data.model.PermissionResponse
17import soy.iko.opencode.data.model.Project
18import soy.iko.opencode.data.model.PromptPart
19import soy.iko.opencode.data.model.PromptRequest
20import soy.iko.opencode.data.model.ProvidersResponse
21import soy.iko.opencode.data.model.RevertRequest
22import soy.iko.opencode.data.model.Session
23import soy.iko.opencode.data.model.ShellRequest
24import soy.iko.opencode.data.model.SummarizeRequest
25import soy.iko.opencode.data.model.SymbolResult
26import soy.iko.opencode.data.model.TextPromptPart
27import soy.iko.opencode.data.model.UpdateSessionRequest
28import io.ktor.client.HttpClient
29import io.ktor.client.call.body
30import io.ktor.client.plugins.ClientRequestException
31import io.ktor.client.plugins.ResponseException
32import io.ktor.client.plugins.ServerResponseException
33import io.ktor.client.request.delete
34import io.ktor.client.request.get
35import io.ktor.client.request.header
36import io.ktor.client.request.parameter
37import io.ktor.client.request.patch
38import io.ktor.client.request.post
39import io.ktor.client.request.setBody
40import io.ktor.http.ContentType
41import io.ktor.http.contentType
42import kotlinx.coroutines.delay
43import kotlinx.coroutines.sync.Mutex
44import kotlinx.coroutines.sync.withLock
45import kotlinx.serialization.json.buildJsonObject
46import kotlinx.serialization.json.put
47import kotlin.random.Random
48import java.net.URLEncoder
51 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
52 * SSE stream lives in [EventStreamClient]; everything else is here.
54open class OpencodeApiClient private constructor(
55 private val client: HttpClient?,
56 @Suppress("unused") private val testMode: Boolean,
58 constructor(client: HttpClient) : this(client, false)
59 protected constructor() : this(null, true)
61 // --- Catalog cache (providers/agents/commands) ---
62 // These change rarely, but the ChatViewModel re-fetches them on every creation.
63 // A short-lived in-memory cache avoids redundant network calls when navigating
64 // between sessions on the same server.
65 private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
66 // Per-catalog mutexes so a slow fetch of one catalog doesn't block the others.
67 // The fetch runs OUTSIDE the lock (holding it across the network call would serialize
68 // all callers and stall invalidation). To keep that correct, each catalog carries a
69 // generation counter, bumped under the mutex by its invalidateXCache(): a fetch captures
70 // the generation before it starts and, when it completes, only writes its result back if
71 // the generation is unchanged — so an invalidation that raced the in-flight fetch is not
72 // defeated by the pre-invalidation result being stored afterwards.
73 private val providersMutex = Mutex()
74 private val agentsMutex = Mutex()
75 private val commandsMutex = Mutex()
76 private var cachedProviders: CachedEntry<ProvidersResponse>? = null
77 private var cachedAgents: CachedEntry<List<Agent>>? = null
78 private var cachedCommands: CachedEntry<List<Command>>? = null
79 // Generation counters (guarded by the matching mutex above) for the invalidation-race guard.
80 private var providersGeneration = 0
81 private var agentsGeneration = 0
82 private var commandsGeneration = 0
84 /** Lightweight connectivity check. Throws on non-2xx / network failure. */
85 open suspend fun ping() {
86 withRetry { client!!.get("global/health").body<String>() }
89 open suspend fun listSessions(): List<Session> = withRetry {
90 client!!.get("session").body()
94 * Create a session, optionally in a specific worktree [directory]. The directory is a
95 * query parameter (not a body field): the server records it on the session and resolves
96 * it by session id thereafter, so follow-up calls (messages, commands, abort, …) don't
97 * — and shouldn't — re-send it. A null/blank [directory] lets the server fall back to
98 * its own launch cwd, preserving the pre-directory behavior.
100 open suspend fun createSession(title: String? = null, directory: String? = null): Session {
101 // Generate the idempotency key before entering withRetry so every retry attempt
102 // shares it. Without this, a POST that reached the server but whose response was
103 // lost (reset/timeout) would be retried and create a second orphan session.
104 val idempotencyKey = java.util.UUID.randomUUID().toString()
105 val dir = directory?.takeIf { it.isNotBlank() }
107 client!!.post("session") {
108 contentType(ContentType.Application.Json)
109 header("Idempotency-Key", idempotencyKey)
110 if (dir != null) parameter("directory", dir)
111 setBody(CreateSessionRequest(title = title))
116 /** List the projects (worktree roots) the server knows about, for the directory picker. */
117 open suspend fun listProjects(): List<Project> = withRetry {
118 client!!.get("project").body()
121 /** The server's current path info; its [PathInfo.directory] is the default (cwd) a
122 * session with no directory override runs in. */
123 open suspend fun currentPath(): PathInfo = withRetry {
124 client!!.get("path").body()
127 open suspend fun updateSession(id: String, title: String): Session = withRetry {
128 client!!.patch("session/${encode(id)}") {
129 contentType(ContentType.Application.Json)
130 setBody(UpdateSessionRequest(title = title))
134 open suspend fun deleteSession(id: String) {
136 withRetry { client!!.delete("session/${encode(id)}").body<String>() }
137 } catch (t: ClientRequestException) {
138 // A DELETE that succeeded but whose response was lost gets retried; the retry
139 // hits an already-gone session and returns 404. Deleting a non-existent session
140 // is effectively success, so swallow 404 rather than surfacing a spurious failure.
141 if (t.response.status.value != 404) throw t
145 open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
146 client!!.get("session/${encode(sessionId)}/message").body()
149 open suspend fun sendPrompt(
152 attachments: List<FilePromptPart> = emptyList(),
153 model: ModelRef? = null,
154 agent: String? = null,
155 // A caller-stable idempotency key. Callers that may re-invoke sendPrompt across
156 // *separate* calls for the same logical message (the offline outbox, which re-sends
157 // a message that stayed queued after an earlier flush failed) must pass a stable key
158 // derived from the persistent message id — otherwise each attempt mints a fresh key
159 // and a POST that reached the server but whose response was lost is re-created as a
160 // duplicate run. When null, a fresh key is generated (correct for one-shot sends).
161 idempotencyKey: String? = null,
162 ): MessageWithParts {
163 // Resolve the key *before* entering withRetry so all retry attempts share it. If the
164 // key were generated inside withRetry, each attempt would get a fresh UUID and the
165 // server couldn't deduplicate a request that reached it but whose response was lost.
166 val resolvedKey = idempotencyKey ?: java.util.UUID.randomUUID().toString()
167 // A text part is only included when there's actual text, so an image-only prompt
168 // (attachments, empty text) doesn't send an empty text part. Attachments follow
169 // the text so the model reads the instruction first.
170 val parts = buildList<PromptPart> {
171 if (text.isNotEmpty()) add(TextPromptPart(text))
175 client!!.post("session/${encode(sessionId)}/message") {
176 contentType(ContentType.Application.Json)
177 header("Idempotency-Key", resolvedKey)
189 /** Abort the active run in a session. NOT retried: a lost response followed by a
190 * retry would POST abort again, and if a new run started in the gap the retry
191 * would kill that *new* run. Unlike sendPrompt/runCommand/summarize there's no
192 * Idempotency-Key the server could use to dedupe, so a single attempt is the
193 * safe choice — the SSE stream reflects the actual run state regardless. */
194 open suspend fun abort(sessionId: String) {
195 withRetry(maxAttempts = 1) { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
198 /** Invoke a slash-command by name via `POST /session/:id/command`. */
199 open suspend fun runCommand(
202 arguments: String = "",
203 agent: String? = null,
204 ): MessageWithParts {
205 // Generate the idempotency key before entering withRetry so all retry
206 // attempts share the same key, preventing duplicate command execution if
207 // the server processed the request but the response was lost.
208 val idempotencyKey = java.util.UUID.randomUUID().toString()
210 client!!.post("session/${encode(sessionId)}/command") {
211 contentType(ContentType.Application.Json)
212 header("Idempotency-Key", idempotencyKey)
213 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
218 /** Revert the session to just before [messageId] (optionally part [partId]); the
219 * server hides everything after the checkpoint. Returns the updated session. Naturally
220 * idempotent (reverting to the same point twice yields the same state). */
221 open suspend fun revert(sessionId: String, messageId: String, partId: String? = null): Session = withRetry {
222 client!!.post("session/${encode(sessionId)}/revert") {
223 contentType(ContentType.Application.Json)
224 setBody(RevertRequest(messageID = messageId, partID = partId))
228 /** Undo the active revert checkpoint, restoring the hidden messages. */
229 open suspend fun unrevert(sessionId: String): Session = withRetry {
230 client!!.post("session/${encode(sessionId)}/unrevert").body()
233 /** Create a public share link for the session. Returns the session with [Session.share] set.
234 * Mint the Idempotency-Key *before* withRetry so a retried POST (first request reached the
235 * server but its response was lost) doesn't create a second, orphaned, still-valid share. */
236 open suspend fun shareSession(sessionId: String): Session {
237 val idempotencyKey = java.util.UUID.randomUUID().toString()
239 client!!.post("session/${encode(sessionId)}/share") {
240 header("Idempotency-Key", idempotencyKey)
245 /** Revoke the session's public share link. Returns the session with share cleared.
246 * Like [deleteSession], a retried DELETE (first reached the server but the response was
247 * lost) hits an already-unshared session and gets 404 — revoking a non-shared session is
248 * effectively success, so swallow 404 rather than surfacing a spurious failure. */
249 open suspend fun unshareSession(sessionId: String): Session {
251 withRetry { client!!.delete("session/${encode(sessionId)}/share").body() }
252 } catch (t: ClientRequestException) {
253 if (t.response.status.value != 404) throw t
254 withRetry { client!!.get("session/${encode(sessionId)}").body() }
258 /** Ask the agent to summarize/compact the conversation to reclaim context. The compacted
259 * summary streams back via SSE like any run; this just triggers it. */
260 open suspend fun summarize(sessionId: String, model: ModelRef) {
261 val idempotencyKey = java.util.UUID.randomUUID().toString()
263 client!!.post("session/${encode(sessionId)}/summarize") {
264 contentType(ContentType.Application.Json)
265 header("Idempotency-Key", idempotencyKey)
266 setBody(SummarizeRequest(providerID = model.providerID, modelID = model.modelID))
271 /** Analyze the project and (re)generate its AGENTS.md. The body is omitted so the server
272 * assigns the message id and uses its default model; the run streams back via SSE. */
273 open suspend fun initSession(sessionId: String) {
274 val idempotencyKey = java.util.UUID.randomUUID().toString()
276 client!!.post("session/${encode(sessionId)}/init") {
277 header("Idempotency-Key", idempotencyKey)
282 /** Run a one-off shell [command] in the session's worktree. Output streams back via SSE.
283 * [agent] scopes the run (the server requires it). */
284 open suspend fun shell(sessionId: String, command: String, agent: String, model: ModelRef? = null) {
285 val idempotencyKey = java.util.UUID.randomUUID().toString()
287 client!!.post("session/${encode(sessionId)}/shell") {
288 contentType(ContentType.Application.Json)
289 header("Idempotency-Key", idempotencyKey)
290 setBody(ShellRequest(agent = agent, command = command, model = model))
295 open suspend fun providers(): ProvidersResponse {
297 providersMutex.withLock {
298 val cached = cachedProviders
299 if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
302 generation = providersGeneration
304 // Fetch outside the lock so a slow/retrying request (and its backoff) can't stall other
305 // callers or cache invalidation. Concurrent cold callers may double-fetch — acceptable
306 // for a rarely-fetched catalog — and the last store wins.
307 val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
308 providersMutex.withLock {
309 // FIX 15: skip the store if an invalidateProvidersCache() raced this fetch — the
310 // result predates the invalidation and would otherwise resurrect stale data.
311 if (providersGeneration == generation) cachedProviders = CachedEntry(value, System.currentTimeMillis())
316 /** The server's resolved configuration as a raw JSON object. Parsed loosely by callers
317 * (e.g. the MCP viewer) so a schema addition on the server never breaks decoding. */
318 open suspend fun config(): kotlinx.serialization.json.JsonObject = withRetry {
319 client!!.get("config").body()
322 /** Live MCP server status (`GET /mcp`): a map of server name to its status (connected,
323 * available tools, any error). Decoded as a raw JSON object so a schema change on the
324 * server never breaks the viewer. */
325 open suspend fun mcpStatus(): kotlinx.serialization.json.JsonObject = withRetry {
326 client!!.get("mcp").body()
329 /** Dynamically register an MCP server (`POST /mcp`). [config] is the server's config object
330 * (type/command-or-url/env…), sent as `{ "name": [name], "config": [config] }`. Mint the
331 * Idempotency-Key *before* withRetry so a retried POST (first reached the server but the
332 * response was lost) doesn't register a duplicate. */
333 open suspend fun addMcp(name: String, config: kotlinx.serialization.json.JsonObject): kotlinx.serialization.json.JsonObject {
334 val idempotencyKey = java.util.UUID.randomUUID().toString()
336 client!!.post("mcp") {
337 contentType(ContentType.Application.Json)
338 header("Idempotency-Key", idempotencyKey)
342 put("config", config)
349 open suspend fun agents(): List<Agent> {
351 agentsMutex.withLock {
352 val cached = cachedAgents
353 if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
356 generation = agentsGeneration
358 // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
359 // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
360 val value = withRetry { client!!.get("agent").body<List<Agent>>() }
361 agentsMutex.withLock {
362 // FIX 15: skip the store if an invalidateAgentsCache() raced this fetch (see providers()).
363 if (agentsGeneration == generation) cachedAgents = CachedEntry(value, System.currentTimeMillis())
368 open suspend fun commands(): List<Command> {
370 commandsMutex.withLock {
371 val cached = cachedCommands
372 if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
375 generation = commandsGeneration
377 // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
378 // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
379 val value = withRetry { client!!.get("command").body<List<Command>>() }
380 commandsMutex.withLock {
381 // FIX 15: skip the store if an invalidateCommandsCache() raced this fetch (see providers()).
382 if (commandsGeneration == generation) cachedCommands = CachedEntry(value, System.currentTimeMillis())
387 /** Invalidate the catalog cache (e.g. on server switch). */
388 open suspend fun invalidateCache() {
389 // FIX 15: bump each generation so an in-flight fetch drops its now-stale result.
390 providersMutex.withLock { cachedProviders = null; providersGeneration++ }
391 agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
392 commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
395 open suspend fun invalidateProvidersCache() {
396 providersMutex.withLock { cachedProviders = null; providersGeneration++ }
399 open suspend fun invalidateAgentsCache() {
400 agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
403 open suspend fun invalidateCommandsCache() {
404 commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
407 /** Respond to a permission request so a paused tool run can proceed. */
408 open suspend fun respondPermission(
410 permissionId: String,
411 response: PermissionResponse,
414 client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
415 contentType(ContentType.Application.Json)
416 setBody(PermissionReplyBody(response.wire))
419 } catch (t: ClientRequestException) {
420 // A response that reached the server but whose reply was lost gets retried; the
421 // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
422 // the answer landed (or the session/permission no longer exists, in which case the
423 // response is moot anyway), so treat them as success instead of a spurious failure
424 // that would re-queue the dialog against a gone session. Log the swallowed status
425 // so a 404 caused by a deleted session is debuggable rather than fully silent.
426 val code = t.response.status.value
427 if (code != 404 && code != 409) throw t
428 android.util.Log.d("OpencodeApi", "respondPermission swallowed $code for session=$sessionId permission=$permissionId")
434 open suspend fun findFiles(query: String): List<String> = withRetry {
435 client!!.get("find/file") { parameter("query", query) }.body()
438 /** Ripgrep content search across the project (`GET /find?pattern=`). */
439 open suspend fun findText(pattern: String): List<FindMatch> = withRetry {
440 client!!.get("find") { parameter("pattern", pattern) }.body()
443 /** LSP workspace symbol search (`GET /find/symbol?query=`). */
444 open suspend fun findSymbol(query: String): List<SymbolResult> = withRetry {
445 client!!.get("find/symbol") { parameter("query", query) }.body()
448 open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
449 client!!.get("file") { parameter("path", path) }.body()
452 open suspend fun readFile(path: String): FileContent = withRetry {
453 client!!.get("file/content") { parameter("path", path) }.body()
456 open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
457 client!!.get("file/status").body()
461private suspend fun <T> withRetry(
462 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
463 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
464 block: suspend () -> T,
465): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
468 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
469 * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
470 * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
471 * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
472 * server errors are retried. Exposed as `internal` so the retry/skip rules are
473 * unit-testable without an HTTP server.
475internal suspend fun <T> withRetryInternal(
476 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
477 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
478 jitterFactor: Double = NetworkConfig.retryJitterFactor,
479 block: suspend () -> T,
481 var lastError: Throwable? = null
482 for (attempt in 1..maxAttempts) {
485 } catch (c: kotlinx.coroutines.CancellationException) {
487 } catch (t: ClientRequestException) {
488 // 408 (Request Timeout) and 429 (Too Many Requests) are transient — retry with
489 // backoff. All other 4xx are non-retryable (a 401/404 won't succeed on retry).
490 val status = t.response.status.value
491 if (status != 408 && status != 429) throw t
493 if (attempt < maxAttempts) {
494 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
496 } catch (t: ServerResponseException) {
497 // 5xx server errors are retryable with backoff.
499 if (attempt < maxAttempts) {
500 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
502 } catch (t: ResponseException) {
503 // 3xx (redirect limit exceeded) is non-retryable.
505 } catch (t: io.ktor.serialization.ContentConvertException) {
506 // A malformed/unexpected response body (deserialization failure via content
507 // negotiation) is deterministic — it fails identically on every attempt — so
508 // retrying only burns the whole backoff budget re-parsing the same bytes. Fail
511 } catch (t: IllegalArgumentException) {
512 // Also deterministic: a bad argument, or a raw kotlinx SerializationException
513 // (which extends IllegalArgumentException) thrown outside content negotiation.
515 } catch (t: Exception) {
517 if (attempt < maxAttempts) {
518 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
522 throw lastError ?: IllegalStateException("withRetry failed without error")
525/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
526 * so the backoff formula is unit-testable without an HTTP server. */
527internal fun jitteredBackoff(
529 initialDelayMs: Long,
530 jitterFactor: Double,
532 // Guard against Int overflow from the shift and Long overflow from
533 // the multiplication at high attempt counts.
534 val shift = (attempt - 1).coerceAtMost(30)
535 val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
536 // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
537 // shorter, sometimes longer — spreads concurrent client retries and
538 // prevents thundering-herd reconnection storms.
539 val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
540 return (baseDelay + jitter).coerceAtLeast(0)
543/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding).
544 * URLEncoder leaves '.' unencoded, so a "." or ".." segment would otherwise survive as a real
545 * path-traversal component; percent-encode a segment that is nothing but dots. */
546private fun encode(segment: String): String {
547 val encoded = URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
548 return if (encoded.isNotEmpty() && encoded.all { it == '.' }) encoded.replace(".", "%2E") else encoded
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list