The same pass the server had, on the Kotlin side: comments restating what the code says are gone, and the ones recording a measurement, a constraint or an incident are kept but cut to a few lines each. 6540 comment lines to 5674, and 920 lines off the app. Two doc comments had drifted onto the item above the one they describe -- `contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s equivalent on the server was fixed in the previous commit. Each is back on its own item, which is the only non-comment line this diff moves. The comments are reflowed to the column limit at their own indentation: several were written wide, and ktfmt re-wrapped them into lines holding a single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the result, which is the check. Left alone deliberately: this codebase's remaining comment density is high because the comments carry things the code cannot say -- what a null means, what a number was measured against, which bug a guard exists for. Of the 238 one-line doc comments in the app, five were pure restatement of the name and were removed; the rest each say something the signature does not. ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass; cargo test (127), clippy --all-targets and fmt still clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1143 lines
44 KiB
Kotlin
1143 lines
44 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 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 <T> 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<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()
|
|
}
|
|
}
|
|
|
|
private fun HttpURLConnection.jsonObject(): JSONObject =
|
|
JSONObject(inputStream.bufferedReader().readText())
|
|
|
|
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) }
|
|
|
|
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<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 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<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.
|
|
*/
|
|
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<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.
|
|
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<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 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<DirEntry>)
|
|
|
|
/**
|
|
* 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<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 decided on the server.
|
|
*/
|
|
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 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<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 -- which is what lets the screen be left.
|
|
*/
|
|
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.
|
|
*
|
|
* [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<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.
|
|
(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<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(),
|
|
) {}
|
|
}
|