Reopening a session downloaded the conversation again, every time, over the tunnel. It now draws from a copy of what the server has already sent and asks for one event to check that copy is still current. Per session, under cacheDir, the server's own event lines in chunks named for the range they cover -- so a coalesced page, whose lines do not say what they cover, still records it. Only the contiguous run ending at the newest chunk is served; a gap is closed by paging through it, bounded by `after` on /transcript so the page stops where the phone's copy starts and can therefore be kept. Nothing is derived and stored: rows are a rendering, and a cache of them would need throwing away on every change to the fold. Nothing here is load-bearing. Missing, evicted, damaged or unwritable all degrade to the cold open this screen did before, and the check before the stream resumes -- one request, one event -- is what stops a replaced or truncated file being spliced onto a copy of a different conversation. What that check cannot see, a line changed mid-file with the tail intact, is what Reload in session settings is for. Measured on the emulator against ui-sandbox, on a 505-event session: reopening it costs one request for one event, including scrolling the whole conversation back; a cold open is two requests and 100 events. A reset after falling 300 behind fetched the gap as four coalesced rows rather than re-fetching 104 events and discarding them. Every chunk was checked line by line against what the server says for the range its name claims, across the reset and the gap-fill. transcript-bench.sh, same viewport content and gestures, before and after: p50 16.9ms both, p90 25.6 -> 23.2ms, p99 33.5 -> 36.7ms, and the transcript's own draw accounting 0.33ms -> 0.32ms with place 0.31ms either way. Within the emulator's noise, which is what a cache must be: it changes what is fetched, not what is drawn. Building it also found that the server handed out the same transcript line two different ways. serde_json's default float parser is not correctly rounded, so a ts written as ...0757 came back from /transcript as ...0755 while the SSE stream sent the original -- invisible on screen, since a ts is drawn as a relative time, and visible here only because the cache compares a line it holds against the server's answer. Fixed with float_roundtrip, with a test that fails the moment it is dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1007 lines
40 KiB
Kotlin
1007 lines
40 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
|
|
|
|
class ApiException(message: String, 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
|
|
}
|
|
)
|
|
}
|
|
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)?",
|
|
e,
|
|
)
|
|
} catch (e: Exception) {
|
|
throw ApiException(
|
|
"Reached ${settings.baseUrl}$path but couldn't read its response " +
|
|
"(${e::class.simpleName}: ${e.message})",
|
|
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")
|
|
}
|
|
}
|
|
|
|
/** 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(),
|
|
) {}
|
|
}
|