Stream attachments end to end, ship files to a remote session's machine, and write up the transcript work

Uploads no longer sit whole in memory anywhere: the phone writes the
multipart body chunked as it reads the picked file, and the server writes
each chunk to a `.part` file under the session and renames it when whole.
The per-request cap is 4 GB and bounds disk, not memory.

A file attached to a session on another machine is copied there in the
same request: one ssh invocation takes the bytes on stdin into the
setup's `attachmentsDir` (new, optional, on the machine form and in the
config), else the session's cwd, else the login home, and answers with
`pwd -P`, which is recorded beside the file as `<name>.remote` and is the
path the driver tells the CLI. A failed copy fails the upload and says
why, so no message ever names a file that is not there. The host keeps
its copy so transcripts can reference and fetch it. Measured against the
Gentoo test guest: a 40 MB file shared from the phone arrived there byte
for byte. The tilde in that setting is the remote home, so it is not
expanded on the server the way other setup paths are.

TRANSCRIPT_RENDERING.md records the week of transcript work -- the
measurements behind each decision, the harness, what was rejected, and
what to do next -- so a new session can start from it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-03 13:24:36 -04:00
1 parent 6180663f14
commit 801618ba0e
12 files changed
+499 -57

No files matched your search

@@ -32,8 +32,12 @@ fun <T> requestFromServer(
path: String,
method: String = "GET",
jsonBody: String? = null,
/** Raw request body as content-type to bytes -- the upload path. */
binaryBody: Pair<String, ByteArray>? = null,
/**
* A request body written as it is produced -- the upload path. Content type, and a writer
* handed the connection's stream. Sent chunked, since what a writer will produce is not known
* up front and the point is that a file never sits whole in memory on this side.
*/
streamBody: Pair<String, (java.io.OutputStream) -> Unit>? = null,
readTimeoutMs: Int = READ_TIMEOUT_MS,
readBody: (HttpURLConnection) -> T,
): T {
@@ -48,10 +52,11 @@ fun <T> requestFromServer(
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.outputStream.use { it.write(jsonBody.encodeToByteArray()) }
} else if (binaryBody != null) {
} else if (streamBody != null) {
connection.doOutput = true
connection.setRequestProperty("Content-Type", binaryBody.first)
connection.outputStream.use { it.write(binaryBody.second) }
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()
@@ -381,12 +386,17 @@ data class SshDetails(
val address: String,
val port: Int? = null,
val identityFile: String? = null,
/**
* Where files attached from here land on that machine; null for the session's own directory.
*/
val attachmentsDir: String? = null,
)
private fun SshDetails.toJson() =
JSONObject().put("address", address).apply {
if (port != null) put("port", port)
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir)
}
/** What a machine turns out to have, without saving anything. */
@@ -540,16 +550,16 @@ fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) {
}
/**
* Uploads one attachment; 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.
* 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,
bytes: ByteArray,
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.
@@ -564,8 +574,16 @@ fun uploadAttachment(
settings,
"/sessions/$sessionId/attachments",
method = "POST",
binaryBody = "multipart/form-data; boundary=$boundary" to (head + bytes + tail),
readTimeoutMs = 60000,
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")
}
@@ -33,7 +33,7 @@ suspend fun uploadPickedImage(
maxEdge: Int?,
): String {
val (bytes, mime) = readForUpload(context, uri, maxEdge)
return uploadAttachment(settings, sessionId, bytes, mime, "image")
return uploadAttachment(settings, sessionId, mime, "image") { it.write(bytes) }
}
/**
@@ -53,26 +53,34 @@ suspend fun uploadPicked(
if (mime != null && mime.startsWith("image/")) {
return uploadPickedImage(context, settings, sessionId, uri, maxEdge)
}
val bytes = readAll(resolver, uri)
return uploadAttachment(
settings,
sessionId,
bytes,
mime ?: "application/octet-stream",
displayName(resolver, uri),
)
// Opened before the request starts, so a provider that refuses says so here and not from
// inside the connection; then streamed, since a trace or a log is bigger than this process
// should hold at once.
val source = openSource(resolver, uri)
val name = displayName(resolver, uri)
return uploadAttachment(settings, sessionId, mime ?: "application/octet-stream", name) { out ->
try {
source.use { it.copyTo(out, COPY_BUFFER) }
} catch (e: java.io.IOException) {
// Either side of the copy can fail; the message names the file, which is the
// part the reader can do something about.
throw ApiException("couldn't send $name: ${e.message}", e)
}
}
}
private const val COPY_BUFFER = 64 * 1024
/**
* Everything at [uri], or the failure as the kind the composer reports beside the message.
* A stream of [uri], or the refusal as the kind the composer reports beside the message.
*
* A share arrives with whatever access the other app granted, and a provider that refuses says so
* with a `SecurityException`; a file gone between the pick and the read is an `IOException`. Both
* are things the reader can act on, so neither is left to end the process.
*/
private fun readAll(resolver: ContentResolver, uri: Uri): ByteArray =
private fun openSource(resolver: ContentResolver, uri: Uri): java.io.InputStream =
try {
resolver.openInputStream(uri)?.use { it.readBytes() }
resolver.openInputStream(uri)
?: throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: nothing there")
} catch (e: SecurityException) {
throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: no access to it")
@@ -80,6 +88,10 @@ private fun readAll(resolver: ContentResolver, uri: Uri): ByteArray =
throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: ${e.message}")
}
/** Everything at [uri]; an image is decoded whole anyway, so it is read whole. */
private fun readAll(resolver: ContentResolver, uri: Uri): ByteArray =
openSource(resolver, uri).use { it.readBytes() }
/**
* The name a document provider shows for [uri]. The last path segment is the fallback because a
* provider's own id for a file is usually a number, which says nothing to the session.
@@ -236,6 +236,7 @@ private fun AddSetupDialog(
var name by remember { mutableStateOf("") }
var address by remember { mutableStateOf("") }
var identity by remember { mutableStateOf("") }
var attachmentsDir by remember { mutableStateOf("") }
var tested by remember { mutableStateOf<String?>(null) }
var testing by remember { mutableStateOf(false) }
@@ -249,6 +250,7 @@ private fun AddSetupDialog(
address = host,
port = typedPort,
identityFile = identity.trim().ifEmpty { null },
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
)
}
@@ -285,6 +287,15 @@ private fun AddSetupDialog(
label = { Text("Key path on the backend") },
singleLine = true,
)
// Where a file attached from the phone lands on that machine. Blank means the
// session's own directory, which is what most people want and what needs no
// path typed on a phone.
OutlinedTextField(
value = attachmentsDir,
onValueChange = { attachmentsDir = it },
label = { Text("Folder for attached files (optional)") },
singleLine = true,
)
tested?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall)