Skip to content
17 changes: 17 additions & 0 deletions packages/kilo-docs/pages/code-with-ai/platforms/mobile.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,23 @@ The composer stays editable while the agent is working, so you don't have to wai

A queued message shows a subtle **Queued** badge on its bubble. The badge clears when the message starts processing or when the queue drains or is cancelled. Queueing works for Cloud Agent sessions and for remote sessions on a connected `kilo remote` CLI instance.

## Attachments in remote sessions

When you connect the mobile app to a `kilo remote` CLI session, you can share files in both directions.

### Sending files from your phone to the CLI

Attach up to **5 files** (each up to **20 MiB**) from your phone to the remote session. The CLI automatically processes them:

- **Text, images, and PDFs** — the file content is converted to a `data:` URL and handed directly to the model as a file part. The model sees the content as if you had loaded it locally.
- **Other file types** (binaries, archives, etc.) — the file is saved to a per-session scratch directory on the CLI machine. The session transcript shows the saved path, filename, file size, and MIME type. The agent can inspect the file with the `read` tool for text content or shell utilities for binary content.

Attaching files from the phone is the mobile flow — this is separate from `kilo run --file <path>`, which attaches local files to a local prompt.

### Receiving files from the CLI on your phone

While the CLI is connected, the agent can deliver a file to your phone with the `send_file` tool (up to **4 MiB**, remote sessions only). The file appears as a chip on the tool card — tap the chip to open the share sheet and save or forward the file. This tool works only when `kilo remote` is actively connected; it is not available in Cloud Agent sessions.
Comment thread
iscekic marked this conversation as resolved.

## Reviewing GitHub pull requests

Open a pull request from a PR link to review it without leaving the app:
Expand Down
14 changes: 7 additions & 7 deletions packages/opencode/src/kilocode/remote-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@ export namespace RemoteAttachments {
sql: "text/plain",
}
export const BINARY_MIME = "application/octet-stream"
// Hard cap on attachment bytes (5 MB + 1 byte so the helper aborts
// Hard cap on attachment bytes (20 MB + 1 byte so the helper aborts
// strictly when the body exceeds the agreed ceiling).
export const MAX_BYTES = 5 * 1024 * 1024 + 1
export const MAX_BYTES = 20 * 1024 * 1024 + 1
Comment thread
iscekic marked this conversation as resolved.
// Per-attachment fetch budget. R2 presigned GETs in the same region
// complete in tens of ms; 15s is generous but bounded so a stalled
// connection can never hold the prompt open indefinitely.
export const FETCH_TIMEOUT_MS = 15_000
// complete quickly, but a 20 MB body may take a few seconds on slower
// mobile connections, so the budget is generous.
export const FETCH_TIMEOUT_MS = 60_000
Comment thread
iscekic marked this conversation as resolved.
export const SCRATCH_DIRNAME = "remote-attachments"

export type Fetcher = (input: string, init?: RequestInit) => Promise<Response>
Expand Down Expand Up @@ -194,7 +194,7 @@ export namespace RemoteAttachments {
* - HTTPS only
* - redirects rejected
* - no credentials forwarded
* - body bounded to 5 MB + 1 byte
* - body bounded to 20 MB + 1 byte
* - bounded timeout
* - non-2xx rejected
*/
Expand Down Expand Up @@ -338,7 +338,7 @@ export namespace RemoteAttachments {
type: "text" as const,
text:
`attachment saved to ${target} (filename: ${filename ?? basename}, mime: ${BINARY_MIME}, size: ${bytes.byteLength} bytes). ` +
`Use the read tool on that path to inspect it.`,
`Inspect it with the read tool (text content) or shell utilities (binary content).`,
})
continue
}
Expand Down
13 changes: 10 additions & 3 deletions packages/opencode/src/kilocode/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "./noteb
import { MemoryRecallTool } from "./memory-recall"
import { MemorySaveTool } from "./memory-save"
import { NotifyUserTool } from "./notify-user"
import { SendFileTool } from "./send-file"
import * as Tool from "../../tool/tool"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Effect } from "effect"
Expand Down Expand Up @@ -80,14 +81,15 @@ export namespace KiloToolRegistry {
// context here and injects it into the tool's init Effect.
const sessions = yield* KiloSessions.Service
const notify = yield* NotifyUserTool.pipe(Effect.provideService(KiloSessions.Service, sessions))
const send = yield* SendFileTool
if (!notebook)
return { codebase, recall, managerModels, memory, save, manager, process, image, terminal, notify }
return { codebase, recall, managerModels, memory, save, manager, process, image, terminal, notify, send }
const tools = yield* Effect.all({
notebookRead: NotebookReadTool,
notebookEdit: NotebookEditTool,
notebookExecute: NotebookExecuteTool,
}).pipe(Effect.provideService(Notebook.Service, notebook))
return { codebase, recall, managerModels, memory, save, manager, process, image, terminal, notify, ...tools }
return { codebase, recall, managerModels, memory, save, manager, process, image, terminal, notify, send, ...tools }
})
}

