Three readings that were each a part presented as the whole.
**"just now", everywhere, after a restart.** A relaunched session took its
last-activity from the clock, so every session the backend brought back
claimed to have been active that instant. On the phone that is every row
reading "just now" and the list -- which sorts by it -- coming back in an
order that means nothing, with the conversation somebody was in the middle
of buried among sessions untouched for days. It comes from the transcript
now, in the pass `Transcript::open` already makes, which is the same
correction `last_status` got and for the same reason: a server that has just
started has been told nothing, and the file is the only thing it knows. The
test backdates a transcript by a day, so it cannot pass by the test being
fast; it fails on the old code with the clock's answer in the message.
**The token total was the newest page's.** The phone added up the
`UsageDelta`s it had received, and it opens a session on the newest page of
the transcript -- so a long conversation reported its last few turns as the
total, and a page with no turn in it reported nothing at all, since zero is
drawn as blank. That is the reading Bryan saw: no tokens, on sessions that
had certainly spent some.
The count belongs to the server, which is the only side that sees every
turn. `UsageDelta` now carries the running total beside the delta, filled in
by the pump rather than by each driver -- a driver knows what its own turn
cost and nothing else does, so a new one cannot get this wrong by leaving it
out -- and the session row reports it for a screen that has not opened the
stream yet. The phone takes the largest total it has seen instead of
accumulating, which also means paging older history cannot move it, and
leaves the seeded figure alone for transcripts recorded before the field
existed. Seeded by summing deltas at startup for exactly that reason.
**The header said the model twice and the machine backwards.** A session's
subtitle now reads `machine · provider`, in that order and with no "on"
joining them, matching the list and the usage dialog -- the "on" made it a
phrase, which works in one order and stops working the moment the same pair
is shown somewhere else. The model is gone from it: the footer's picker
already shows what the session is set to, and two places showing it meant
two things to keep in step, which disagreed for a moment on every switch
since one follows the request and the other the session's own answer.
Checked on the emulator against a twelve-turn session whose visible page
held the last six: the header reads "this machine · echo", the status row
reads "idle", and the total reads 42 tok, which is what `GET
/sessions/{id}` says rather than what the page adds up to.
765 lines
29 KiB
Kotlin
765 lines
29 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,
|
|
/** Raw request body as content-type to bytes -- the upload path. */
|
|
binaryBody: Pair<String, ByteArray>? = 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 (binaryBody != null) {
|
|
connection.doOutput = true
|
|
connection.setRequestProperty("Content-Type", binaryBody.first)
|
|
connection.outputStream.use { it.write(binaryBody.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,
|
|
/**
|
|
* Every token this session has spent, as the server counts it -- see `SessionEvent.UsageDelta`.
|
|
*/
|
|
val totalTokens: Long,
|
|
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),
|
|
totalTokens = session.optLong("totalTokens", 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,
|
|
)
|
|
|
|
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
|
|
requestFromServer(settings, "/setups/$setup/importable") {
|
|
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),
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
)
|
|
|
|
private fun SshDetails.toJson() =
|
|
JSONObject().put("address", address).apply {
|
|
if (port != null) put("port", port)
|
|
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
|
|
}
|
|
|
|
/** 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(),
|
|
) {}
|
|
}
|
|
|
|
/** Uploads one picked image; the returned id goes into [sendMessage]. */
|
|
fun uploadAttachment(
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
bytes: ByteArray,
|
|
mime: String,
|
|
): String {
|
|
val boundary = "----aiapp-${System.currentTimeMillis()}"
|
|
val head =
|
|
("--$boundary\r\n" +
|
|
"Content-Disposition: form-data; name=\"file\"; filename=\"image\"\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",
|
|
binaryBody = "multipart/form-data; boundary=$boundary" to (head + bytes + tail),
|
|
readTimeoutMs = 60000,
|
|
) { 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") {}
|
|
}
|
|
|
|
/**
|
|
* Removes a Claude Code session from the machine.
|
|
*
|
|
* The transcript *is* the session, so this ends any chance of resuming that conversation. The
|
|
* caller confirms first; see ImportScreen.
|
|
*/
|
|
fun deleteImportable(settings: ServerSettings, setup: String, sessionId: String) {
|
|
requestFromServer(settings, "/setups/$setup/importable/$sessionId", method = "DELETE") {}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
): List<SeqEvent> {
|
|
val query = buildString {
|
|
append("?limit=").append(limit)
|
|
if (before != null) append("&before=").append(before)
|
|
}
|
|
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection ->
|
|
val body = JSONArray(connection.inputStream.bufferedReader().readText())
|
|
(0 until body.length()).map { parseSeqEvent(body.getJSONObject(it).toString()) }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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(),
|
|
) {}
|
|
}
|
|
|
|
/** 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") {}
|
|
}
|
|
|
|
fun deleteSession(settings: ServerSettings, sessionId: String) {
|
|
requestFromServer(settings, "/sessions/$sessionId", 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(),
|
|
) {}
|
|
}
|