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 * The fallback `GET` re-reads the current session state so the caller sees the cleared
250 * share. If the session itself was deleted in the window between the DELETE-404 and the
251 * GET (race with another client / a concurrent deleteSession), the GET also 404s —
252 * swallow that too and surface a synthesized gone-session rather than an unexpected
253 * ClientRequestException, mirroring deleteSession's contract. */
254 open suspend fun unshareSession(sessionId: String): Session {
256 withRetry { client!!.delete("session/${encode(sessionId)}/share").body() }
257 } catch (t: ClientRequestException) {
258 if (t.response.status.value != 404) throw t
260 withRetry { client!!.get("session/${encode(sessionId)}").body<Session>() }
261 } catch (t2: ClientRequestException) {
262 if (t2.response.status.value != 404) throw t2
263 Session(id = sessionId, title = null, share = null)
268 /** Ask the agent to summarize/compact the conversation to reclaim context. The compacted
269 * summary streams back via SSE like any run; this just triggers it. */
270 open suspend fun summarize(sessionId: String, model: ModelRef) {
271 val idempotencyKey = java.util.UUID.randomUUID().toString()
273 client!!.post("session/${encode(sessionId)}/summarize") {
274 contentType(ContentType.Application.Json)
275 header("Idempotency-Key", idempotencyKey)
276 setBody(SummarizeRequest(providerID = model.providerID, modelID = model.modelID))
281 /** Analyze the project and (re)generate its AGENTS.md. The body is omitted so the server
282 * assigns the message id and uses its default model; the run streams back via SSE. */
283 open suspend fun initSession(sessionId: String) {
284 val idempotencyKey = java.util.UUID.randomUUID().toString()
286 client!!.post("session/${encode(sessionId)}/init") {
287 header("Idempotency-Key", idempotencyKey)
292 /** Run a one-off shell [command] in the session's worktree. Output streams back via SSE.
293 * [agent] scopes the run (the server requires it). */
294 open suspend fun shell(sessionId: String, command: String, agent: String, model: ModelRef? = null) {
295 val idempotencyKey = java.util.UUID.randomUUID().toString()
297 client!!.post("session/${encode(sessionId)}/shell") {
298 contentType(ContentType.Application.Json)
299 header("Idempotency-Key", idempotencyKey)
300 setBody(ShellRequest(agent = agent, command = command, model = model))
305 open suspend fun providers(): ProvidersResponse {
307 providersMutex.withLock {
308 val cached = cachedProviders
309 if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
312 generation = providersGeneration
314 // Fetch outside the lock so a slow/retrying request (and its backoff) can't stall other
315 // callers or cache invalidation. Concurrent cold callers may double-fetch — acceptable
316 // for a rarely-fetched catalog — and the last store wins.
317 val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
318 providersMutex.withLock {
319 // FIX 15: skip the store if an invalidateProvidersCache() raced this fetch — the
320 // result predates the invalidation and would otherwise resurrect stale data.
321 if (providersGeneration == generation) cachedProviders = CachedEntry(value, System.currentTimeMillis())
326 /** The server's resolved configuration as a raw JSON object. Parsed loosely by callers
327 * (e.g. the MCP viewer) so a schema addition on the server never breaks decoding. */
328 open suspend fun config(): kotlinx.serialization.json.JsonObject = withRetry {
329 client!!.get("config").body()
332 /** Live MCP server status (`GET /mcp`): a map of server name to its status (connected,
333 * available tools, any error). Decoded as a raw JSON object so a schema change on the
334 * server never breaks the viewer. */
335 open suspend fun mcpStatus(): kotlinx.serialization.json.JsonObject = withRetry {
336 client!!.get("mcp").body()
339 /** Dynamically register an MCP server (`POST /mcp`). [config] is the server's config object
340 * (type/command-or-url/env…), sent as `{ "name": [name], "config": [config] }`. Mint the
341 * Idempotency-Key *before* withRetry so a retried POST (first reached the server but the
342 * response was lost) doesn't register a duplicate. */
343 open suspend fun addMcp(name: String, config: kotlinx.serialization.json.JsonObject): kotlinx.serialization.json.JsonObject {
344 val idempotencyKey = java.util.UUID.randomUUID().toString()
346 client!!.post("mcp") {
347 contentType(ContentType.Application.Json)
348 header("Idempotency-Key", idempotencyKey)
352 put("config", config)
359 open suspend fun agents(): List<Agent> {
361 agentsMutex.withLock {
362 val cached = cachedAgents
363 if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
366 generation = agentsGeneration
368 // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
369 // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
370 val value = withRetry { client!!.get("agent").body<List<Agent>>() }
371 agentsMutex.withLock {
372 // FIX 15: skip the store if an invalidateAgentsCache() raced this fetch (see providers()).
373 if (agentsGeneration == generation) cachedAgents = CachedEntry(value, System.currentTimeMillis())
378 open suspend fun commands(): List<Command> {
380 commandsMutex.withLock {
381 val cached = cachedCommands
382 if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
385 generation = commandsGeneration
387 // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
388 // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
389 val value = withRetry { client!!.get("command").body<List<Command>>() }
390 commandsMutex.withLock {
391 // FIX 15: skip the store if an invalidateCommandsCache() raced this fetch (see providers()).
392 if (commandsGeneration == generation) cachedCommands = CachedEntry(value, System.currentTimeMillis())
397 /** Invalidate the catalog cache (e.g. on server switch). */
398 open suspend fun invalidateCache() {
399 // FIX 15: bump each generation so an in-flight fetch drops its now-stale result.
400 providersMutex.withLock { cachedProviders = null; providersGeneration++ }
401 agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
402 commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
405 open suspend fun invalidateProvidersCache() {
406 providersMutex.withLock { cachedProviders = null; providersGeneration++ }
409 open suspend fun invalidateAgentsCache() {
410 agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
413 open suspend fun invalidateCommandsCache() {
414 commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
417 /** Respond to a permission request so a paused tool run can proceed. */
418 open suspend fun respondPermission(
420 permissionId: String,
421 response: PermissionResponse,
424 client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
425 contentType(ContentType.Application.Json)
426 setBody(PermissionReplyBody(response.wire))
429 } catch (t: ClientRequestException) {
430 // A response that reached the server but whose reply was lost gets retried; the
431 // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
432 // the answer landed (or the session/permission no longer exists, in which case the
433 // response is moot anyway), so treat them as success instead of a spurious failure
434 // that would re-queue the dialog against a gone session. Log the swallowed status
435 // so a 404 caused by a deleted session is debuggable rather than fully silent.
436 val code = t.response.status.value
437 if (code != 404 && code != 409) throw t
438 android.util.Log.d("OpencodeApi", "respondPermission swallowed $code for session=$sessionId permission=$permissionId")
444 open suspend fun findFiles(query: String): List<String> = withRetry {
445 client!!.get("find/file") { parameter("query", query) }.body()
448 /** Ripgrep content search across the project (`GET /find?pattern=`). */
449 open suspend fun findText(pattern: String): List<FindMatch> = withRetry {
450 client!!.get("find") { parameter("pattern", pattern) }.body()
453 /** LSP workspace symbol search (`GET /find/symbol?query=`). */
454 open suspend fun findSymbol(query: String): List<SymbolResult> = withRetry {
455 client!!.get("find/symbol") { parameter("query", query) }.body()
458 open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
459 client!!.get("file") { parameter("path", path) }.body()
462 open suspend fun readFile(path: String): FileContent = withRetry {
463 client!!.get("file/content") { parameter("path", path) }.body()
466 open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
467 client!!.get("file/status").body()
471private suspend fun <T> withRetry(
472 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
473 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
474 block: suspend () -> T,
475): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
478 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
479 * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
480 * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
481 * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
482 * server errors are retried. Exposed as `internal` so the retry/skip rules are
483 * unit-testable without an HTTP server.
485internal suspend fun <T> withRetryInternal(
486 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
487 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
488 jitterFactor: Double = NetworkConfig.retryJitterFactor,
489 block: suspend () -> T,
491 var lastError: Throwable? = null
492 for (attempt in 1..maxAttempts) {
495 } catch (c: kotlinx.coroutines.CancellationException) {
497 } catch (t: ClientRequestException) {
498 // 408 (Request Timeout) and 429 (Too Many Requests) are transient — retry with
499 // backoff. All other 4xx are non-retryable (a 401/404 won't succeed on retry).
500 val status = t.response.status.value
501 if (status != 408 && status != 429) throw t
503 if (attempt < maxAttempts) {
504 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
506 } catch (t: ServerResponseException) {
507 // 5xx server errors are retryable with backoff.
509 if (attempt < maxAttempts) {
510 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
512 } catch (t: ResponseException) {
513 // 3xx (redirect limit exceeded) is non-retryable.
515 } catch (t: io.ktor.serialization.ContentConvertException) {
516 // A malformed/unexpected response body (deserialization failure via content
517 // negotiation) is deterministic — it fails identically on every attempt — so
518 // retrying only burns the whole backoff budget re-parsing the same bytes. Fail
521 } catch (t: IllegalArgumentException) {
522 // Also deterministic: a bad argument, or a raw kotlinx SerializationException
523 // (which extends IllegalArgumentException) thrown outside content negotiation.
525 } catch (t: Exception) {
527 if (attempt < maxAttempts) {
528 delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
532 throw lastError ?: IllegalStateException("withRetry failed without error")
535/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
536 * so the backoff formula is unit-testable without an HTTP server. */
537internal fun jitteredBackoff(
539 initialDelayMs: Long,
540 jitterFactor: Double,
542 // Guard against Int overflow from the shift and Long overflow from
543 // the multiplication at high attempt counts.
544 val shift = (attempt - 1).coerceAtMost(30)
545 val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
546 // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
547 // shorter, sometimes longer — spreads concurrent client retries and
548 // prevents thundering-herd reconnection storms.
549 val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
550 return (baseDelay + jitter).coerceAtLeast(0)
553/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding).
554 * URLEncoder leaves '.' unencoded, so a "." or ".." segment would otherwise survive as a real
555 * path-traversal component; percent-encode a segment that is nothing but dots. */
556private fun encode(segment: String): String {
557 val encoded = URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
558 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