Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt
T
iris e3e02d55f7 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt
2026-09-04 15:03:37 -04:00

1188 lines
47 KiB
Kotlin

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 on failure,
// 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 -- those messages are written to be read on the screen that made the call.
*
* [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", where every
* other refusal is a message to show. 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 to read from.
*
* @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 (see EventStream.kt).
*/
fun <T> requestFromServer(
settings: ServerSettings,
path: String,
method: String = "GET",
jsonBody: String? = null,
/**
* A request body written as it is produced -- the upload path. Content type, and a writer
* handed the connection's stream. 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 on this side.
*/
streamBody: Pair<String, (java.io.OutputStream) -> 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()
}
}
/** The response body as one JSON object. */
private fun HttpURLConnection.jsonObject(): JSONObject =
JSONObject(inputStream.bufferedReader().readText())
/** The response body as a JSON array of objects, each mapped through [parse]. */
private fun <T> HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List<T> =
JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse)
private fun <T> JSONArray.mapObjects(parse: (JSONObject) -> T): List<T> =
(0 until length()).map { parse(getJSONObject(it)) }
private fun JSONArray.strings(): List<String> = (0 until length()).map { getString(it) }
/** Percent-encodes a value going into a query string. */
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 for the header's five-hour bar.
*
* It was deliberately left out until 2026-08-29, on the grounds that nothing here addressed a
* setup and holding both the id and the name invited showing the wrong one, which had already
* happened once. Something addresses one now, so the reason lapsed rather than being overruled.
* The guard that replaces it is the rule below: never show this.
*/
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 of the conversation 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 -- this app's
* transcript holds things that record does not.
*/
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. Defaults to
* on when a backend is too old to say, which matches what that backend actually does.
*/
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, and what the
* process then starts in belongs to whatever launches it. 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 -- see
* `SessionEvent.UsageDelta`.
*
* 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
* nearly 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: a provider that
* does not care about size should not be given a threshold this app invented. Decided by the
* server because that is where a provider's kind is known -- see `uploadPickedImage`.
*/
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<SessionSummary> =
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 switch drawn from a stale row shows a position that may have been
* changed since -- here or on another device -- and nothing on screen says which.
*/
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<String>)
/**
* 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<Provider>,
)
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<Setup> =
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 -- the same rule that keeps a provider's command out of this client.
*/
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.
*
* The number that predicts what continuing this session costs. 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, and a session that cannot be checked is not a session that is free. 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
* when nothing is.
*
* The server's answer rather than the phone's, because the work outlives the screen that asked
* for it: leaving the import list and coming back has to show what is still running, and a
* phone that was asleep or out of range 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 that was 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, and that figure grows with every session
* anybody has. A timeout is for a server that has stopped answering, so it is set well clear of how
* long the work takes rather than just above it.
*/
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
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, so
// it stays null and the row simply does not claim a figure.
contextTokens =
if (session.isNull("contextTokens")) null
else session.optLong("contextTokens").takeIf { it > 0L },
// Absent means an older backend that cannot answer, which is exactly what
// "unknown" says -- so the default is the honest one rather than "no".
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<Provider> =
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<String, String> = 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<String, Any>()))
}
}
.toString(),
readTimeoutMs = 30000,
) { connection ->
parseSession(connection.jsonObject())
}
fun sendMessage(
settings: ServerSettings,
sessionId: String,
text: String,
attachmentIds: List<String> = 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 is the success case and it arrives on the event stream, not from
* here -- every device drops it, not only the one that tapped.
*/
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 on that machine and refuses if it is not -- a mistyped
* path accepted here would surface much later, as a session that would not start, with nothing
* pointing at the typo.
*
* 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, which is this app's rule for a
* session with no process everywhere else.
*/
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, because it is the
* only thing that can.
*/
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<DirEntry>)
/**
* What reading a file produced.
*
* Four cases, because they are four different things to draw and none of them is an error the
* screen can shrug off: content, something that is not text, something too big to have sent, and
* (as [ApiException], not a case here) the machine's own refusal. A file with nothing in it is
* [FileContent.Text] with an empty string -- which is what it is, and not the same as any of these.
*/
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 -- a timeout is for a
// server that has stopped answering.
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 this app has
// 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 and arrives as an [ApiException] 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, not the exotic one.
*/
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,
) {}
}
/** Fetches an image the transcript references (produced or uploaded). */
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.
*
* How to find a particular window. 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 -- while the other two
* are faults worth chasing. 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<UsageWindow>,
)
/** The backend caches; refreshing more often than its poll interval just re-reads the cache. */
fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
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 its own business and is decided
* on the server; nothing here joins, splits or reformats them for one.
*/
fun answerQuestion(
settings: ServerSettings,
sessionId: String,
questionId: String,
answers: List<String>,
) {
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 exactly where it is and [startSession]
* picks it back up. The server reports what it could not do -- there was nothing running, or the
* machine would not say whether there was -- 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, through [fetchImportable] and the change stream. That is the point:
* leaving the screen used to cancel the delete it had started.
*
* One request for the whole batch, which is what makes a handover all-or-nothing. Sending one per
* row meant a batch could half-arrive -- four deleted, two never asked for -- and the two that were
* missed looked exactly like two that had not been picked.
*/
fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<String>) {
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 rather than as a reply -- which is what lets the screen be left. One request for all of
* them, for the reason [deleteImportable] gives.
*/
fun startImport(
settings: ServerSettings,
setup: String,
sessionIds: List<String>,
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, because it was.
*
* [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 (it counts events to a known
// seq). Ignored by the server for the newest window, where the live cursor needs real seqs.
// See the server's `read_window`.
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 at a seq inside its own
// delta run. Exclusive, like the SSE route's cursor. See TranscriptCache and `read_window`.
after: Long? = null,
): List<Pair<String, SeqEvent>> {
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 -- a second entry point differing only in
// return type would be two answers to one question.
(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 and to any other agent that lists it.
*/
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<LocalModel>, val downloads: List<Download>)
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<RemoteRepo> =
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<RemoteFile> =
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(),
) {}
}