Expand All @@ -105,6 +107,7 @@ export namespace KiloToolRegistry {
image: Tool.Info
terminal?: Tool.Info
notify: Tool.Info
send: Tool.Info
notebookRead?: Tool.Info
notebookEdit?: Tool.Info
notebookExecute?: Tool.Info
Expand All @@ -123,6 +126,7 @@ export namespace KiloToolRegistry {
process: Tool.init(tools.process),
image: Tool.init(tools.image),
notify: Tool.init(tools.notify),
send: Tool.init(tools.send),
})
const terminal = tools.terminal ? yield* Tool.init(tools.terminal) : undefined
const notebooks =
Expand All @@ -134,7 +138,7 @@ export namespace KiloToolRegistry {
})
: {}
const semantic = yield* semanticTool(deps, loaders)
return { ...base, terminal, ...notebooks, semantic, notify: base.notify }
return { ...base, terminal, ...notebooks, semantic, notify: base.notify, send: base.send }
})
}

Expand Down Expand Up @@ -178,6 +182,7 @@ export namespace KiloToolRegistry {
/** Hide human-driven tools from agents that cannot interact with the user directly. */
export function available(tool: Tool.Def, agent: Agent.Info) {
if (tool.id === "notify_user") return KiloSessions.remoteStatus().enabled
if (tool.id === "send_file") return KiloSessions.remoteStatus().connected
if (tool.id !== "interactive_terminal") return true
return agent.mode === "primary"
}
Expand All @@ -196,6 +201,7 @@ export namespace KiloToolRegistry {
image: Tool.Def
terminal?: Tool.Def
notify: Tool.Def
send: Tool.Def
notebookRead?: Tool.Def
notebookEdit?: Tool.Def
notebookExecute?: Tool.Def
Expand All @@ -221,6 +227,7 @@ export namespace KiloToolRegistry {
? [tools.notebookRead, tools.notebookEdit, tools.notebookExecute]
: []),
tools.notify,
tools.send,
]
}

Expand Down
167 changes: 167 additions & 0 deletions packages/opencode/src/kilocode/tool/send-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { Tool } from "@/tool/tool"
import { Effect, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { assertExternalDirectoryEffect } from "@/tool/external-directory"
import { KiloSessions } from "@/kilo-sessions/kilo-sessions"
import { KiloReadObject } from "@/kilocode/tool/read-object"
import { sniffAttachmentMime } from "@/util/media"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { KiloReference } from "@/kilocode/reference/contains"
import DESCRIPTION from "./send-file.txt"
import path from "node:path"

/**
* Remote-CLI-only live-connection delivery cap. The send_file path rides the
* existing tool-attachment transport (remote-sender → UserConnectionDO → mobile
* SDK), which has no in-repo frame cap. Cloud-agent ingest trims tool-attachment
* URLs at 1 MiB (`MAX_INGEST_EVENT_BYTES`), so delivery through the cloud-agent
* path is impossible by design — the tool is gated on `KiloSessions.remoteStatus()
* .connected`, which is only true for the remote-CLI relay. History/cold-open
* re-hydration rides the existing R2 spill (>~1.94 MiB) and 8 MiB page budget;
* near the cap a cold-open "unavailable" is accepted page-pressure behavior.
*/
export const SEND_FILE_MAX_BYTES = 4 * 1024 * 1024
Comment thread
iscekic marked this conversation as resolved.

const SAMPLE_BYTES = 4096

const Params = Schema.Struct({
path: Schema.String.annotate({ description: "Absolute or relative path to the file to send to the mobile app." }),
})

function fail(msg: string) {
return { title: "Send file failed", output: msg, metadata: {} }
}

export const SendFileTool = Tool.define<typeof Params, {}, FSUtil.Service, "send_file">(
"send_file",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
return {
description: DESCRIPTION,
parameters: Params,
execute: (params, ctx) =>
Effect.gen(function* () {
if (!KiloSessions.remoteStatus().connected) {
return fail(
"Cannot send files: this session is not connected to Kilo cloud. Delivery needs an active link.",
)
}

const inst = yield* InstanceState.context
const requested = path.resolve(inst.directory, params.path)
const basename = path.basename(requested)

// kilocode_change start — authorize missing and directory paths with the same
// security sequence as read.ts before any file inspection via KiloReadObject.
// Route absent targets through a read-style authorized failure, and produce a
// structured fail() for directories. This prevents access-pattern leakage where
// missing vs directory vs external-directory errors differ before permission
// checks.
const info = yield* fs.stat(requested).pipe(
Effect.catchIf(
(err) => "reason" in err && err.reason._tag === "NotFound",
() => Effect.succeed(undefined),
),
)
if (!info) {
const dir = path.dirname(requested)
const parent = yield* fs.realPath(dir).pipe(Effect.option)
if (parent._tag === "None") return fail(`File not found: ${basename}`)
yield* assertExternalDirectoryEffect(ctx, parent.value, { bypass: false, kind: "directory" })
yield* ctx.ask({
permission: "read",
patterns: [...new Set([requested, parent.value].map((item) => path.relative(inst.worktree, item)))],
always: ["*"],
metadata: {},
})
return fail(`File not found: ${basename}`)
}
if (info.type === "Directory") {
const resolved = yield* fs.realPath(requested)
const target = process.platform === "win32" ? FSUtil.normalizePath(resolved) : resolved
const explicit =
typeof ctx.extra?.["referenceRoot"] === "string"
? yield* KiloReference.path(fs, ctx.extra["referenceRoot"], target).pipe(
Effect.option,
Effect.map((result) => result._tag === "Some" && result.value),
)
: false
yield* assertExternalDirectoryEffect(ctx, target, { bypass: explicit, kind: "directory" })
yield* ctx.ask({
permission: "read",
patterns: [...new Set([requested, target].map((item) => path.relative(inst.worktree, item)))],
always: ["*"],
metadata: {},
})
return fail(`Cannot send: ${basename} is a directory.`)
}
// kilocode_change end

// 1. Resolve via KiloReadObject.file (same authorization sequence as read.ts)
const file = yield* KiloReadObject.file(requested)

// 2. Authorization — same pattern as read.ts
const explicit =
typeof ctx.extra?.["referenceRoot"] === "string"
? yield* KiloReference.path(fs, ctx.extra["referenceRoot"], file.target).pipe(
Effect.option,
Effect.map((result) => result._tag === "Some" && result.value),
)
: false
yield* assertExternalDirectoryEffect(ctx, file.target, { bypass: explicit, kind: "file" })
yield* ctx.ask({
permission: "read",
patterns: [...new Set([requested, file.target].map((item) => path.relative(inst.worktree, item)))],
always: ["*"],
metadata: {},
})

// 3. Size check before reading content
if (Number(file.stat.size) > SEND_FILE_MAX_BYTES) {
return {
title: "Send file too large",
output: `Cannot send: ${basename} is ${file.stat.size} bytes, which exceeds the ${SEND_FILE_MAX_BYTES / (1024 * 1024)} MiB limit. For larger files, give the user the workspace path instead.`,
metadata: {},
}
}

// 4. Open and read with TOCTOU safety (same pattern as read.ts)
return yield* KiloReadObject.use(file, (bound) =>
Effect.gen(function* () {
const sample = yield* Effect.tryPromise({
try: (signal) => bound.sample(SAMPLE_BYTES, AbortSignal.any([ctx.abort, signal])),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
})
const mime = sniffAttachmentMime(sample, FSUtil.mimeType(requested))

const bytes = yield* Effect.tryPromise({
try: (signal) => bound.read(SEND_FILE_MAX_BYTES + 1, AbortSignal.any([ctx.abort, signal])),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
})
if (bytes.byteLength > SEND_FILE_MAX_BYTES) {
return {
title: "Send file too large",
output: `Cannot send: ${basename} exceeds the ${SEND_FILE_MAX_BYTES / (1024 * 1024)} MiB limit. For larger files, give the user the workspace path instead.`,
metadata: {},
}
}

return {
title: `Sent file: ${basename}`,
output: `File ${basename} (${bytes.byteLength} bytes, ${mime}) delivered to the user's Kilo app. Older app builds ignore non-image file attachments — make sure the user has an up-to-date app to see the delivery.`,
metadata: {},
attachments: [
{
type: "file" as const,
mime,
filename: basename,
url: `data:${mime};base64,${bytes.toString("base64")}`,
},
],
}
}),
)
}).pipe(Effect.orDie),
}
}),
)
14 changes: 14 additions & 0 deletions packages/opencode/src/kilocode/tool/send-file.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Send a file from the local machine to the user's Kilo app. This tool works only when this session is connected to Kilo cloud (remote CLI relay). It sends exactly one file per call, up to 4 MiB.

