Nothing sorts the sessions tab any more. The order is the server's `sessions` list, which is the reader's arrangement: holding a row puts the screen in selection mode -- the same gesture and the same bottom bar as the import tab -- and each card grows a burger handle at its right edge that drags the row to a new place, with a tick of haptic feedback for each one it passes. The two attempts this replaces, sorting by activity and then by when each agent was turned on, were both looking for an order a session could not move itself out of; no rule computed from what a session is doing can be one. `POST /sessions/order` rewrites the config's order, so it is the same on every device and survives a backend restart, and `SessionConfig::started` goes with the sort that needed it. Rearranging is independent of the selection: the handle moves the row it is on, picked out or not. The click moved off the card and onto its contents so that a press landing on the handle cannot also select the row it is about to move. Selection's one action is Delete, which now takes the whole set. Two traps in `Reorder.kt`, both measured on the emulator and written down in `this-machine-android`: a crossing is decided from how far the finger has travelled, because a lazy list animates an item into its new place and its `offset` reports the old one for several frames; and the viewport is pinned with `requestScrollToItem` around each move, because a lazy list keeps its place by the key of the top item and would otherwise follow the row being dragged. Verified on the emulator against the sandbox: the order survives an app restart and a backend read-back, a two-row drag moves exactly two rows, a drag to the bottom edge scrolls the list and lands the row last, pressing the handle without moving changes nothing, and deleting two selected sessions leaves the rest in place.
1749 lines
68 KiB
Kotlin
1749 lines
68 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 machine, 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; [machineName] is what
|
|
* a reader sees, and holding both invites showing the wrong one.
|
|
*/
|
|
val machine: String,
|
|
/** The machine's current label. This is the one to display; [machine] is never shown. */
|
|
val machineName: 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,
|
|
/** Product whose durable transcript survives an ordinary app deletion. */
|
|
val ownTranscriptName: String?,
|
|
/** How much the session asks before acting; null when it was never set. */
|
|
val permissionMode: String?,
|
|
/**
|
|
* How hard the model thinks, or null for the CLI's own default.
|
|
*
|
|
* Null is a level somebody can choose, not only one to start in -- see [EFFORT_LEVELS]. It is
|
|
* reported rather than assumed for the same reason [permissionMode] is.
|
|
*/
|
|
val effort: String?,
|
|
/**
|
|
* Whether a thinking level does anything here -- a coding CLI session, not llama or echo.
|
|
*
|
|
* Asked of the server rather than worked out from the provider's name, because this is a
|
|
* property of the driver's *kind* and the phone only has the name.
|
|
*/
|
|
val takesEffort: Boolean,
|
|
/**
|
|
* 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,
|
|
/**
|
|
* Whether this session sends itself a message once its account's usage limit lifts, and what
|
|
* that message says.
|
|
*
|
|
* The message is what the server would actually send, with its own default already filled in,
|
|
* so the field shows the words rather than an empty box standing for them.
|
|
*/
|
|
val autoResume: Boolean,
|
|
val autoResumeMessage: String,
|
|
/**
|
|
* When the server next intends to check whether the limit has lifted, in epoch seconds, or null
|
|
* when nothing is waiting.
|
|
*
|
|
* A time to *ask*, not a time to resume: the server checks the meter at that moment and waits
|
|
* again if the limit is still on. Worded that way wherever it is shown, because a promise this
|
|
* app cannot keep is worse than no time at all.
|
|
*/
|
|
val resumeAt: Double?,
|
|
/**
|
|
* 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?,
|
|
/**
|
|
* What [contextTokens] is out of, or null where this session's provider does not say. A third
|
|
* state, not a fourth reading of the same one: the occupancy is known and the ceiling is not.
|
|
*/
|
|
val contextLimit: Long?,
|
|
/** What this session's provider settings are set to; empty where it takes none. */
|
|
val params: Map<String, String>,
|
|
/**
|
|
* 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?,
|
|
/**
|
|
* Which of `GET /usage`'s snapshots is about this session, and null where nothing meters it.
|
|
*
|
|
* The rate-limit bar answers a question about an *account*, and what decides which account --
|
|
* if any -- is the provider this session runs, not the machine it runs on. Pairing by machine
|
|
* alone drew the Claude CLI's five-hour window under every echo session on a machine that also
|
|
* has the CLI: a quota that session cannot spend and could never run down. Decided by the
|
|
* server for the same reason [maxImageEdge] is -- it is a fact about the provider's kind, and
|
|
* this app has only its name.
|
|
*/
|
|
val usageProvider: String?,
|
|
val status: String,
|
|
val lastActivity: Double,
|
|
/** Latest measured number of live background tasks; zero also covers older servers. */
|
|
val backgroundTasks: Int,
|
|
)
|
|
|
|
private fun parseSession(session: JSONObject) =
|
|
SessionSummary(
|
|
id = session.getString("id"),
|
|
machine = session.getString("machine"),
|
|
keepsOwnTranscript = session.optBoolean("keepsOwnTranscript", false),
|
|
ownTranscriptName = session.optString("ownTranscriptName").ifEmpty { null },
|
|
machineName = session.getString("machineName"),
|
|
provider = session.getString("provider"),
|
|
title = session.getString("title"),
|
|
model = session.optString("model").ifEmpty { null },
|
|
permissionMode = session.optString("permissionMode").ifEmpty { null },
|
|
effort = session.optString("effort").ifEmpty { null },
|
|
takesEffort = session.optBoolean("takesEffort", false),
|
|
imported = session.optBoolean("imported", false),
|
|
notify = session.optBoolean("notify", true),
|
|
autoResume = session.optBoolean("autoResume", false),
|
|
// The server sends its own default rather than nothing, so an empty answer means an older
|
|
// server -- and this app's word for it is the same word.
|
|
autoResumeMessage =
|
|
session.optString("autoResumeMessage").ifEmpty { DEFAULT_RESUME_MESSAGE },
|
|
resumeAt = if (session.has("resumeAt")) session.getDouble("resumeAt") else null,
|
|
cwd = session.optString("cwd").ifEmpty { null },
|
|
contextTokens =
|
|
if (session.has("contextTokens")) session.getLong("contextTokens") else null,
|
|
contextLimit = if (session.has("contextLimit")) session.getLong("contextLimit") else null,
|
|
params = session.optJSONObject("params").stringMap(),
|
|
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
|
|
usageProvider = session.optString("usageProvider").ifEmpty { null },
|
|
status = session.getString("status"),
|
|
lastActivity = session.getDouble("lastActivity"),
|
|
backgroundTasks = session.optInt("backgroundTasks", 0),
|
|
)
|
|
|
|
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()) }
|
|
|
|
/**
|
|
* One row of `GET /sessions/{id}/subagents`, oldest first.
|
|
*
|
|
* A subagent is a second transcript owned by a session -- no process, no controls of its own -- so
|
|
* this carries only what a card needs to draw and to open it; see SUBAGENTS.md. [status] is
|
|
* "running", "exited" or "unknown": a subagent whose session is not itself running cannot be
|
|
* running, and the list says so rather than reporting a state that cannot hold.
|
|
*/
|
|
data class SubagentSummary(
|
|
val id: String,
|
|
val title: String,
|
|
val status: String,
|
|
val created: Double,
|
|
val lastActivity: Double,
|
|
)
|
|
|
|
/**
|
|
* One thing a session has running while it is free to do something else: a backgrounded command, a
|
|
* subagent, whatever else its provider can leave going. See `GET /sessions/{id}/background`.
|
|
*/
|
|
data class BackgroundTaskSummary(
|
|
/** The provider's own id. Never shown -- it is what keys the list and matches two snapshots. */
|
|
val id: String,
|
|
/**
|
|
* What it is doing, in the provider's own words. Null where it names a task by nothing a reader
|
|
* would recognise -- Codex reports a process id -- and the card says so instead.
|
|
*/
|
|
val description: String?,
|
|
/** `agent`, `command`, `workflow`, or `other` for a kind this build has not heard of. */
|
|
val kind: String,
|
|
)
|
|
|
|
/**
|
|
* What [sessionId] has running in the background right now.
|
|
*
|
|
* Null is "its provider has not said", which a stopped session and an old backend both answer, and
|
|
* is a different thing from the empty list.
|
|
*/
|
|
fun fetchBackgroundTasks(
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
): List<BackgroundTaskSummary>? =
|
|
requestFromServer(settings, "/sessions/$sessionId/background") { connection ->
|
|
val body = connection.inputStream.bufferedReader().readText()
|
|
if (body.trim() == "null") null
|
|
else
|
|
JSONArray(body).mapObjects { row ->
|
|
BackgroundTaskSummary(
|
|
id = row.getString("id"),
|
|
description =
|
|
if (row.isNull("description")) null else row.getString("description"),
|
|
kind = row.getString("kind"),
|
|
)
|
|
}
|
|
}
|
|
|
|
fun fetchSubagents(settings: ServerSettings, sessionId: String): List<SubagentSummary> =
|
|
requestFromServer(settings, "/sessions/$sessionId/subagents") {
|
|
it.jsonObjects { row ->
|
|
SubagentSummary(
|
|
id = row.getString("id"),
|
|
title = row.getString("title"),
|
|
status = row.getString("status"),
|
|
created = row.getDouble("created"),
|
|
lastActivity = row.getDouble("lastActivity"),
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes finished subagents from their session, transcripts and all.
|
|
*
|
|
* All or nothing, and only for subagents that have finished: the server checks every id before it
|
|
* removes any, so a batch naming one that is still running leaves the whole selection as it was.
|
|
* One request for the batch, for the same reason [deleteImportable] is one -- sent row by row, a
|
|
* batch could half-arrive and the rows that were missed would look exactly like rows nobody picked.
|
|
*
|
|
* Unlike an import delete this is local file removal and is done by the time it returns, so there
|
|
* is no per-row state to follow afterwards.
|
|
*/
|
|
fun deleteSubagents(settings: ServerSettings, sessionId: String, subagentIds: List<String>) {
|
|
requestFromServer(
|
|
settings,
|
|
"/sessions/$sessionId/subagents/delete",
|
|
method = "POST",
|
|
jsonBody = JSONObject().put("subagents", JSONArray(subagentIds)).toString(),
|
|
) {}
|
|
}
|
|
|
|
// What the server offers, so the spawn screen has no hardcoded lists: a machine 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,
|
|
/** The program discovery found on that machine, or null for a provider that is not one. */
|
|
val command: String?,
|
|
val models: List<String>,
|
|
val permissionModes: List<String>,
|
|
val defaultPermissionMode: String?,
|
|
/** The extra settings this provider takes, in the order to draw them. See [ParamSpec]. */
|
|
val params: List<ParamSpec>,
|
|
)
|
|
|
|
/**
|
|
* One setting a provider takes, as the server describes it.
|
|
*
|
|
* Declared by the server rather than drawn here, so a driver that grows a setting gets a control
|
|
* with no app change — and, more to the point, so that a value measured on one machine can ship as
|
|
* a default without becoming a constant nobody else can reach.
|
|
*/
|
|
data class ParamSpec(
|
|
val key: String,
|
|
val label: String,
|
|
/** What leaving it blank means, in words, shown as the placeholder. */
|
|
val unset: String,
|
|
val kind: String,
|
|
/** For [kind] `"choice"`: the options, the first of which means "unset". */
|
|
val options: List<String>,
|
|
/** Whether a change waits for the session's process to start again. */
|
|
val restart: Boolean,
|
|
)
|
|
|
|
/**
|
|
* 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 machine uses the id and everything a person reads uses the name.
|
|
*/
|
|
data class Machine(
|
|
val id: String,
|
|
val name: String,
|
|
val address: String?,
|
|
val providers: List<Provider>,
|
|
)
|
|
|
|
/** A JSON object of strings, and the empty map for one that is absent. */
|
|
private fun JSONObject?.stringMap(): Map<String, String> =
|
|
this?.let { object_ -> object_.keys().asSequence().associateWith { object_.getString(it) } }
|
|
?: emptyMap()
|
|
|
|
private fun parseProvider(provider: JSONObject): Provider {
|
|
val kind = provider.getString("kind")
|
|
return Provider(
|
|
name = provider.getString("name"),
|
|
kind = kind,
|
|
command = provider.optString("command").ifEmpty { null },
|
|
// Omitted entirely when the provider offers none.
|
|
models = provider.optJSONArray("models")?.strings().orEmpty(),
|
|
permissionModes = provider.optJSONArray("permissionModes")?.strings().orEmpty(),
|
|
defaultPermissionMode = provider.optString("defaultPermissionMode").ifEmpty { null },
|
|
params = provider.optJSONArray("params")?.mapObjects(::parseParamSpec) ?: emptyList(),
|
|
)
|
|
}
|
|
|
|
private fun parseParamSpec(spec: JSONObject) =
|
|
ParamSpec(
|
|
key = spec.getString("key"),
|
|
label = spec.getString("label"),
|
|
unset = spec.getString("unset"),
|
|
kind = spec.getString("kind"),
|
|
options = spec.optJSONArray("options")?.strings().orEmpty(),
|
|
restart = spec.optBoolean("restart"),
|
|
)
|
|
|
|
private fun parseMachine(machine: JSONObject) =
|
|
Machine(
|
|
id = machine.getString("id"),
|
|
name = machine.getString("name"),
|
|
address = machine.optString("address").ifEmpty { null },
|
|
providers = machine.getJSONArray("providers").mapObjects(::parseProvider),
|
|
)
|
|
|
|
fun fetchMachines(settings: ServerSettings): List<Machine> =
|
|
requestFromServer(settings, "/machines") { it.jsonObjects(::parseMachine) }
|
|
|
|
/** One CLI-owned provider sign-in. The browser URL and pasted code are never persisted. */
|
|
data class ProviderLogin(
|
|
val attempt: String,
|
|
val state: String,
|
|
val authorizationUrl: String?,
|
|
val detail: String?,
|
|
)
|
|
|
|
private fun parseProviderLogin(login: JSONObject) =
|
|
ProviderLogin(
|
|
attempt = login.getString("attempt"),
|
|
state = login.getString("state"),
|
|
authorizationUrl = login.optString("authorizationUrl").ifEmpty { null },
|
|
detail = login.optString("detail").ifEmpty { null },
|
|
)
|
|
|
|
private fun providerLoginPath(machine: String, provider: String) =
|
|
"/machines/${machine.urlEncoded()}/providers/${provider.urlEncoded()}/auth"
|
|
|
|
fun startProviderLogin(
|
|
settings: ServerSettings,
|
|
machine: String,
|
|
provider: String,
|
|
): ProviderLogin =
|
|
requestFromServer(
|
|
settings,
|
|
providerLoginPath(machine, provider),
|
|
method = "POST",
|
|
readTimeoutMs = 25_000,
|
|
) {
|
|
parseProviderLogin(it.jsonObject())
|
|
}
|
|
|
|
fun fetchProviderLogin(
|
|
settings: ServerSettings,
|
|
machine: String,
|
|
provider: String,
|
|
attempt: String,
|
|
): ProviderLogin =
|
|
requestFromServer(
|
|
settings,
|
|
"${providerLoginPath(machine, provider)}/${attempt.urlEncoded()}",
|
|
) {
|
|
parseProviderLogin(it.jsonObject())
|
|
}
|
|
|
|
fun submitProviderLoginCode(
|
|
settings: ServerSettings,
|
|
machine: String,
|
|
provider: String,
|
|
attempt: String,
|
|
code: String,
|
|
): ProviderLogin =
|
|
requestFromServer(
|
|
settings,
|
|
"${providerLoginPath(machine, provider)}/${attempt.urlEncoded()}/code",
|
|
method = "POST",
|
|
jsonBody = JSONObject().put("code", code).toString(),
|
|
) {
|
|
parseProviderLogin(it.jsonObject())
|
|
}
|
|
|
|
fun cancelProviderLogin(
|
|
settings: ServerSettings,
|
|
machine: String,
|
|
provider: String,
|
|
attempt: String,
|
|
) {
|
|
requestFromServer(
|
|
settings,
|
|
"${providerLoginPath(machine, provider)}/${attempt.urlEncoded()}",
|
|
method = "DELETE",
|
|
) {}
|
|
}
|
|
|
|
/**
|
|
* 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?,
|
|
/**
|
|
* What [contextTokens] is out of, or null where this session's provider does not say. A third
|
|
* state, not a fourth reading of the same one: the occupancy is known and the ceiling is not.
|
|
*/
|
|
val contextLimit: Long?,
|
|
/** What this session's provider settings are set to; empty where it takes none. */
|
|
val params: Map<String, String>,
|
|
/** 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 /machines/{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, machine: String): List<Importable> =
|
|
requestFromServer(settings, "/machines/$machine/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 },
|
|
contextLimit =
|
|
if (session.isNull("contextLimit")) null
|
|
else session.optLong("contextLimit").takeIf { it > 0L },
|
|
params = session.optJSONObject("params").stringMap(),
|
|
// 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,
|
|
/**
|
|
* Where that machine keeps its GGUF models; null for the same place the backend keeps its own
|
|
* (`~/.local/share/ai-app/models`, read on that machine). A llama.cpp session serves the file
|
|
* from the machine it runs on, so this is where its models are looked for and listed.
|
|
*/
|
|
val modelsDir: 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)
|
|
if (!modelsDir.isNullOrBlank()) put("modelsDir", modelsDir)
|
|
}
|
|
|
|
/** What a machine turns out to have, without saving anything. */
|
|
fun probeMachine(settings: ServerSettings, ssh: SshDetails?): List<Provider> =
|
|
requestFromServer(
|
|
settings,
|
|
"/machines/probe",
|
|
method = "POST",
|
|
jsonBody = JSONObject().apply { if (ssh != null) put("ssh", ssh.toJson()) }.toString(),
|
|
readTimeoutMs = 40000,
|
|
) {
|
|
it.jsonObjects(::parseProvider)
|
|
}
|
|
|
|
fun addMachine(settings: ServerSettings, name: String, ssh: SshDetails?): Machine =
|
|
requestFromServer(
|
|
settings,
|
|
"/machines",
|
|
method = "POST",
|
|
jsonBody =
|
|
JSONObject()
|
|
.put("name", name)
|
|
.apply { if (ssh != null) put("ssh", ssh.toJson()) }
|
|
.toString(),
|
|
readTimeoutMs = 40000,
|
|
) {
|
|
parseMachine(it.jsonObject())
|
|
}
|
|
|
|
/** Renames a machine, and optionally asks it again what it has. */
|
|
fun updateMachine(
|
|
settings: ServerSettings,
|
|
id: String,
|
|
name: String? = null,
|
|
rediscover: Boolean = false,
|
|
): Machine =
|
|
requestFromServer(
|
|
settings,
|
|
"/machines/${id.urlEncoded()}",
|
|
method = "PUT",
|
|
jsonBody =
|
|
JSONObject()
|
|
.apply {
|
|
if (name != null) put("name", name)
|
|
if (rediscover) put("rediscover", true)
|
|
}
|
|
.toString(),
|
|
readTimeoutMs = 40000,
|
|
) {
|
|
parseMachine(it.jsonObject())
|
|
}
|
|
|
|
fun deleteMachine(settings: ServerSettings, id: String) {
|
|
requestFromServer(settings, "/machines/${id.urlEncoded()}", method = "DELETE") {}
|
|
}
|
|
|
|
/**
|
|
* Spawns a session and returns it as the list would show it. [machine] names the machine and
|
|
* [provider] one of the things that machine offers.
|
|
*/
|
|
fun spawnSession(
|
|
settings: ServerSettings,
|
|
machine: String,
|
|
provider: String,
|
|
title: String,
|
|
model: String? = null,
|
|
cwd: String? = null,
|
|
permissionMode: String? = null,
|
|
/** Null for whatever the server's default is; see [fetchDefaultEffort]. */
|
|
effort: 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("machine", machine)
|
|
.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 (!effort.isNullOrBlank()) put("effort", effort)
|
|
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 a configured machine.
|
|
*
|
|
* [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 [machine] names, and what [path] resolved to. */
|
|
fun fetchDir(settings: ServerSettings, machine: String, path: String): Listing =
|
|
requestFromServer(
|
|
settings,
|
|
"/machines/${machine.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, machine: String, path: String): FileContent =
|
|
requestFromServer(
|
|
settings,
|
|
"/machines/${machine.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,
|
|
machine: String,
|
|
path: String,
|
|
content: String,
|
|
ifSha256: String,
|
|
): Written =
|
|
requestFromServer(
|
|
settings,
|
|
"/machines/${machine.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, machine: String, path: String) {
|
|
requestFromServer(
|
|
settings,
|
|
"/machines/${machine.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, machine: String, path: String) {
|
|
requestFromServer(
|
|
settings,
|
|
"/machines/${machine.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,
|
|
/** Length of this cycle, when the provider reported it. */
|
|
val durationMinutes: Long?,
|
|
val resetsAt: String?,
|
|
val active: Boolean,
|
|
)
|
|
|
|
data class UsageSnapshot(
|
|
val provider: String,
|
|
/** Stable id of the machine these numbers belong to. */
|
|
val machine: String,
|
|
/** That machine's current label. */
|
|
val machineName: String,
|
|
/** Provider-specific billing pool, such as Codex's regular or Luna Reserve pool. */
|
|
val limitId: String?,
|
|
/** Provider-specific human-facing pool name, when supplied. */
|
|
val limitName: String?,
|
|
/**
|
|
* What came back: "ok", "notLoggedIn", "authenticating", "loginRequired", "unreachable" or
|
|
* "failed".
|
|
*
|
|
* Named states 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 machine read as broken.
|
|
*/
|
|
val state: String,
|
|
/** Why, for states that have a useful explanation. 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"),
|
|
machine = snapshot.optString("machine"),
|
|
machineName = snapshot.optString("machineName"),
|
|
limitId = snapshot.optString("limitId").ifEmpty { null },
|
|
limitName = snapshot.optString("limitName").ifEmpty { null },
|
|
// 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"),
|
|
durationMinutes =
|
|
if (window.has("durationMinutes")) {
|
|
window.getLong("durationMinutes")
|
|
} else null,
|
|
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, machine: String, sessionIds: List<String>) {
|
|
requestFromServer(
|
|
settings,
|
|
"/machines/$machine/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,
|
|
machine: 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,
|
|
"/machines/$machine/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,
|
|
address: TranscriptAddress,
|
|
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, "/${address.urlPath}/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(),
|
|
) {}
|
|
}
|
|
|
|
/**
|
|
* Puts the session list in the order given, ids from top to bottom.
|
|
*
|
|
* Held by the server rather than by this phone because the arrangement is a fact about the
|
|
* sessions, not about the device that dragged them: a second phone opening the tab shows the same
|
|
* list. See the server's `reorder_sessions` for what it does with a list that changed underneath --
|
|
* nothing here has to be checked first.
|
|
*/
|
|
fun reorderSessions(settings: ServerSettings, sessionIds: List<String>) {
|
|
requestFromServer(
|
|
settings,
|
|
"/sessions/order",
|
|
method = "POST",
|
|
jsonBody = JSONObject().put("sessions", JSONArray(sessionIds)).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(),
|
|
) {}
|
|
}
|
|
|
|
/**
|
|
* What a new session's thinking level is when nothing chose one, or null for the CLI's own.
|
|
*
|
|
* Held by the server rather than by this phone, because a second device would otherwise spawn
|
|
* sessions at a level the first one's owner never picked.
|
|
*/
|
|
fun fetchDefaultEffort(settings: ServerSettings): String? =
|
|
requestFromServer(settings, "/defaults") {
|
|
it.jsonObject().optString("effort").ifEmpty { null }
|
|
}
|
|
|
|
/** Sets what new sessions start at. Nothing already running changes. */
|
|
fun setDefaultEffort(settings: ServerSettings, level: String?) {
|
|
requestFromServer(
|
|
settings,
|
|
"/defaults",
|
|
method = "POST",
|
|
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
|
|
) {}
|
|
}
|
|
|
|
/**
|
|
* How hard a coding model thinks, cheapest first.
|
|
*
|
|
* Not offered alongside the model and the permission mode on the session's own bar, because it does
|
|
* not behave like them: the CLI has a control request for those two and none for this (checked
|
|
* against 2.1.258), so a level is settled when the process is launched. Changing it therefore stops
|
|
* the process, which is what the working directory beside it in this dialog does, and why it is
|
|
* here rather than on a bar whose other controls take effect mid-turn.
|
|
*/
|
|
val EFFORT_LEVELS = listOf("low", "medium", "high", "xhigh", "max")
|
|
|
|
/** What the picker shows, and sends as null, for a session that has chosen no level. */
|
|
const val DEFAULT_EFFORT = "default"
|
|
|
|
/**
|
|
* Records how hard a session thinks and **stops its process**, since the level is read when the
|
|
* process is launched. The next message, or Start, runs one that has it.
|
|
*
|
|
* [level] is null for the CLI's own default.
|
|
*/
|
|
fun setSessionEffort(settings: ServerSettings, sessionId: String, level: String?) {
|
|
requestFromServer(
|
|
settings,
|
|
"/sessions/$sessionId/effort",
|
|
method = "POST",
|
|
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
|
|
) {}
|
|
}
|
|
|
|
/** Switches how much a running session asks before acting, also in place. */
|
|
/** Replaces a session's provider settings with [params] — the whole map, not a patch. */
|
|
fun setSessionParams(settings: ServerSettings, sessionId: String, params: Map<String, String>) {
|
|
requestFromServer(
|
|
settings,
|
|
"/sessions/$sessionId/params",
|
|
method = "POST",
|
|
jsonBody = JSONObject().put("params", JSONObject(params.toMap())).toString(),
|
|
) {}
|
|
}
|
|
|
|
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`. */
|
|
/**
|
|
* What an auto-resume says when nothing else was typed. Mirrors the server's own default, so a
|
|
* cleared field shows the word that would actually be sent instead of going blank.
|
|
*/
|
|
const val DEFAULT_RESUME_MESSAGE = "continue"
|
|
|
|
/**
|
|
* Turns auto-resume on or off and sets what it would say, in one request because they are one
|
|
* decision -- see the server's `/sessions/{id}/auto-resume`.
|
|
*/
|
|
fun setSessionAutoResume(
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
autoResume: Boolean,
|
|
message: String?,
|
|
) {
|
|
requestFromServer(
|
|
settings,
|
|
"/sessions/$sessionId/auto-resume",
|
|
method = "POST",
|
|
jsonBody =
|
|
JSONObject()
|
|
.put("autoResume", autoResume)
|
|
// Empty means the server's default rather than a session poked with nothing to
|
|
// read, which is the same rule the server applies to the field.
|
|
.put("message", message?.trim()?.ifEmpty { null } ?: JSONObject.NULL)
|
|
.toString(),
|
|
) {}
|
|
}
|
|
|
|
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: coding CLIs keep their own durable
|
|
* record, 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 one machine has, what it is fetching onto itself, and what HuggingFace offers.
|
|
// Under a machine because that is whose disk the file is on -- `llama-server` reads it where it
|
|
// runs, so a list or a download naming anywhere else would be about the wrong filesystem.
|
|
// 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,
|
|
/**
|
|
* What the file itself says it is called, or null when it does not say. Not what to draw: see
|
|
* the server's `models::labels`, which needs the whole list to decide -- two quantisations of
|
|
* one model share a name.
|
|
*/
|
|
val name: String?,
|
|
)
|
|
|
|
/**
|
|
* A download in flight or stopped. [total] is null when HuggingFace never said how big the file is
|
|
* -- which must render as "not known", never as a bar at some invented position.
|
|
*
|
|
* There is no "finished": a download that finished is a model, and it is in [Models.local] beside
|
|
* this one.
|
|
*/
|
|
data class Download(
|
|
val key: String,
|
|
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 modelsPath(machineId: String, tail: String = "") =
|
|
"/machines/${machineId.urlEncoded()}/models$tail"
|
|
|
|
/** Every GGUF on one machine, and every download putting one there. */
|
|
fun fetchMachineModels(settings: ServerSettings, machineId: String): Models =
|
|
requestFromServer(settings, modelsPath(machineId), readTimeoutMs = 40000) { connection ->
|
|
val body = connection.jsonObject()
|
|
Models(
|
|
local =
|
|
body.getJSONArray("local").mapObjects { m ->
|
|
LocalModel(
|
|
key = m.getString("key"),
|
|
repo = m.getString("repo"),
|
|
file = m.getString("file"),
|
|
bytes = m.getLong("bytes"),
|
|
name = m.optString("name").ifEmpty { null },
|
|
)
|
|
},
|
|
downloads =
|
|
body.getJSONArray("downloads").mapObjects { o ->
|
|
Download(
|
|
key = o.getString("key"),
|
|
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 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, machineId: String, repo: String): List<RemoteFile> =
|
|
requestFromServer(
|
|
settings,
|
|
modelsPath(machineId, "/files?repo=${repo.urlEncoded()}"),
|
|
readTimeoutMs = 40000,
|
|
) { connection ->
|
|
connection.jsonObjects { f ->
|
|
RemoteFile(
|
|
path = f.getString("path"),
|
|
bytes = f.getLong("bytes"),
|
|
have = f.getBoolean("have"),
|
|
)
|
|
}
|
|
}
|
|
|
|
/** Starts one on that machine, or joins the run already going for the same model. */
|
|
fun startDownload(settings: ServerSettings, machineId: String, repo: String, file: String) {
|
|
requestFromServer(
|
|
settings,
|
|
modelsPath(machineId, "/download"),
|
|
method = "POST",
|
|
jsonBody = JSONObject().put("repo", repo).put("file", file).toString(),
|
|
readTimeoutMs = 40000,
|
|
) {}
|
|
}
|
|
|
|
/** Stops one. The partial stays on the machine, so starting again carries on from there. */
|
|
fun cancelDownload(settings: ServerSettings, machineId: String, key: String) {
|
|
requestFromServer(
|
|
settings,
|
|
modelsPath(machineId, "/cancel"),
|
|
method = "POST",
|
|
jsonBody = JSONObject().put("key", key).toString(),
|
|
readTimeoutMs = 40000,
|
|
) {}
|
|
}
|
|
|
|
/** Takes a model off that machine, downloaded or half-downloaded. */
|
|
fun deleteModel(settings: ServerSettings, machineId: String, key: String) {
|
|
requestFromServer(
|
|
settings,
|
|
modelsPath(machineId, "/delete"),
|
|
method = "POST",
|
|
jsonBody = JSONObject().put("key", key).toString(),
|
|
readTimeoutMs = 40000,
|
|
) {}
|
|
}
|
|
|
|
/**
|
|
* One model a picker can offer.
|
|
*
|
|
* Two fields because for one provider they differ: a llama.cpp session names its model by the path
|
|
* it lives at and reads it as the name its own metadata gives it. Every other provider's [id] is
|
|
* already what a person calls it, and the server says so by repeating it -- which is what keeps
|
|
* every picker here free of a branch on the session kind.
|
|
*/
|
|
data class OfferedModel(val id: String, val label: String)
|
|
|
|
/** The current model catalog for one provider on the machine where it runs. */
|
|
fun fetchProviderModels(
|
|
settings: ServerSettings,
|
|
machineId: String,
|
|
provider: String,
|
|
): List<OfferedModel> =
|
|
requestFromServer(
|
|
settings,
|
|
"/machines/${machineId.urlEncoded()}/providers/${provider.urlEncoded()}/models",
|
|
) { connection ->
|
|
JSONArray(connection.inputStream.bufferedReader().readText()).mapObjects { m ->
|
|
OfferedModel(id = m.getString("id"), label = m.getString("label"))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One provider on one machine: what it is, every model it offers with the settings that decide how
|
|
* that model is loaded, and what its shared server is holding.
|
|
*
|
|
* [server] is null for a provider that runs no server of its own — a CLI, or echo. That is not the
|
|
* same as a server that is down, and the screen says so differently.
|
|
*/
|
|
data class ProviderView(
|
|
val machine: String,
|
|
val name: String,
|
|
val kind: String,
|
|
val command: String?,
|
|
/** What each of this provider's models takes; empty for one that loads no models. */
|
|
val modelParams: List<ParamSpec>,
|
|
val maxLoaded: Int?,
|
|
val models: List<ProviderModel>,
|
|
val mcpServers: List<String>,
|
|
val server: ServerState?,
|
|
)
|
|
|
|
/**
|
|
* A model this provider offers, its saved settings, and what the server is doing with it.
|
|
*
|
|
* [status] is null where nothing was asked — a provider with no server, or one that is not running
|
|
* — rather than a guess at "unloaded", which is a fact about the server nobody checked.
|
|
*/
|
|
data class ProviderModel(
|
|
val id: String,
|
|
val label: String,
|
|
val settings: Map<String, String>,
|
|
val status: String?,
|
|
)
|
|
|
|
/** The shared server behind a provider: whether it is up, and where the backend reaches it. */
|
|
data class ServerState(val running: Boolean, val port: Int?)
|
|
|
|
private fun providerPath(machineId: String, provider: String, tail: String = "") =
|
|
"/machines/${machineId.urlEncoded()}/providers/${provider.urlEncoded()}$tail"
|
|
|
|
fun fetchProvider(settings: ServerSettings, machineId: String, provider: String): ProviderView =
|
|
requestFromServer(settings, providerPath(machineId, provider), readTimeoutMs = 40000) {
|
|
val body = it.jsonObject()
|
|
ProviderView(
|
|
machine = body.getString("machine"),
|
|
name = body.getString("name"),
|
|
kind = body.getString("kind"),
|
|
command = body.optString("command").ifEmpty { null },
|
|
modelParams = body.optJSONArray("modelParams")?.mapObjects(::parseParamSpec).orEmpty(),
|
|
maxLoaded = if (body.isNull("maxLoaded")) null else body.optInt("maxLoaded"),
|
|
models =
|
|
body
|
|
.optJSONArray("models")
|
|
?.mapObjects { model ->
|
|
ProviderModel(
|
|
id = model.getString("id"),
|
|
label = model.getString("label"),
|
|
settings = model.optJSONObject("settings").stringMap(),
|
|
status = model.optString("status").ifEmpty { null },
|
|
)
|
|
}
|
|
.orEmpty(),
|
|
mcpServers = body.optJSONArray("mcpServers")?.strings().orEmpty(),
|
|
server =
|
|
body.optJSONObject("server")?.let { server ->
|
|
ServerState(
|
|
running = server.optBoolean("running", false),
|
|
port = if (server.isNull("port")) null else server.optInt("port"),
|
|
)
|
|
},
|
|
)
|
|
}
|
|
|
|
/** How many models this provider's server keeps loaded at once; null for the default. */
|
|
fun setProviderSettings(
|
|
settings: ServerSettings,
|
|
machineId: String,
|
|
provider: String,
|
|
maxLoaded: Int?,
|
|
) {
|
|
requestFromServer(
|
|
settings,
|
|
providerPath(machineId, provider, "/settings"),
|
|
method = "POST",
|
|
jsonBody =
|
|
JSONObject().apply { if (maxLoaded != null) put("maxLoaded", maxLoaded) }.toString(),
|
|
) {}
|
|
}
|
|
|
|
/**
|
|
* How one model is loaded. Whole, like a session's params: what is absent is unset.
|
|
*
|
|
* Slow on purpose: the backend writes it where the machine's server reads it, which unloads the
|
|
* model if it was loaded — that is the change taking effect, not a side effect.
|
|
*/
|
|
fun setModelSettings(
|
|
settings: ServerSettings,
|
|
machineId: String,
|
|
provider: String,
|
|
model: String,
|
|
params: Map<String, String>,
|
|
) {
|
|
requestFromServer(
|
|
settings,
|
|
providerPath(machineId, provider, "/model-settings"),
|
|
method = "POST",
|
|
jsonBody =
|
|
JSONObject()
|
|
.put("model", model)
|
|
.put("params", JSONObject(params as Map<*, *>))
|
|
.toString(),
|
|
readTimeoutMs = 40000,
|
|
) {}
|
|
}
|
|
|
|
/** Ends the machine's shared server and everything it was holding. */
|
|
fun stopProviderServer(settings: ServerSettings, machineId: String, provider: String) {
|
|
requestFromServer(
|
|
settings,
|
|
providerPath(machineId, provider, "/stop"),
|
|
method = "POST",
|
|
readTimeoutMs = 40000,
|
|
) {}
|
|
}
|
|
|
|
/** Takes one model out of memory, leaving the server and every other model alone. */
|
|
fun unloadProviderModel(
|
|
settings: ServerSettings,
|
|
machineId: String,
|
|
provider: String,
|
|
model: String,
|
|
) {
|
|
requestFromServer(
|
|
settings,
|
|
providerPath(machineId, provider, "/unload"),
|
|
method = "POST",
|
|
jsonBody = JSONObject().put("model", model).toString(),
|
|
readTimeoutMs = 40000,
|
|
) {}
|
|
}
|