@@ -29,6 +29,7 @@ import io.ktor.http.ContentType
2929import io.ktor.http.contentType
3030import kotlinx.coroutines.delay
3131import kotlin.random.Random
32+import java.net.URLEncoder
3233
3334/**
3435 * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
@@ -52,17 +53,17 @@ class OpencodeApiClient(private val client: HttpClient) {
5253 }.body()
5354
5455 suspend fun updateSession(id: String, title: String): Session =
55− client.patch("session/$id") {
56+ client.patch("session/${encode(id)}") {
5657 contentType(ContentType.Application.Json)
5758 setBody(UpdateSessionRequest(title = title))
5859 }.body()
5960
6061 suspend fun deleteSession(id: String) {
61− client.delete("session/$id")
62+ client.delete("session/${encode(id)}")
6263 }
6364
6465 suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
65− client.get("session/$sessionId/message").body()
66+ client.get("session/${encode(sessionId)}/message").body()
6667 }
6768
6869 suspend fun sendPrompt(
@@ -71,7 +72,7 @@ class OpencodeApiClient(private val client: HttpClient) {
7172 model: ModelRef? = null,
7273 agent: String? = null,
7374 ): MessageWithParts =
74− client.post("session/$sessionId/message") {
75+ client.post("session/${encode(sessionId)}/message") {
7576 contentType(ContentType.Application.Json)
7677 setBody(
7778 PromptRequest(
@@ -83,7 +84,7 @@ class OpencodeApiClient(private val client: HttpClient) {
8384 }.body()
8485
8586 suspend fun abort(sessionId: String) {
86− client.post("session/$sessionId/abort")
87+ client.post("session/${encode(sessionId)}/abort")
8788 }
8889
8990 /** Invoke a slash-command by name via `POST /session/:id/command`. */
@@ -93,7 +94,7 @@ class OpencodeApiClient(private val client: HttpClient) {
9394 arguments: String = "",
9495 agent: String? = null,
9596 ): MessageWithParts =
96− client.post("session/$sessionId/command") {
97+ client.post("session/${encode(sessionId)}/command") {
9798 contentType(ContentType.Application.Json)
9899 setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
99100 }.body()
@@ -116,7 +117,7 @@ class OpencodeApiClient(private val client: HttpClient) {
116117 permissionId: String,
117118 response: PermissionResponse,
118119 ) = withRetry {
119− client.post("session/$sessionId/permissions/$permissionId") {
120+ client.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
120121 contentType(ContentType.Application.Json)
121122 setBody(PermissionReplyBody(response.wire))
122123 }
@@ -169,7 +170,10 @@ internal suspend fun <T> withRetryInternal(
169170 throw c
170171 } catch (t: Exception) {
171172 lastError = t
172− if (t is ClientRequestException) throw t
173+ if (t is ClientRequestException) {
174+ // 429 (Too Many Requests) is transient — retry with backoff.
175+ if (t.response.status.value != 429) throw t
176+ }
173177 if (attempt < maxAttempts) {
174178 val baseDelay = initialDelayMs * (1 shl (attempt - 1))
175179 val jitter = (baseDelay * jitterFactor * Random.nextDouble()).toLong()
@@ -179,3 +183,7 @@ internal suspend fun <T> withRetryInternal(
179183 }
180184 throw lastError ?: IllegalStateException("withRetry failed without error")
181185}
186+
187+/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
188+private fun encode(segment: String): String =
189+ URLEncoder.encode(segment, "UTF-8").replace("+", "%20")