Use this tool ONLY for:
- Files the user explicitly asked to see on mobile ("show me the log file on my phone")
- Sharing a generated file (a report, chart, or artifact) so the user can view it in the app
- Sending a screenshot or image the user requested

Do NOT use this tool:
- For files the user did not ask to see — the user pulls files on demand
- For every file you generate or read — only when the user asks for it on mobile
- For files larger than 4 MiB — the tool rejects them; give the user the workspace path instead
- In a cloud-agent session — delivery is remote-CLI only

The filename visible on mobile is always the basename, never a full path. Older Kilo app builds ignore non-image file deliveries.
8 changes: 7 additions & 1 deletion packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
const outputText = part.state.time.compacted
? "[Old tool result content cleared]"
: truncateToolOutput(part.state.output, options?.toolOutputMaxChars)
const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])
// kilocode_change start — do not replay send_file delivery attachments to the model;
// they are mobile delivery artifacts (up to 4 MiB base64), not model context.
const attachments =
part.state.time.compacted || options?.stripMedia || part.tool === "send_file"
Comment thread
iscekic marked this conversation as resolved.
? []
: (part.state.attachments ?? [])
// kilocode_change end

// For providers that don't support media in tool results, extract media files
// (images, PDFs) to be sent as a separate user message
Expand Down
9 changes: 8 additions & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,8 +706,14 @@ export const layer = Layer.effect(
return
}
const rawOutput = toolResultOutput(value)
// kilocode_change start — send_file delivery attachments (up to 4 MiB raw)
// must reach mobile byte-for-byte. Base64-encoded images near the cap can
// exceed the generic 5 MiB normalization limit, causing rewrites or omission
// after the tool reports success. These attachments are delivery-only; the
// existing message-v2 filter already strips them from model context.
const skipNormalization = value.name === "send_file"
Comment thread
iscekic marked this conversation as resolved.
const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>
attachment.mime.startsWith("image/")
attachment.mime.startsWith("image/") && !skipNormalization
? image.normalize(attachment).pipe(
Effect.catchIf(
(error) => error instanceof Image.ResizerUnavailableError,
Expand All @@ -717,6 +723,7 @@ export const layer = Layer.effect(
)
: Effect.succeed(Exit.succeed<SessionV1.FilePart>(attachment)),
)
// kilocode_change end
const omitted = normalized.filter(Exit.isFailure).length
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
const output = {
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/test/kilocode/remote-attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ describe("RemoteAttachments.create().materialize", () => {
expect(text.text).toContain("filename: blob.bin")
expect(text.text).toContain("mime: application/octet-stream")
expect(text.text).toContain(`size: ${bin.byteLength} bytes`)
expect(text.text).toContain("shell utilities")

const entries = await fs.readdir(dir)
expect(entries).toHaveLength(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function infos() {
process: info("background_process"),
image: info("generate_image"),
notify: info("notify_user"),
send: info("send_file"),
notebookRead: info("notebook_read"),
notebookEdit: info("notebook_edit"),
notebookExecute: info("notebook_execute"),
Expand Down
Loading
Loading