package com.example.aiapp import java.io.IOException import java.net.HttpURLConnection import java.net.URL import org.json.JSONArray import org.json.JSONObject // The REST half of the backend's surface (see server/src/routes.rs for the table); the SSE half is // EventStream.kt. All blocking network calls -- invoke from a background dispatcher. Each throws // ApiException carrying the server's own explanation where it sent one, since those messages are // written to be read on this screen. // Shared with EventStream.kt, which connects the same way but then reads without a deadline. const val CONNECT_TIMEOUT_MS = 5000 private const val READ_TIMEOUT_MS = 5000 /** * A request that did not produce what it asked for, carrying the server's own wording where it sent * some. * * [status] is the HTTP status where there was a response at all, and null where the server was * never reached. Callers that need it need it because the *same* failure is two different things to * do: a 409 from a write is "somebody else changed this, here are three ways out". Nothing should * branch on it to decide what to *say* -- the message is what says that. */ class ApiException(message: String, val status: Int? = null, cause: Throwable? = null) : Exception(message, cause) /** * Runs one request against the backend, with the pinned TLS setup, the bearer token, and the * failure translation every call needs. [readBody] gets the connected, already-status-checked * connection. * * @param readTimeoutMs how long to wait on the response body. The SSE stream doesn't come through * here -- an event stream has no bounded read time. */ fun requestFromServer( settings: ServerSettings, path: String, method: String = "GET", jsonBody: String? = null, /** * A request body written as it is produced -- the upload path. Sent chunked, since what a * writer will produce is not known up front and the point is that a file never sits whole in * memory. */ streamBody: Pair Unit>? = null, readTimeoutMs: Int = READ_TIMEOUT_MS, readBody: (HttpURLConnection) -> T, ): T { val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection try { connection.applyPinnedTls() connection.requestMethod = method connection.connectTimeout = CONNECT_TIMEOUT_MS connection.readTimeout = readTimeoutMs connection.setRequestProperty("Authorization", "Bearer ${settings.token}") if (jsonBody != null) { connection.doOutput = true connection.setRequestProperty("Content-Type", "application/json") connection.outputStream.use { it.write(jsonBody.encodeToByteArray()) } } else if (streamBody != null) { connection.doOutput = true connection.setChunkedStreamingMode(0) connection.setRequestProperty("Content-Type", streamBody.first) connection.outputStream.use(streamBody.second) } if (connection.responseCode !in 200..299) { val detail = connection.errorStream?.bufferedReader()?.readText()?.trim() throw ApiException( when { connection.responseCode == 401 -> "The server rejected this device's token. Re-enroll by scanning " + "the server's QR (or rotate with --rotate-token and scan the new one)." detail.isNullOrEmpty() -> "Server returned HTTP ${connection.responseCode} for $path" else -> detail }, status = connection.responseCode, ) } return readBody(connection) } catch (e: ApiException) { throw e } catch (e: IOException) { // Surfacing the real exception rather than one canned message for every failure mode is // what lets this be diagnosed on a device with no logcat access. throw ApiException( "Couldn't reach the server at ${settings.baseUrl} " + "(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " + "this device able to reach that address (WireGuard up)?", cause = e, ) } catch (e: Exception) { throw ApiException( "Reached ${settings.baseUrl}$path but couldn't read its response " + "(${e::class.simpleName}: ${e.message})", cause = e, ) } finally { connection.disconnect() } } private fun HttpURLConnection.jsonObject(): JSONObject = JSONObject(inputStream.bufferedReader().readText()) private fun HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List = JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse) private fun JSONArray.mapObjects(parse: (JSONObject) -> T): List = (0 until length()).map { parse(getJSONObject(it)) } private fun JSONArray.strings(): List = (0 until length()).map { getString(it) } private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name()) // One row of GET /sessions. A session names the machine it runs on and which of that machine's // providers it runs. data class SessionSummary( val id: String, /** * Id of the machine this session runs on. Only ever used to *address* that machine -- to pick * this session's row out of the per-machine usage snapshots. Never shown; [setupName] is what a * reader sees, and holding both invites showing the wrong one. */ val setup: String, /** The machine's current label. This is the one to display; [setup] is never shown. */ val setupName: String, val provider: String, val title: String, val model: String?, /** * Whether the conversation would outlive deleting this session, decided by the server from the * provider's kind rather than here from its name. * * What it licenses is narrow, and the delete dialog is worded to match: the driver keeps its * own record somewhere this app's delete does not reach. It is not a promise that the file is * still there, and re-importing is not a restore. */ val keepsOwnTranscript: Boolean, /** How much the session asks before acting; null when it was never set. */ val permissionMode: String?, /** * Whether this continues a session the machine already had, which changes what deleting means. */ val imported: Boolean, /** * Whether this session announces itself when it wants attention. * * Reported rather than assumed, for the same reason [permissionMode] is: a switch that draws * itself from a default is one you can turn off while believing you are reading it. */ val notify: Boolean, /** * The directory the session works in, or null where it was never given one. * * Null is not "the home directory": it is the session never having been told. Shown as unset * rather than filled in with a guess, so a reader changing it is choosing rather than * confirming. */ val cwd: String?, /** * How much context this session is holding, as the server last measured it. * * Null where nothing has been measured: a session that has not run a turn, a provider that does * not report usage, or a clear nobody has run a turn since. That is not zero, and the status * row says so in words rather than drawing an empty context for a conversation that may be * full. */ val contextTokens: Long?, /** * The longest edge an image should have when it reaches this session, or null where the * provider has no limit. * * Null and "a big number" are different answers, and only the first stays true. Decided by the * server because that is where a provider's kind is known. */ val maxImageEdge: Int?, val status: String, val lastActivity: Double, ) private fun parseSession(session: JSONObject) = SessionSummary( id = session.getString("id"), setup = session.getString("setup"), keepsOwnTranscript = session.optBoolean("keepsOwnTranscript", false), setupName = session.getString("setupName"), provider = session.getString("provider"), title = session.getString("title"), model = session.optString("model").ifEmpty { null }, permissionMode = session.optString("permissionMode").ifEmpty { null }, imported = session.optBoolean("imported", false), notify = session.optBoolean("notify", true), cwd = session.optString("cwd").ifEmpty { null }, contextTokens = if (session.has("contextTokens")) session.getLong("contextTokens") else null, maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 }, status = session.getString("status"), lastActivity = session.getDouble("lastActivity"), ) fun fetchSessions(settings: ServerSettings): List = requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) } /** * One session as the server has it now. * * For screens whose controls are *set to* something rather than merely showing it. A screen opened * from a list row carries the row the list last fetched, which is a snapshot: fine for a title, * wrong for a switch, since a stale row shows a position that may have been changed since. */ fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary = requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) } // What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's // config.ron appears here with no app rebuild. // // One list rather than two. A provider only exists on a machine that has it installed, so offering // machines and providers as independent choices would offer pairs that cannot work. data class Provider(val name: String, val kind: String, val models: List) /** * A machine, and what it can run. [address] is absent for the backend itself. * * [id] is stable and [name] is not: renaming a machine keeps its sessions, so everything that * refers to a setup uses the id and everything a person reads uses the name. */ data class Setup( val id: String, val name: String, val address: String?, val providers: List, ) private fun parseProvider(provider: JSONObject) = Provider( name = provider.getString("name"), kind = provider.getString("kind"), // Omitted entirely when the provider offers none. models = provider.optJSONArray("models")?.strings().orEmpty(), ) private fun parseSetup(setup: JSONObject) = Setup( id = setup.getString("id"), name = setup.getString("name"), address = setup.optString("address").ifEmpty { null }, providers = setup.getJSONArray("providers").mapObjects(::parseProvider), ) fun fetchSetups(settings: ServerSettings): List = requestFromServer(settings, "/setups") { it.jsonObjects(::parseSetup) } /** * A Claude Code session already on a machine, which can be continued here. * * Identified by [id] and never by a path. The server resolves which file that is, so this app has * no way to ask it to read one. */ data class Importable( val id: String, val cwd: String, val title: String, val modified: Double, val lines: Int, /** * Size of the session file in bytes. Worth a place on the row because it is the only thing * there that predicts what continuing the session costs, and the line count does not: these * transcripts embed screenshots as base64, so a single line can be a megabyte. */ val bytes: Long, /** * Tokens the model was holding at the last turn, or null if no turn has recorded any. It * disagrees with [bytes] in the direction that matters: most of a large transcript is usually * history from before a compaction, which the model is no longer given. */ val contextTokens: Long?, /** Whether [title] is a name somebody chose rather than the last thing said in the session. */ val named: Boolean, /** * Whether a Claude Code is running this session right now. * * "unknown" is a third answer and not a synonym for "no": the machine may keep no record of * what is running. The server refuses an import of a "yes"; the row says so before you press * it. */ val inUse: String, /** * What this server is doing to the session right now -- "importing" or "deleting" -- or null. * * The server's answer rather than the phone's, because the work outlives the screen that asked * for it: a phone that was asleep never saw the events that said so. */ val pending: String?, /** * How the last attempt on this row failed, if it did. Kept by the server until something * replaces it, for the same reason [pending] is the server's to answer. */ val error: String?, ) /** * One frame of `GET /setups/{id}/importable/events`: an operation starting, finishing or failing. * * [operation] is only set by a start and [message] only by a failure -- the three states are every * way an operation can be, and each carries exactly what that state knows. */ data class ImportableChange( val session: String, val state: String, val operation: String?, val message: String?, ) fun parseImportableChange(payload: String): ImportableChange? = try { val frame = JSONObject(payload) ImportableChange( session = frame.getString("session"), state = frame.getString("state"), operation = frame.optString("operation").takeIf { it.isNotEmpty() }, message = frame.optString("message").takeIf { it.isNotEmpty() }, ) } catch (_: org.json.JSONException) { // A frame this build does not understand is not a reason to drop the stream: the listing is // the truth and will say what happened whatever this missed. null } /** * What a machine has that could be continued. * * The slowest call this app makes, and it was the only expensive one left on the 5 second default * -- which is how it came to time out against a server answering perfectly well. Listing means * reading every transcript Claude Code has ever written: about four seconds against a gigabyte of * them before the tunnel adds anything. A timeout is for a server that has stopped answering. */ fun fetchImportable(settings: ServerSettings, setup: String): List = requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) { it.jsonObjects { session -> Importable( id = session.getString("id"), cwd = session.optString("cwd"), title = session.optString("title"), modified = session.optDouble("modified", 0.0), lines = session.optInt("lines", 0), bytes = session.optLong("bytes", 0L), // Absent means nothing has been measured, which is not a context of zero. contextTokens = if (session.isNull("contextTokens")) null else session.optLong("contextTokens").takeIf { it > 0L }, // Absent means an older backend that cannot answer, which is what "unknown" says. inUse = session.optString("inUse", "unknown"), named = session.optBoolean("named", false), pending = session.optString("pending").takeIf { it.isNotEmpty() }, error = session.optString("error").takeIf { it.isNotEmpty() }, ) } } /** * How to reach a machine. Deliberately carries no command: the server discovers what a machine can * run by asking it, so this app has no way to introduce something to run. * * [identityFile] is a path on the *backend*, not a key -- private keys do not travel. */ data class SshDetails( val address: String, val port: Int? = null, val identityFile: String? = null, /** * Where files attached from here land on that machine; null for the session's own directory. */ val attachmentsDir: String? = null, ) private fun SshDetails.toJson() = JSONObject().put("address", address).apply { if (port != null) put("port", port) if (!identityFile.isNullOrBlank()) put("identityFile", identityFile) if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir) } /** What a machine turns out to have, without saving anything. */ fun probeSetup(settings: ServerSettings, ssh: SshDetails?): List = requestFromServer( settings, "/setups/probe", method = "POST", jsonBody = JSONObject().apply { if (ssh != null) put("ssh", ssh.toJson()) }.toString(), readTimeoutMs = 40000, ) { it.jsonObjects(::parseProvider) } fun addSetup(settings: ServerSettings, name: String, ssh: SshDetails?): Setup = requestFromServer( settings, "/setups", method = "POST", jsonBody = JSONObject() .put("name", name) .apply { if (ssh != null) put("ssh", ssh.toJson()) } .toString(), readTimeoutMs = 40000, ) { parseSetup(it.jsonObject()) } /** Renames a machine, and optionally asks it again what it has. */ fun updateSetup( settings: ServerSettings, id: String, name: String? = null, rediscover: Boolean = false, ): Setup = requestFromServer( settings, "/setups/${id.urlEncoded()}", method = "PUT", jsonBody = JSONObject() .apply { if (name != null) put("name", name) if (rediscover) put("rediscover", true) } .toString(), readTimeoutMs = 40000, ) { parseSetup(it.jsonObject()) } fun deleteSetup(settings: ServerSettings, id: String) { requestFromServer(settings, "/setups/${id.urlEncoded()}", method = "DELETE") {} } /** * Spawns a session and returns it as the list would show it. [setup] names the machine and * [provider] one of the things that machine offers. */ fun spawnSession( settings: ServerSettings, setup: String, provider: String, title: String, model: String? = null, cwd: String? = null, permissionMode: String? = null, params: Map = emptyMap(), /** Continue this Claude Code session instead of starting an empty one. */ import: String? = null, ): SessionSummary = requestFromServer( settings, "/sessions", method = "POST", jsonBody = JSONObject() .put("setup", setup) .put("provider", provider) .put("title", title) .apply { if (!model.isNullOrBlank()) put("model", model) if (!cwd.isNullOrBlank()) put("cwd", cwd) if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode) if (!import.isNullOrBlank()) put("import", import) if (params.isNotEmpty()) { put("params", JSONObject(params.toMap())) } } .toString(), readTimeoutMs = 30000, ) { connection -> parseSession(connection.jsonObject()) } fun sendMessage( settings: ServerSettings, sessionId: String, text: String, attachmentIds: List = emptyList(), ) { requestFromServer( settings, "/sessions/$sessionId/message", method = "POST", jsonBody = JSONObject() .put("text", text) .put("attachmentIds", JSONArray(attachmentIds)) .toString(), ) {} } /** * Takes back a message the session has not read yet, named by the id its `messageQueued` carried. * * Throws rather than returning an outcome, because both ways of failing are things the reader has * to be told: 409 means the session was already given it, and 404 means nothing is waiting under * that id. The bubble disappearing arrives on the event stream, so every device drops it. */ fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: String) { requestFromServer( settings, "/sessions/$sessionId/unqueue", method = "POST", jsonBody = JSONObject().put("messageId", messageId).toString(), ) {} } /** * Moves a session to a different working directory. * * The server checks the directory is there and refuses if it is not -- a mistyped path accepted * here would surface much later, as a session that would not start. * * Its process is **stopped**, because a working directory is settled when the process is spawned. * The next thing said to the session starts it again in the new one. */ fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) { requestFromServer( settings, "/sessions/$sessionId/cwd", method = "POST", jsonBody = JSONObject().put("cwd", cwd).toString(), readTimeoutMs = 30000, ) {} } /** * Uploads one attachment, streamed by [write]; the returned id goes into [sendMessage]. [name] is * what the server keeps a file under and tells the session; for an image it is ignored, since the * model is shown the picture rather than told its name. */ fun uploadAttachment( settings: ServerSettings, sessionId: String, mime: String, name: String, write: (java.io.OutputStream) -> Unit, ): String { val boundary = "----aiapp-${System.currentTimeMillis()}" // The header is a line: a quote or a line break in the name would end it early. val safeName = name.replace(Regex("[\"\r\n]"), "_") val head = ("--$boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; filename=\"$safeName\"\r\n" + "Content-Type: $mime\r\n\r\n") .encodeToByteArray() val tail = "\r\n--$boundary--\r\n".encodeToByteArray() return requestFromServer( settings, "/sessions/$sessionId/attachments", method = "POST", streamBody = "multipart/form-data; boundary=$boundary" to { out -> out.write(head) write(out) out.write(tail) }, // Long: a trace is hundreds of megabytes, and the server copies it on to a remote machine // before answering. readTimeoutMs = 600000, ) { connection -> connection.jsonObject().getString("id") } } /** * One entry of a directory on the machine a setup names. * * [kind] is the *target's* where the entry is a symlink, so a link to a directory descends; [link] * still says it is one. Neither is worked out here -- the machine answers both. */ data class DirEntry( val name: String, val kind: String, val size: Long, val modified: Long, val link: Boolean, ) { val isDirectory: Boolean get() = kind == "directory" } /** A directory's entries, and the path the machine resolved the request to. */ data class Listing(val path: String, val entries: List) /** * What reading a file produced. * * Four cases, because they are four different things to draw and none is an error the screen can * shrug off: content, something that is not text, something too big to have sent, and (as * [ApiException]) the machine's own refusal. A file with nothing in it is [FileContent.Text] with * an empty string, which is what it is. */ sealed class FileContent { abstract val path: String abstract val size: Long abstract val modified: Long data class Text( override val path: String, override val size: Long, override val modified: Long, /** What a write is given back, to prove the file is still the one that was read. */ val sha256: String, val content: String, ) : FileContent() data class Binary( override val path: String, override val size: Long, override val modified: Long, ) : FileContent() data class TooBig( override val path: String, override val size: Long, override val modified: Long, ) : FileContent() } /** What a file is after a write, so the editor's precondition is fresh without a second read. */ data class Written(val size: Long, val modified: Long, val sha256: String) /** Everything in [path] on the machine [setup] names, and what [path] resolved to. */ fun fetchDir(settings: ServerSettings, setup: String, path: String): Listing = requestFromServer( settings, "/setups/${setup.urlEncoded()}/dir?path=${path.urlEncoded()}", readTimeoutMs = 30000, ) { connection -> val body = connection.jsonObject() Listing( path = body.getString("path"), entries = body.getJSONArray("entries").mapObjects { entry -> DirEntry( name = entry.getString("name"), kind = entry.getString("kind"), size = entry.optLong("size"), modified = entry.optLong("modified"), link = entry.optBoolean("link", false), ) }, ) } /** One file's content, or which of the reasons there is none to show. */ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileContent = requestFromServer( settings, "/setups/${setup.urlEncoded()}/file?path=${path.urlEncoded()}", // A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before any // of it moves. Well clear of that rather than just above it. readTimeoutMs = 60000, ) { connection -> val body = connection.jsonObject() val at = body.getString("path") val size = body.optLong("size") val modified = body.optLong("modified") when (val kind = body.getString("kind")) { "text" -> FileContent.Text( at, size, modified, body.getString("sha256"), body.getString("content"), ) "binary" -> FileContent.Binary(at, size, modified) "tooBig" -> FileContent.TooBig(at, size, modified) // A backend that has learned a fifth answer. Reported rather than guessed at: picking // the nearest of the four would draw something confident about a state never seen. else -> throw ApiException( "The server described this file as \"$kind\", which this app does not know how to show." ) } } /** * Replaces a file's contents, but only while it still hashes to [ifSha256]. * * The refusal is a 409 carrying the server's wording, which is what the conflict dialog shows -- an * agent editing the same file while somebody reads it is the ordinary case here. */ fun writeFile( settings: ServerSettings, setup: String, path: String, content: String, ifSha256: String, ): Written = requestFromServer( settings, "/setups/${setup.urlEncoded()}/file", method = "PUT", jsonBody = JSONObject() .put("path", path) .put("content", content) .put("ifSha256", ifSha256) .toString(), readTimeoutMs = 60000, ) { connection -> val body = connection.jsonObject() Written(body.optLong("size"), body.optLong("modified"), body.getString("sha256")) } /** Creates an empty file. Refused, with the machine's own words, if the name is already taken. */ fun createFile(settings: ServerSettings, setup: String, path: String) { requestFromServer( settings, "/setups/${setup.urlEncoded()}/file", method = "POST", jsonBody = JSONObject().put("path", path).toString(), readTimeoutMs = 30000, ) {} } /** Creates a directory, with the same refusal as [createFile]. */ fun createDir(settings: ServerSettings, setup: String, path: String) { requestFromServer( settings, "/setups/${setup.urlEncoded()}/dir", method = "POST", jsonBody = JSONObject().put("path", path).toString(), readTimeoutMs = 30000, ) {} } fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray = requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) { it.inputStream.readBytes() } // One rate-limit window, rendered as a labeled bar on the usage screen. data class UsageWindow( /** * The API's own word for which window this is -- "session" for the five-hour one. The label * beside it is written for a person to read, so matching on it would select nothing the day its * wording changes. */ val kind: String, val label: String, val percent: Double, val resetsAt: String?, val active: Boolean, ) data class UsageSnapshot( val provider: String, /** Stable id of the machine these numbers belong to. */ val setup: String, /** That machine's current label. */ val setupName: String, /** * What came back: "ok", "notLoggedIn", "unreachable" or "failed". * * Four rather than a flag, because the screen has to treat them differently. "notLoggedIn" is a * machine somebody chose not to put an account on -- a fact, not a fault. Collapsing them made * a healthy setup read as broken. */ val state: String, /** Why, for the two states that are faults. Absent otherwise. */ val detail: String?, val windows: List, ) /** The backend caches; refreshing more often than its poll interval just re-reads the cache. */ fun fetchUsage(settings: ServerSettings): List = requestFromServer(settings, "/usage", readTimeoutMs = 30000) { connection -> connection.jsonObjects { snapshot -> UsageSnapshot( provider = snapshot.getString("provider"), setup = snapshot.optString("setup"), setupName = snapshot.optString("setupName"), // Unknown to an older backend, and unknown is not "fine": defaulting to "ok" would // draw an empty card as a healthy one. state = snapshot.optString("state").ifEmpty { "failed" }, detail = snapshot.optString("detail").ifEmpty { null }, windows = snapshot.getJSONArray("windows").mapObjects { window -> UsageWindow( kind = window.optString("kind").ifEmpty { "unknown" }, label = window.getString("label"), percent = window.getDouble("percent"), resetsAt = window.optString("resetsAt").ifEmpty { null }, active = window.getBoolean("active"), ) }, ) } } /** * Answers one question with everything that was chosen. * * A list even when one thing was picked, because that is the shape of the answer rather than a * special case of it. What a provider makes of several answers is decided on the server. */ fun answerQuestion( settings: ServerSettings, sessionId: String, questionId: String, answers: List, ) { requestFromServer( settings, "/sessions/$sessionId/answer", method = "POST", jsonBody = JSONObject() .put("questionId", questionId) .put("answers", JSONArray(answers)) .toString(), ) {} } fun interruptSession(settings: ServerSettings, sessionId: String) { requestFromServer(settings, "/sessions/$sessionId/interrupt", method = "POST") {} } /** * Ends the process behind a session, leaving the session and its transcript. * * Not a delete and not an interrupt: the conversation stays where it is and [startSession] picks it * back up. The server reports what it could not do rather than answering the same way either way. */ fun stopSession(settings: ServerSettings, sessionId: String) { requestFromServer(settings, "/sessions/$sessionId/stop", method = "POST") {} } /** Starts the process again on the conversation it left. See [stopSession]. */ fun startSession(settings: ServerSettings, sessionId: String) { requestFromServer(settings, "/sessions/$sessionId/start", method = "POST") {} } /** * Asks the machine to delete Claude Code sessions, and returns as soon as it has accepted the lot. * * The transcript *is* the session, so this ends any chance of resuming those conversations. The * caller confirms first; see ImportScreen. * * The work runs on the server, so this returning is not the same as it being done -- what says that * is each row's own state. That is the point: leaving the screen used to cancel the delete. * * One request for the whole batch, which is what makes a handover all-or-nothing. One per row meant * a batch could half-arrive, and the rows that were missed looked exactly like rows not picked. */ fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List) { requestFromServer( settings, "/setups/$setup/importable/delete", method = "POST", jsonBody = JSONObject().put("sessions", JSONArray(sessionIds)).toString(), ) {} } /** * Continues Claude Code sessions in the background, returning once the server has accepted them. * * Separate from [spawnSession] because the two are asked different questions. That one means "start * this and take me to it", so it waits and answers with the session. This is the import list's * batch: several at once, nobody waiting on any particular one, and the result arrives as a row * changing -- which is what lets the screen be left. */ fun startImport( settings: ServerSettings, setup: String, sessionIds: List, provider: String, permissionMode: String? = null, model: String? = null, ) { val body = JSONObject().apply { put("sessions", JSONArray(sessionIds)) put("provider", provider) permissionMode?.let { put("permissionMode", it) } model?.let { put("model", it) } } requestFromServer( settings, "/setups/$setup/importable/import", method = "POST", jsonBody = body.toString(), ) {} } /** * A page of a session's transcript, oldest first within the page. * * One request instead of one stream frame per event. The SSE stream is the right shape for live * events and the wrong one for a backlog: opening an imported session replayed hundreds of frames * before anything was readable, which looked exactly like the app loading top-down. * * [before] pages backwards for history somebody scrolls to; absent means the newest page. */ fun fetchTranscript( settings: ServerSettings, sessionId: String, before: Long? = null, limit: Int = 80, // Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of // a delta-heavy conversation is a page of the screen rather than a fraction of one message. The // scroll-back pager wants this; the anchor restore does not. Ignored by the server for the // newest window, where the live cursor needs real seqs. coalesce: Boolean = false, // Return nothing at or below this seq, stopping the page here instead of at [limit]. The phone // passes the end of the run it already holds cached, so a page never overlaps that copy -- an // overlap it cannot store, since a coalesced event cannot be cut inside its own delta run. after: Long? = null, ): List> { val query = buildString { append("?limit=").append(limit) if (before != null) append("&before=").append(before) if (coalesce) append("&coalesce=true") if (after != null) append("&after=").append(after) } return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection -> val body = JSONArray(connection.inputStream.bufferedReader().readText()) // The text as well as the event: the transcript cache stores the one and the fold needs the // other, and they have to be the same line. (0 until body.length()).map { val line = body.getJSONObject(it).toString() line to parseSeqEvent(line) } } } /** * Renames a session. * * The name is the backend's own -- it is what the list shows and it exists before any process does * -- so this settles it rather than asking. Where the thing running the session has a name of its * own, the backend passes it on, which is what makes a session the same session in Claude Code's * picker. */ fun renameSession(settings: ServerSettings, sessionId: String, title: String) { requestFromServer( settings, "/sessions/$sessionId/title", method = "POST", jsonBody = JSONObject().put("title", title).toString(), ) {} } /** Switches a running session's model; the CLI changes it in place. */ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String) { requestFromServer( settings, "/sessions/$sessionId/model", method = "POST", jsonBody = JSONObject().put("model", model).toString(), ) {} } /** * The permission modes the Claude CLI accepts, in the order they give up asking. "manual" asks for * everything (each ask arrives on the phone as a question card); the others are the CLI's own * escalating levels of autonomy. * * One list for every screen that offers them -- spawn, import, and the session's own picker -- * because three copies had already drifted: the import screen was missing "plan". */ val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan") /** Switches how much a running session asks before acting, also in place. */ fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) { requestFromServer( settings, "/sessions/$sessionId/permission-mode", method = "POST", jsonBody = JSONObject().put("mode", mode).toString(), ) {} } /** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */ fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) { requestFromServer( settings, "/sessions/$sessionId/notify", method = "POST", jsonBody = JSONObject().put("notify", notify).toString(), ) {} } /** * Asks the session to run one of its own commands. * * Sent as typed. The server turns the two it understands into its own operations -- a compaction, a * rename, which is also what the settings screen sends -- and passes anything else to whatever runs * the session. Either way it waits for the turn to end if one is in flight, and says so on the * event stream, which is where the waiting bubble comes from. */ fun runCommand(settings: ServerSettings, sessionId: String, text: String) { requestFromServer( settings, "/sessions/$sessionId/command", method = "POST", jsonBody = JSONObject().put("text", text).toString(), ) {} } /** * Asks the session to summarise its own history and carry on from the summary. * * Nothing comes back here: a compaction takes a minute or two, and what it is doing arrives on the * event stream like everything else -- a `compacting` status while it runs, then how much context * it recovered. A call that waited would be a second, worse account of the same thing. */ fun compactSession(settings: ServerSettings, sessionId: String) { requestFromServer(settings, "/sessions/$sessionId/compact", method = "POST") {} } /** * Removes a session, and optionally the machine's own transcript of the same conversation. * * [deleteForeign] is the delete this app cannot otherwise reach: Claude Code keeps its own record * under `~/.claude/projects`, and leaving it is what makes an ordinary delete recoverable. The * server does both halves, and does the unrecoverable one first, so a machine it cannot reach * leaves the session exactly where it was rather than half-deleted. */ fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Boolean = false) { val query = if (deleteForeign) "?deleteForeign=true" else "" requestFromServer(settings, "/sessions/$sessionId$query", method = "DELETE") {} } // Models: what this backend has downloaded, what it is downloading, and what HuggingFace offers. // Browsing is proxied by the server rather than done here, because this app trusts exactly one // certificate and has no general internet trust to spend on huggingface.co. data class LocalModel(val key: String, val repo: String, val file: String, val bytes: Long) /** * A download in flight or finished. [total] is null when the server never said how big the file is * -- which must render as "not known", never as a bar at some invented position. */ data class Download( val key: String, val run: Long, val repo: String, val file: String, val state: String, val done: Long, val total: Long?, val error: String?, ) data class Models(val local: List, val downloads: List) data class RemoteRepo(val id: String, val downloads: Long, val likes: Long) data class RemoteFile(val path: String, val bytes: Long, val have: Boolean) private fun parseDownload(o: JSONObject) = Download( key = o.getString("key"), run = o.getLong("run"), repo = o.getString("repo"), file = o.getString("file"), state = o.getString("state"), done = o.getLong("done"), // Absent rather than zero when unknown; see the field's comment. total = if (o.has("total")) o.getLong("total") else null, error = if (o.has("error")) o.getString("error") else null, ) fun fetchModels(settings: ServerSettings): Models = requestFromServer(settings, "/models") { connection -> val body = JSONObject(connection.inputStream.bufferedReader().readText()) Models( local = body.getJSONArray("local").mapObjects { m -> LocalModel( key = m.getString("key"), repo = m.getString("repo"), file = m.getString("file"), bytes = m.getLong("bytes"), ) }, downloads = body.getJSONArray("downloads").mapObjects(::parseDownload), ) } fun searchModels(settings: ServerSettings, query: String): List = requestFromServer(settings, "/models/search?q=${query.urlEncoded()}") { connection -> connection.jsonObjects { r -> RemoteRepo( id = r.getString("id"), downloads = r.getLong("downloads"), likes = r.getLong("likes"), ) } } fun fetchRepoFiles(settings: ServerSettings, repo: String): List = requestFromServer(settings, "/models/files?repo=${repo.urlEncoded()}") { connection -> connection.jsonObjects { f -> RemoteFile( path = f.getString("path"), bytes = f.getLong("bytes"), have = f.getBoolean("have"), ) } } fun startDownload(settings: ServerSettings, repo: String, file: String): Download = requestFromServer( settings, "/models/download", method = "POST", jsonBody = JSONObject().put("repo", repo).put("file", file).toString(), ) { connection -> parseDownload(JSONObject(connection.inputStream.bufferedReader().readText())) } fun cancelDownload(settings: ServerSettings, key: String) { requestFromServer( settings, "/models/cancel", method = "POST", jsonBody = JSONObject().put("key", key).toString(), ) {} } fun deleteModel(settings: ServerSettings, key: String) { requestFromServer( settings, "/models/delete", method = "POST", jsonBody = JSONObject().put("key", key).toString(), ) {} }