Fix Remote Host downloads and agent status parity (#6436)

* Fix remote host downloads and agent status parity

Co-authored-by: Orca <help@stably.ai>

* Address remote download review comments

Co-authored-by: Orca <help@stably.ai>

* Avoid inefficient SSH chunk fallback

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-26 14:28:16 -07:00 committed by GitHub
parent d5b1ca7fcd
commit 4904ffda9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1320 additions and 26 deletions

View File

@ -299,6 +299,8 @@ describe('registerFilesystemHandlers', () => {
buffer.fill(0x61)
return { bytesRead: buffer.length, buffer }
}),
write: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
close: vi.fn()
})
lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
@ -485,6 +487,71 @@ describe('registerFilesystemHandlers', () => {
expect(rmMock).not.toHaveBeenCalledWith(tempPath, expect.anything())
})
it('streams runtime download chunks to a temp sibling then promotes on finish', async () => {
const writeFile = vi.fn().mockResolvedValue(undefined)
const close = vi.fn().mockResolvedValue(undefined)
openMock.mockResolvedValue({ writeFile, close })
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
registerFilesystemHandlers(store as never)
const started = await handlers.get('fs:startDownloadedFile')!(
{ sender: {} },
{ suggestedName: 'report.pdf' }
)
expect(started).toMatchObject({
canceled: false,
destinationPath: '/downloads/report.pdf'
})
if (!started || typeof started !== 'object' || !('transferId' in started)) {
throw new Error('download did not start')
}
const transferId = started.transferId
await expect(
handlers.get('fs:appendDownloadedFileChunk')!(null, {
transferId,
contentBase64: Buffer.from('hello').toString('base64')
})
).resolves.toEqual({ ok: true })
await expect(handlers.get('fs:finishDownloadedFile')!(null, { transferId })).resolves.toEqual({
canceled: false,
destinationPath: '/downloads/report.pdf'
})
const tempPath = openMock.mock.calls[0][0]
expect(path.dirname(tempPath)).toBe(path.normalize('/downloads'))
expect(openMock).toHaveBeenCalledWith(tempPath, 'wx')
expect(writeFile).toHaveBeenCalledWith(Buffer.from('hello'))
expect(close).toHaveBeenCalled()
expect(renameMock).toHaveBeenCalledWith(tempPath, '/downloads/report.pdf')
})
it('cleans up a runtime download temp file on cancel', async () => {
const close = vi.fn().mockResolvedValue(undefined)
openMock.mockResolvedValue({ writeFile: vi.fn(), close })
showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' })
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
registerFilesystemHandlers(store as never)
const started = await handlers.get('fs:startDownloadedFile')!(
{ sender: {} },
{ suggestedName: 'report.pdf' }
)
if (!started || typeof started !== 'object' || !('transferId' in started)) {
throw new Error('download did not start')
}
const tempPath = openMock.mock.calls[0][0]
await expect(
handlers.get('fs:cancelDownloadedFile')!(null, { transferId: started.transferId })
).resolves.toEqual({ ok: true })
expect(close).toHaveBeenCalled()
expect(rmMock).toHaveBeenCalledWith(tempPath, { force: true })
expect(renameMock).not.toHaveBeenCalled()
})
it('cleans up the temp sibling when remote download transfer fails', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }),

View File

@ -1,6 +1,7 @@
/* eslint-disable max-lines */
import { BrowserWindow, dialog, ipcMain, shell } from 'electron'
import { readdir, readFile, writeFile, stat, lstat, open, rename, rm } from 'fs/promises'
import type { FileHandle } from 'fs/promises'
import { randomUUID } from 'crypto'
import { dirname, extname, join, resolve } from 'path'
import type { ChildProcess } from 'child_process'
@ -163,6 +164,24 @@ function sanitizeSaveDialogFilename(remoteBasename: string): string {
return sanitized
}
function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer {
if (encoding === 'base64') {
return Buffer.from(content, 'base64')
}
return Buffer.from(content, 'utf8')
}
type DownloadSession = {
destinationPath: string
tempPath: string
destinationExisted: boolean
handle: FileHandle
cleanupTimer: ReturnType<typeof setTimeout>
senderId: number
}
const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000
function createSiblingTransferPath(destinationPath: string, suffix: string): string {
return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`)
}
@ -430,6 +449,32 @@ export function registerFilesystemHandlers(
commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers
): void {
const activeTextSearches = new Map<string, ChildProcess>()
const downloadSessions = new Map<string, DownloadSession>()
async function closeDownloadSession(
transferId: string,
cleanupTemp: boolean
): Promise<DownloadSession | null> {
const session = downloadSessions.get(transferId)
if (!session) {
return null
}
downloadSessions.delete(transferId)
clearTimeout(session.cleanupTimer)
await session.handle.close().catch(() => {})
if (cleanupTemp) {
await cleanupLocalTransferPath(session.tempPath)
}
return session
}
function cleanupDownloadSessionsForSender(senderId: number): void {
for (const [transferId, session] of Array.from(downloadSessions)) {
if (session.senderId === senderId) {
void closeDownloadSession(transferId, true)
}
}
}
// ─── Filesystem ─────────────────────────────────────────
ipcMain.handle(
@ -552,6 +597,153 @@ export function registerFilesystemHandlers(
}
)
ipcMain.handle(
'fs:saveDownloadedFile',
async (
event,
args: { suggestedName?: string; content?: string; encoding?: 'utf8' | 'base64' }
): Promise<DownloadFileResult> => {
const suggestedName = sanitizeSaveDialogFilename(
validateRequiredString(args?.suggestedName, 'suggestedName')
)
if (typeof args?.content !== 'string') {
throw new Error('content is required')
}
const content = args.content
const encoding = args?.encoding === 'base64' ? 'base64' : 'utf8'
const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined
const dialogResult = parentWindow
? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName })
: await dialog.showSaveDialog({ defaultPath: suggestedName })
if (dialogResult.canceled || !dialogResult.filePath) {
return { canceled: true }
}
const destinationPath = dialogResult.filePath
const { existed } = await inspectDownloadDestination(destinationPath)
const tempPath = createSiblingTransferPath(destinationPath, 'download')
let promoted = false
try {
await writeFile(tempPath, decodeDownloadedFileContent(content, encoding))
await promoteDownloadedFile(tempPath, destinationPath, existed)
promoted = true
return { canceled: false, destinationPath }
} finally {
if (!promoted) {
await cleanupLocalTransferPath(tempPath)
}
}
}
)
ipcMain.handle(
'fs:startDownloadedFile',
async (
event,
args: { suggestedName?: string }
): Promise<
| { canceled: true }
| {
canceled: false
transferId: string
destinationPath: string
}
> => {
const suggestedName = sanitizeSaveDialogFilename(
validateRequiredString(args?.suggestedName, 'suggestedName')
)
const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined
const dialogResult = parentWindow
? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName })
: await dialog.showSaveDialog({ defaultPath: suggestedName })
if (dialogResult.canceled || !dialogResult.filePath) {
return { canceled: true }
}
const destinationPath = dialogResult.filePath
const { existed } = await inspectDownloadDestination(destinationPath)
const tempPath = createSiblingTransferPath(destinationPath, 'download')
const transferId = randomUUID()
try {
const handle = await open(tempPath, 'wx')
const senderId = typeof event.sender.id === 'number' ? event.sender.id : Number.NaN
const cleanupTimer = setTimeout(() => {
void closeDownloadSession(transferId, true)
}, DOWNLOAD_SESSION_TTL_MS)
if (typeof cleanupTimer.unref === 'function') {
cleanupTimer.unref()
}
downloadSessions.set(transferId, {
destinationPath,
tempPath,
destinationExisted: existed,
handle,
cleanupTimer,
senderId
})
event.sender.once?.('destroyed', () => cleanupDownloadSessionsForSender(senderId))
return { canceled: false, transferId, destinationPath }
} catch (error) {
await cleanupLocalTransferPath(tempPath)
throw error
}
}
)
ipcMain.handle(
'fs:appendDownloadedFileChunk',
async (
_event,
args: { transferId?: string; contentBase64?: string }
): Promise<{ ok: true }> => {
const transferId = validateRequiredString(args?.transferId, 'transferId')
const contentBase64 = validateRequiredString(args?.contentBase64, 'contentBase64')
const session = downloadSessions.get(transferId)
if (!session) {
throw new Error('Download session not found')
}
await session.handle.writeFile(Buffer.from(contentBase64, 'base64'))
return { ok: true }
}
)
ipcMain.handle(
'fs:finishDownloadedFile',
async (
_event,
args: { transferId?: string }
): Promise<{ canceled: false; destinationPath: string }> => {
const transferId = validateRequiredString(args?.transferId, 'transferId')
const session = await closeDownloadSession(transferId, false)
if (!session) {
throw new Error('Download session not found')
}
let promoted = false
try {
await promoteDownloadedFile(
session.tempPath,
session.destinationPath,
session.destinationExisted
)
promoted = true
return { canceled: false, destinationPath: session.destinationPath }
} finally {
if (!promoted) {
await cleanupLocalTransferPath(session.tempPath)
}
}
}
)
ipcMain.handle(
'fs:cancelDownloadedFile',
async (_event, args: { transferId?: string }): Promise<{ ok: true }> => {
const transferId = validateRequiredString(args?.transferId, 'transferId')
await closeDownloadSession(transferId, true)
return { ok: true }
}
)
ipcMain.handle(
'fs:listMarkdownDocuments',
async (

View File

@ -33,6 +33,7 @@ import {
import type {
RuntimeFileListResult,
RuntimeFileOpenResult,
RuntimeFileReadChunkResult,
RuntimeFilePreviewResult,
RuntimeFileReadResult,
RuntimeTerminalPathResolution
@ -469,6 +470,45 @@ export class RuntimeFileCommands {
return { content: buffer.toString('utf-8'), isBinary: false }
}
async readFileExplorerChunk(
worktreeSelector: string,
relativePath: string,
offset: number,
length: number
): Promise<RuntimeFileReadChunkResult> {
const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath)
const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
const fileStat = await provider.stat(target.path)
if (fileStat.type === 'directory') {
throw new Error('Cannot download a directory')
}
throw new Error('SSH runtime chunked download is unavailable; use the SSH download path')
}
const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore())
const fileStats = await stat(filePath)
if (fileStats.isDirectory()) {
throw new Error('Cannot download a directory')
}
const handle = await open(filePath, 'r')
try {
const buffer = Buffer.alloc(Math.min(length, Math.max(0, fileStats.size - offset)))
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset)
const chunk = buffer.subarray(0, bytesRead)
return {
contentBase64: chunk.toString('base64'),
bytesRead,
eof: offset + bytesRead >= fileStats.size
}
} finally {
await handle.close()
}
}
async writeFileExplorerFile(
worktreeSelector: string,
relativePath: string,

View File

@ -4655,6 +4655,8 @@ export class OrcaRuntimeService {
this.fileCommands.watchFileExplorer.bind(this.fileCommands)
readFileExplorerPreview: RuntimeFileCommands['readFileExplorerPreview'] =
this.fileCommands.readFileExplorerPreview.bind(this.fileCommands)
readFileExplorerChunk: RuntimeFileCommands['readFileExplorerChunk'] =
this.fileCommands.readFileExplorerChunk.bind(this.fileCommands)
writeFileExplorerFile: RuntimeFileCommands['writeFileExplorerFile'] =
this.fileCommands.writeFileExplorerFile.bind(this.fileCommands)
writeFileExplorerFileBase64: RuntimeFileCommands['writeFileExplorerFileBase64'] =

View File

@ -338,6 +338,33 @@ describe('file RPC methods', () => {
})
})
it('reads a file chunk for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
readFileExplorerChunk: vi.fn().mockResolvedValue({
contentBase64: 'YWJj',
bytesRead: 3,
eof: true
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('files.readChunk', {
worktree: 'id:wt-1',
relativePath: 'archive.zip',
offset: 0,
length: 1024
})
)
expect(runtime.readFileExplorerChunk).toHaveBeenCalledWith('id:wt-1', 'archive.zip', 0, 1024)
expect(response).toMatchObject({
ok: true,
result: { contentBase64: 'YWJj', bytesRead: 3, eof: true }
})
})
it('reads a file explorer directory for a selected worktree', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',

View File

@ -78,6 +78,15 @@ const FileWriteBase64Chunk = FileWriteBase64.extend({
append: z.boolean().optional()
})
const FileReadChunk = FileOpen.extend({
offset: z.number().int().nonnegative(),
length: z
.number()
.int()
.positive()
.max(512 * 1024)
})
const FileRename = WorktreeSelector.extend({
oldRelativePath: z
.unknown()
@ -175,6 +184,17 @@ export const FILE_METHODS: RpcAnyMethod[] = [
handler: async (params, { runtime }) =>
runtime.readFileExplorerPreview(params.worktree, params.relativePath)
}),
defineMethod({
name: 'files.readChunk',
params: FileReadChunk,
handler: async (params, { runtime }) =>
runtime.readFileExplorerChunk(
params.worktree,
params.relativePath,
params.offset,
params.length
)
}),
defineMethod({
name: 'files.readDir',
params: FileTreePath,

View File

@ -323,6 +323,27 @@ function appendPendingMultiplexOutput(
stream.pendingOutputOverflowed ||= trimmed.overflowed
}
function getOutputAfterSnapshotSeq(
chunk: TerminalOutputChunk,
snapshotSeq: number | undefined
): string | null {
if (
typeof snapshotSeq !== 'number' ||
typeof chunk.meta?.seq !== 'number' ||
typeof chunk.meta.rawLength !== 'number'
) {
return chunk.data
}
if (chunk.meta.seq <= snapshotSeq) {
return null
}
const chunkStartSeq = chunk.meta.seq - chunk.meta.rawLength
if (chunkStartSeq >= snapshotSeq) {
return chunk.data
}
return chunk.data.slice(snapshotSeq - chunkStartSeq)
}
function trimPendingOutputToBudget(
pendingOutput: TerminalOutputChunk[],
pendingOutputBytes: number
@ -1489,7 +1510,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const size = runtime.getTerminalSize(ptyId)
const displayMode = runtime.getMobileDisplayMode(ptyId)
const layoutSeq = runtime.getLayout(ptyId)?.seq
const snapshotSeq = serialized?.seq ?? layoutSeq
const snapshotFrameSeq = serialized?.seq ?? layoutSeq
const snapshotOutputSeq = serialized?.seq
if (!isMobile) {
const fitOverride = runtime.getTerminalFitOverride(ptyId)
emit({
@ -1520,7 +1542,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
cols: serialized?.cols ?? size?.cols ?? 80,
rows: serialized?.rows ?? size?.rows ?? 24,
displayMode,
seq: snapshotSeq,
seq: snapshotFrameSeq,
truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read),
truncatedByByteBudget: serialized?.truncatedByByteBudget,
source: serialized?.source,
@ -1532,7 +1554,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
stream.lastResizeCols = serialized?.cols ?? size?.cols
stream.buffering = false
for (const chunk of stream.pendingOutput.splice(0)) {
stream.outputBatcher.push(chunk.data, chunk.meta)
const uncoveredData = getOutputAfterSnapshotSeq(chunk, snapshotOutputSeq)
if (uncoveredData) {
stream.outputBatcher.push(uncoveredData, chunk.meta)
}
}
stream.pendingOutputBytes = 0
stream.pendingOutputOverflowed = false
@ -1778,16 +1803,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
.catch(() => runtime.cleanupSubscription(subscriptionId))
const sendFrame = (
opcode: TerminalStreamOpcode,
payload: Uint8Array<ArrayBufferLike> = new Uint8Array()
payload: Uint8Array<ArrayBufferLike> = new Uint8Array(),
frameSeq = cursor++
): void => {
if (closed || !sendBinary) {
return
}
sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: cursor++, payload }))
sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload }))
}
outputBatcher = createTerminalOutputBatcher((data) => {
for (const chunk of iterateTerminalOutputFrameChunks(data)) {
sendFrame(TerminalStreamOpcode.Output, chunk.bytes)
outputBatcher = createTerminalOutputBatcher((data, meta) => {
for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) {
sendFrame(TerminalStreamOpcode.Output, chunk.bytes, chunk.seq)
}
})
unregisterBinaryHandler =
@ -1842,7 +1868,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
return
}
unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => {
unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
if (closed) {
return
}
@ -1854,13 +1880,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const measurement = measureTerminalStreamByteLength(data, {
stopAfterBytes: remainingBudget
})
pendingOutput.push({ data, bytes: measurement.byteLength })
pendingOutput.push({ data, bytes: measurement.byteLength, meta })
pendingOutputBytes += measurement.byteLength
const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes)
pendingOutputBytes = trimmed.bytes
return
}
outputBatcher?.push(data)
outputBatcher?.push(data, meta)
})
const read = await runtime.readTerminal(params.terminal)
@ -1874,7 +1900,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
// the mobile client's stale-event filter knows the high-water mark.
// Undefined when the PTY has never transitioned (filter is fail-open).
// See docs/mobile-terminal-layout-state-machine.md.
const seq = runtime.getLayout(ptyId)?.seq
const layoutSeq = runtime.getLayout(ptyId)?.seq
const snapshotFrameSeq = serialized?.seq ?? layoutSeq
const snapshotOutputSeq = serialized?.seq
emit({
type: 'subscribed',
streamId,
@ -1883,14 +1911,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
cols: serialized?.cols ?? size?.cols,
rows: serialized?.rows ?? size?.rows,
displayMode,
seq
seq: layoutSeq
})
const snapshotStats = sendSnapshotFrames(sendFrame, {
kind: 'scrollback',
cols: serialized?.cols ?? size?.cols ?? 80,
rows: serialized?.rows ?? size?.rows ?? 24,
displayMode,
seq,
seq: snapshotFrameSeq,
truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read),
truncatedByByteBudget: serialized?.truncatedByByteBudget,
oscLinks: serialized?.oscLinks,
@ -1910,7 +1938,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
lastResizeCols = serialized?.cols ?? size?.cols
buffering = false
for (const item of pendingOutput.splice(0)) {
outputBatcher.push(item.data, item.meta)
const uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq)
if (uncoveredData) {
outputBatcher.push(uncoveredData, item.meta)
}
}
pendingOutputBytes = 0
outputBatcher.flush()

View File

@ -1026,6 +1026,275 @@ describe('terminal multiplex RPC', () => {
}
})
it('flushes output buffered during initial multiplex snapshot once', async () => {
vi.useFakeTimers()
try {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => void>()
const dataListenerRef: {
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
} = {}
let resolveSnapshot: (value: { data: string; cols: number; rows: number }) => void = () => {}
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn(
() =>
new Promise<{ data: string; cols: number; rows: number }>((resolve) => {
resolveSnapshot = resolve
})
),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn((_: string, listener) => {
dataListenerRef.current = listener
return vi.fn()
}),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateDesktopViewport: vi.fn().mockResolvedValue(true)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.subscribe', {
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' },
capabilities: { terminalBinaryStream: 1 }
}),
(msg) => messages.push(msg),
{
connectionId: 'conn-buffered-output-on-subscribe',
sendBinary: (bytes) => binaryFrames.push(bytes),
registerBinaryStreamHandler: vi.fn(() => vi.fn())
}
)
await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined())
dataListenerRef.current?.('starting shell\r\n', { seq: 16, rawLength: 16 })
resolveSnapshot({ data: '', cols: 120, rows: 40 })
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
)
await vi.runOnlyPendingTimersAsync()
const output = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
.join('')
expect(output).toBe('starting shell\r\n')
runtime.cleanupSubscription('terminal-1:desktop-1')
await dispatchPromise
} finally {
vi.useRealTimers()
}
})
it('drops buffered multiplex output already covered by the initial snapshot seq', async () => {
vi.useFakeTimers()
try {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => void>()
const dataListenerRef: {
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
} = {}
let resolveSnapshot: (value: {
data: string
cols: number
rows: number
seq: number
}) => void = () => {}
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn(
() =>
new Promise<{ data: string; cols: number; rows: number; seq: number }>((resolve) => {
resolveSnapshot = resolve
})
),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn((_: string, listener) => {
dataListenerRef.current = listener
return vi.fn()
}),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateDesktopViewport: vi.fn().mockResolvedValue(true)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.subscribe', {
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' },
capabilities: { terminalBinaryStream: 1 }
}),
(msg) => messages.push(msg),
{
connectionId: 'conn-buffered-output-covered-by-snapshot',
sendBinary: (bytes) => binaryFrames.push(bytes),
registerBinaryStreamHandler: vi.fn(() => vi.fn())
}
)
await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined())
const startupLine = 'starting shell\r\n'
dataListenerRef.current?.(startupLine, {
seq: startupLine.length,
rawLength: startupLine.length
})
resolveSnapshot({
data: startupLine,
cols: 120,
rows: 40,
seq: startupLine.length
})
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
)
await vi.runOnlyPendingTimersAsync()
const outputFrames = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
const snapshotStart = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart)
expect(snapshotStart && decodeTerminalStreamJson(snapshotStart.payload)).toMatchObject({
seq: startupLine.length
})
expect(outputFrames).toHaveLength(0)
runtime.cleanupSubscription('terminal-1:desktop-1')
await dispatchPromise
} finally {
vi.useRealTimers()
}
})
it('replays only buffered multiplex output not covered by the initial snapshot seq', async () => {
vi.useFakeTimers()
try {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => void>()
const dataListenerRef: {
current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
} = {}
let resolveSnapshot: (value: {
data: string
cols: number
rows: number
seq: number
}) => void = () => {}
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn(
() =>
new Promise<{ data: string; cols: number; rows: number; seq: number }>((resolve) => {
resolveSnapshot = resolve
})
),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn((_: string, listener) => {
dataListenerRef.current = listener
return vi.fn()
}),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateDesktopViewport: vi.fn().mockResolvedValue(true)
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.subscribe', {
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' },
capabilities: { terminalBinaryStream: 1 }
}),
(msg) => messages.push(msg),
{
connectionId: 'conn-buffered-output-partially-covered-by-snapshot',
sendBinary: (bytes) => binaryFrames.push(bytes),
registerBinaryStreamHandler: vi.fn(() => vi.fn())
}
)
await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined())
const buffered = 'hello world'
dataListenerRef.current?.(buffered, {
seq: buffered.length,
rawLength: buffered.length
})
resolveSnapshot({
data: 'hello',
cols: 120,
rows: 40,
seq: 'hello'.length
})
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true)
)
await vi.runOnlyPendingTimersAsync()
const output = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
.join('')
expect(output).toBe(' world')
runtime.cleanupSubscription('terminal-1:desktop-1')
await dispatchPromise
} finally {
vi.useRealTimers()
}
})
it('settles mobile multiplex PTY waits when the stream signal aborts before PTY spawn', async () => {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []

View File

@ -152,6 +152,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'files.open',
'files.openDiff',
'files.read',
'files.readChunk',
'files.readPreview',
'files.resolveTerminalPath',
'git.abortMerge',

View File

@ -2007,6 +2007,24 @@ export type PreloadApi = {
filePath: string
connectionId: string
}) => Promise<{ canceled: true } | { canceled: false; destinationPath: string }>
saveDownloadedFile: (args: {
suggestedName: string
content: string
encoding: 'utf8' | 'base64'
}) => Promise<{ canceled: true } | { canceled: false; destinationPath: string }>
startDownloadedFile: (args: {
suggestedName: string
}) => Promise<
{ canceled: true } | { canceled: false; transferId: string; destinationPath: string }
>
appendDownloadedFileChunk: (args: {
transferId: string
contentBase64: string
}) => Promise<{ ok: true }>
finishDownloadedFile: (args: {
transferId: string
}) => Promise<{ canceled: false; destinationPath: string }>
cancelDownloadedFile: (args: { transferId: string }) => Promise<{ ok: true }>
listMarkdownDocuments: (args: {
rootPath: string
connectionId?: string

View File

@ -2404,6 +2404,27 @@ const api = {
connectionId: string
}): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> =>
ipcRenderer.invoke('fs:downloadFile', args),
saveDownloadedFile: (args: {
suggestedName: string
content: string
encoding: 'utf8' | 'base64'
}): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> =>
ipcRenderer.invoke('fs:saveDownloadedFile', args),
startDownloadedFile: (args: {
suggestedName: string
}): Promise<
{ canceled: true } | { canceled: false; transferId: string; destinationPath: string }
> => ipcRenderer.invoke('fs:startDownloadedFile', args),
appendDownloadedFileChunk: (args: {
transferId: string
contentBase64: string
}): Promise<{ ok: true }> => ipcRenderer.invoke('fs:appendDownloadedFileChunk', args),
finishDownloadedFile: (args: {
transferId: string
}): Promise<{ canceled: false; destinationPath: string }> =>
ipcRenderer.invoke('fs:finishDownloadedFile', args),
cancelDownloadedFile: (args: { transferId: string }): Promise<{ ok: true }> =>
ipcRenderer.invoke('fs:cancelDownloadedFile', args),
listMarkdownDocuments: (args: {
rootPath: string
connectionId?: string

View File

@ -23,8 +23,10 @@ import {
import { FileExplorerVirtualRows } from './FileExplorerVirtualRows'
import type { TreeNode } from './file-explorer-types'
import { createFileExplorerRowProjection } from './file-explorer-row-projection'
import type * as RuntimeFileClient from '@/runtime/runtime-file-client'
const { toastErrorMock, toastSuccessMock } = vi.hoisted(() => ({
const { downloadRuntimeFileMock, toastErrorMock, toastSuccessMock } = vi.hoisted(() => ({
downloadRuntimeFileMock: vi.fn(),
toastErrorMock: vi.fn(),
toastSuccessMock: vi.fn()
}))
@ -36,6 +38,14 @@ vi.mock('sonner', () => ({
}
}))
vi.mock('@/runtime/runtime-file-client', async (importOriginal) => {
const actual = await importOriginal<typeof RuntimeFileClient>()
return {
...actual,
downloadRuntimeFile: downloadRuntimeFileMock
}
})
type ReactElementLike = {
type: unknown
props: Record<string, unknown>
@ -565,15 +575,24 @@ describe('FileExplorerRow collapse folder action', () => {
).toBe(false)
})
it('shows remote download only for desktop SSH file-like rows', () => {
it('shows remote download only for desktop SSH or Remote Host file-like rows', () => {
const runtimeContext = {
settings: { activeRuntimeEnvironmentId: 'runtime-1' },
worktreeId: 'wt-1',
worktreePath: '/repo'
}
expect(shouldShowRemoteDownloadAction(fileNode, 'ssh-1')).toBe(true)
expect(shouldShowRemoteDownloadAction({ ...fileNode, isSymlink: true }, 'ssh-1')).toBe(true)
expect(shouldShowRemoteDownloadAction(fileNode, null, runtimeContext)).toBe(true)
expect(shouldShowRemoteDownloadAction(fileNode, null)).toBe(false)
expect(shouldShowRemoteDownloadAction(directoryNode, 'ssh-1')).toBe(false)
expect(shouldShowRemoteDownloadAction(directoryNode, null, runtimeContext)).toBe(false)
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
expect(shouldShowRemoteDownloadAction(fileNode, 'ssh-1')).toBe(false)
expect(shouldShowRemoteDownloadAction(fileNode, null, runtimeContext)).toBe(false)
})
it('shows OS file copy for single local rows and SSH file rows on desktop', () => {
@ -677,6 +696,43 @@ describe('FileExplorerRow collapse folder action', () => {
expect(toastErrorMock).not.toHaveBeenCalled()
})
it('downloads Remote Host rows through the runtime download path', async () => {
const runtimeContext = {
settings: { activeRuntimeEnvironmentId: 'runtime-1' },
worktreeId: 'wt-1',
worktreePath: '/repo'
}
downloadRuntimeFileMock.mockResolvedValueOnce({
canceled: false,
destinationPath: '/downloads/index.ts'
})
const openPath = vi.fn().mockResolvedValue(undefined)
;(
globalThis as unknown as {
window: {
api: {
shell: { openPath: typeof openPath }
}
}
}
).window = { api: { shell: { openPath } } }
await downloadRemoteFile(fileNode, runtimeContext)
expect(downloadRuntimeFileMock).toHaveBeenCalledWith(
runtimeContext,
'/repo/src/index.ts',
'index.ts'
)
expect(toastSuccessMock).toHaveBeenCalledWith("Downloaded 'index.ts'", {
action: {
label: 'Open',
onClick: expect.any(Function)
}
})
expect(toastErrorMock).not.toHaveBeenCalled()
})
it('shows a failure toast when remote download fails', async () => {
const downloadFile = vi.fn().mockRejectedValue(new Error('Remote connection dropped'))
;(

View File

@ -53,6 +53,7 @@ import { useFileExplorerVisibleRowProjection } from './useFileExplorerVisibleRow
import { translate } from '@/i18n/i18n'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab'
import type { RightSidebarExplorerView } from '../../../../shared/types'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
function FileExplorerFiles(): React.JSX.Element {
const explorerView = useAppStore((s) => s.rightSidebarExplorerView)
@ -78,6 +79,9 @@ function FileExplorerFiles(): React.JSX.Element {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeWorktree = useActiveWorktree()
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
const activeRuntimeEnvironmentId = useAppStore((s) =>
getRuntimeEnvironmentIdForWorktree(s, activeWorktreeId)
)
const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration)
const expandedDirs = useAppStore((s) => s.expandedDirs)
const collapseAllDirs = useAppStore((s) => s.collapseAllDirs)
@ -99,6 +103,18 @@ function FileExplorerFiles(): React.JSX.Element {
const toggleShowDotfilesForWorktree = useAppStore((s) => s.toggleShowDotfilesForWorktree)
const worktreePath = activeWorktree?.path ?? null
const runtimeDownloadContext = useMemo(
() =>
activeRuntimeEnvironmentId && activeWorktreeId && worktreePath
? {
settings: { activeRuntimeEnvironmentId },
worktreeId: activeWorktreeId,
worktreePath,
connectionId: activeRepo?.connectionId ?? undefined
}
: null,
[activeRepo?.connectionId, activeRuntimeEnvironmentId, activeWorktreeId, worktreePath]
)
const isFilesViewActive = explorerView === 'files'
const visibleFilesWorktreePath = getVisibleFileExplorerWorktreePath({
explorerView,
@ -716,6 +732,7 @@ function FileExplorerFiles(): React.JSX.Element {
flashingPath={flashingPath}
deleteShortcutLabel={deleteShortcutLabel}
connectionId={activeRepo?.connectionId ?? null}
runtimeDownloadContext={runtimeDownloadContext}
onClick={handleRowClick}
onDoubleClick={handleDoubleClick}
onContextMenuSelect={preserveSelectionForContextMenu}

View File

@ -50,6 +50,7 @@ import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/loc
import { translate } from '@/i18n/i18n'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab'
import { downloadRuntimeFile, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
@ -270,6 +271,7 @@ type FileExplorerRowProps = {
isIgnored: boolean
deleteShortcutLabel: string
connectionId?: string | null
runtimeDownloadContext?: RuntimeFileOperationArgs | null
canCollapseFolderSubtree: boolean
targetDir: string
targetDepth: number
@ -304,12 +306,13 @@ export function shouldShowFindInFolderAction(node: TreeNode): boolean {
export function shouldShowRemoteDownloadAction(
node: TreeNode,
connectionId?: string | null
connectionId?: string | null,
runtimeDownloadContext?: RuntimeFileOperationArgs | null
): boolean {
// Why: Desktop-only because download depends on Electron's native save dialog.
return (
!node.isDirectory &&
Boolean(connectionId) &&
Boolean(connectionId || runtimeDownloadContext) &&
(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true
)
}
@ -328,9 +331,18 @@ export function shouldShowCopyFileAction(
)
}
export async function downloadRemoteFile(node: TreeNode, connectionId: string): Promise<void> {
export async function downloadRemoteFile(
node: TreeNode,
connectionIdOrRuntimeContext: string | RuntimeFileOperationArgs
): Promise<void> {
try {
const result = await window.api.fs.downloadFile({ filePath: node.path, connectionId })
const result =
typeof connectionIdOrRuntimeContext === 'string'
? await window.api.fs.downloadFile({
filePath: node.path,
connectionId: connectionIdOrRuntimeContext
})
: await downloadRuntimeFile(connectionIdOrRuntimeContext, node.path, node.name)
// Why: Suppress toasts when the user cancels the native save dialog per design.
if (result.canceled) {
return
@ -396,6 +408,7 @@ export function FileExplorerRow({
isIgnored,
deleteShortcutLabel,
connectionId,
runtimeDownloadContext,
canCollapseFolderSubtree,
targetDir,
targetDepth,
@ -426,7 +439,11 @@ export function FileExplorerRow({
const findInFolderShortcutLabel = useShortcutLabel('sidebar.search.toggle')
const FileIcon = getFileTypeIcon(node.relativePath || node.name)
const rowDropDir = node.isDirectory ? node.path : targetDir
const showRemoteDownloadAction = shouldShowRemoteDownloadAction(node, connectionId)
const showRemoteDownloadAction = shouldShowRemoteDownloadAction(
node,
connectionId,
runtimeDownloadContext
)
const showCopyFileAction = shouldShowCopyFileAction(node, connectionId, selectionSize)
const { setRowDragNode, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } =
useFileExplorerRowDrag({
@ -450,11 +467,12 @@ export function FileExplorerRow({
}
}, [activeWorktreeId, node.path])
const handleDownload = useCallback(() => {
if (!connectionId) {
const downloadTarget = connectionId || runtimeDownloadContext
if (!downloadTarget) {
return
}
void downloadRemoteFile(node, connectionId)
}, [connectionId, node])
void downloadRemoteFile(node, downloadTarget)
}, [connectionId, node, runtimeDownloadContext])
const handleCopyFile = useCallback(() => {
void copyFileToOsClipboard(node, connectionId)
}, [connectionId, node])

View File

@ -7,6 +7,7 @@ import { FileExplorerRow, InlineInputRow, type InlineInput } from './FileExplore
import { shouldShowIgnoredDecoration, STATUS_COLORS } from './status-display'
import type { DirCache, TreeNode } from './file-explorer-types'
import type { FileExplorerRowProjection } from './file-explorer-row-projection'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
type FileExplorerVirtualRowsProps = {
virtualizer: Virtualizer<HTMLDivElement, Element>
@ -26,6 +27,7 @@ type FileExplorerVirtualRowsProps = {
flashingPath: string | null
deleteShortcutLabel: string
connectionId?: string | null
runtimeDownloadContext?: RuntimeFileOperationArgs | null
onClick: (node: TreeNode, event: React.MouseEvent<HTMLButtonElement>) => void
onDoubleClick: (node: TreeNode) => void
onContextMenuSelect: (node: TreeNode) => void
@ -68,6 +70,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
flashingPath,
deleteShortcutLabel,
connectionId,
runtimeDownloadContext,
onClick,
onDoubleClick,
onContextMenuSelect,
@ -168,6 +171,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re
isIgnored={isIgnored}
deleteShortcutLabel={deleteShortcutLabel}
connectionId={connectionId}
runtimeDownloadContext={runtimeDownloadContext}
canCollapseFolderSubtree={canCollapseFolderSubtree}
targetDir={n.isDirectory ? n.path : dirname(n.path)}
targetDepth={n.isDirectory ? n.depth + 1 : n.depth}

View File

@ -6,6 +6,7 @@ import {
copyRuntimePath,
createRuntimePath,
deleteRuntimePath,
downloadRuntimeFile,
getRuntimeFileReadScope,
importExternalPathsToRuntime,
listRuntimeFiles,
@ -36,6 +37,12 @@ const fsDeletePath = vi.fn()
const fsStat = vi.fn()
const fsPathExists = vi.fn()
const fsSearch = vi.fn()
const fsDownloadFile = vi.fn()
const fsSaveDownloadedFile = vi.fn()
const fsStartDownloadedFile = vi.fn()
const fsAppendDownloadedFileChunk = vi.fn()
const fsFinishDownloadedFile = vi.fn()
const fsCancelDownloadedFile = vi.fn()
const fsImportExternalPaths = vi.fn()
const fsStageExternalPathsForRuntimeUpload = vi.fn()
const runtimeEnvironmentCall = vi.fn()
@ -56,6 +63,12 @@ beforeEach(() => {
fsStat.mockReset()
fsPathExists.mockReset()
fsSearch.mockReset()
fsDownloadFile.mockReset()
fsSaveDownloadedFile.mockReset()
fsStartDownloadedFile.mockReset()
fsAppendDownloadedFileChunk.mockReset()
fsFinishDownloadedFile.mockReset()
fsCancelDownloadedFile.mockReset()
fsImportExternalPaths.mockReset()
fsStageExternalPathsForRuntimeUpload.mockReset()
runtimeEnvironmentCall.mockReset()
@ -89,6 +102,12 @@ beforeEach(() => {
stat: fsStat,
pathExists: fsPathExists,
search: fsSearch,
downloadFile: fsDownloadFile,
saveDownloadedFile: fsSaveDownloadedFile,
startDownloadedFile: fsStartDownloadedFile,
appendDownloadedFileChunk: fsAppendDownloadedFileChunk,
finishDownloadedFile: fsFinishDownloadedFile,
cancelDownloadedFile: fsCancelDownloadedFile,
importExternalPaths: fsImportExternalPaths,
stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload
},
@ -348,6 +367,96 @@ describe('runtime file client', () => {
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('downloads remote runtime files in chunks instead of using preview content', async () => {
fsStartDownloadedFile.mockResolvedValue({
canceled: false,
transferId: 'download-1',
destinationPath: '/downloads/archive.zip'
})
fsAppendDownloadedFileChunk.mockResolvedValue({ ok: true })
fsFinishDownloadedFile.mockResolvedValue({
canceled: false,
destinationPath: '/downloads/archive.zip'
})
runtimeEnvironmentCall
.mockResolvedValueOnce({
id: 'chunk-1',
ok: true,
result: { contentBase64: 'YWJj', bytesRead: 3, eof: false },
_meta: { runtimeId: 'remote-runtime' }
})
.mockResolvedValueOnce({
id: 'chunk-2',
ok: true,
result: { contentBase64: 'ZA==', bytesRead: 1, eof: true },
_meta: { runtimeId: 'remote-runtime' }
})
await expect(
downloadRuntimeFile(
{
settings: { activeRuntimeEnvironmentId: 'env-1' },
worktreeId: 'wt-1',
worktreePath: '/remote/repo'
},
'/remote/repo/archive.zip',
'archive.zip'
)
).resolves.toEqual({ canceled: false, destinationPath: '/downloads/archive.zip' })
expect(fsStartDownloadedFile).toHaveBeenCalledWith({ suggestedName: 'archive.zip' })
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, {
selector: 'env-1',
method: 'files.readChunk',
params: {
worktree: 'id:wt-1',
relativePath: 'archive.zip',
offset: 0,
length: 384 * 1024
},
timeoutMs: 60_000
})
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, {
selector: 'env-1',
method: 'files.readChunk',
params: {
worktree: 'id:wt-1',
relativePath: 'archive.zip',
offset: 3,
length: 384 * 1024
},
timeoutMs: 60_000
})
expect(fsAppendDownloadedFileChunk).toHaveBeenCalledTimes(2)
expect(fsFinishDownloadedFile).toHaveBeenCalledWith({ transferId: 'download-1' })
expect(fsCancelDownloadedFile).not.toHaveBeenCalled()
})
it('cancels the local temp download when a remote chunk fails', async () => {
fsStartDownloadedFile.mockResolvedValue({
canceled: false,
transferId: 'download-1',
destinationPath: '/downloads/archive.zip'
})
fsCancelDownloadedFile.mockResolvedValue({ ok: true })
runtimeEnvironmentCall.mockRejectedValueOnce(new Error('connection dropped'))
await expect(
downloadRuntimeFile(
{
settings: { activeRuntimeEnvironmentId: 'env-1' },
worktreeId: 'wt-1',
worktreePath: '/remote/repo'
},
'/remote/repo/archive.zip',
'archive.zip'
)
).rejects.toThrow('connection dropped')
expect(fsCancelDownloadedFile).toHaveBeenCalledWith({ transferId: 'download-1' })
expect(fsFinishDownloadedFile).not.toHaveBeenCalled()
})
it('routes root directory reads with an empty relative path', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',

View File

@ -9,7 +9,11 @@ import type {
SearchOptions,
SearchResult
} from '../../../shared/types'
import type { RuntimeFilePreviewResult, RuntimeFileReadResult } from '../../../shared/runtime-types'
import type {
RuntimeFilePreviewResult,
RuntimeFileReadChunkResult,
RuntimeFileReadResult
} from '../../../shared/runtime-types'
import {
callRuntimeRpc,
getActiveRuntimeTarget,
@ -49,6 +53,10 @@ export type RuntimeFileOperationArgs = {
connectionId?: string
}
export type RuntimeFileDownloadResult =
| { canceled: true }
| { canceled: false; destinationPath: string }
type StagedRuntimeImportSource =
| {
sourcePath: string
@ -93,6 +101,7 @@ type RuntimeFileWatchEvent =
| { type: 'end' }
const REMOTE_UPLOAD_BASE64_CHUNK_CHARS = 512 * 1024
const REMOTE_DOWNLOAD_CHUNK_BYTES = 384 * 1024
type RuntimeFileWatchListener = {
onPayload: (payload: FsChangedPayload) => void
@ -179,6 +188,71 @@ export async function readRuntimeFilePreview(
)
}
export async function downloadRuntimeFile(
context: RuntimeFileOperationArgs,
filePath: string,
suggestedName: string
): Promise<RuntimeFileDownloadResult> {
const remoteArgs = getRemoteFileArgs(context, filePath)
if (!remoteArgs) {
if (hasRemoteRuntimeOwner(context)) {
throw new Error('Remote file is outside the owning runtime worktree')
}
if (context.connectionId) {
return window.api.fs.downloadFile({ filePath, connectionId: context.connectionId })
}
const result = await readRuntimeFilePreview(context, filePath)
return window.api.fs.saveDownloadedFile({
suggestedName,
content: result.content,
encoding: result.isBinary ? 'base64' : 'utf8'
})
}
const download = await window.api.fs.startDownloadedFile({ suggestedName })
if (download.canceled) {
return download
}
let finished = false
try {
let offset = 0
for (;;) {
const chunk = await callRuntimeRpc<RuntimeFileReadChunkResult>(
remoteArgs.target,
'files.readChunk',
{
worktree: remoteArgs.worktreeSelector,
relativePath: remoteArgs.relativePath,
offset,
length: REMOTE_DOWNLOAD_CHUNK_BYTES
},
{ timeoutMs: 60_000 }
)
if (chunk.bytesRead > 0) {
await window.api.fs.appendDownloadedFileChunk({
transferId: download.transferId,
contentBase64: chunk.contentBase64
})
}
offset += chunk.bytesRead
if (chunk.eof) {
break
}
if (chunk.bytesRead <= 0) {
throw new Error('Remote download stalled before reaching EOF')
}
}
const result = await window.api.fs.finishDownloadedFile({ transferId: download.transferId })
finished = true
return result
} finally {
if (!finished) {
await window.api.fs.cancelDownloadedFile({ transferId: download.transferId }).catch(() => {})
}
}
}
export async function readRuntimeDirectory(
context: RuntimeFileOperationArgs,
dirPath: string

View File

@ -1274,6 +1274,21 @@ function createFileApi(): NonNullable<Partial<PreloadApi>['fs']> {
downloadFile: async () => {
throw new Error('Remote file download is unavailable in paired web clients.')
},
saveDownloadedFile: async () => {
throw new Error('Remote file download is unavailable in paired web clients.')
},
startDownloadedFile: async () => {
throw new Error('Remote file download is unavailable in paired web clients.')
},
appendDownloadedFileChunk: async () => {
throw new Error('Remote file download is unavailable in paired web clients.')
},
finishDownloadedFile: async () => {
throw new Error('Remote file download is unavailable in paired web clients.')
},
cancelDownloadedFile: async () => {
throw new Error('Remote file download is unavailable in paired web clients.')
},
listMarkdownDocuments: async ({ rootPath }) => {
const file = await resolveRuntimeFilePath(rootPath)
return callRuntimeResult('files.listMarkdownDocuments', {

View File

@ -77,3 +77,25 @@ describe('MiMo title detection', () => {
}
)
})
describe('Pi-compatible title detection', () => {
it.each([
['\u280b OMP', 'OMP', 'working'],
['OMP ready', 'OMP', 'idle'],
['OMP - action required', 'OMP', 'permission'],
['\u280b Pi', 'Pi', 'working'],
['Pi ready', 'Pi', 'idle'],
['Pi - action required', 'Pi', 'permission']
] as const)('classifies synthesized %s', (title, expectedLabel, expectedStatus) => {
expect(getAgentLabel(title)).toBe(expectedLabel)
expect(detectAgentStatusFromTitle(title)).toBe(expectedStatus)
})
it.each(['~/omp/working', 'omp-harness ready', '~/pi/working', 'pi-scratch ready'])(
'does not classify path or hyphen false positive %s',
(title) => {
expect(getAgentLabel(title)).toBeNull()
expect(detectAgentStatusFromTitle(title)).toBeNull()
}
)
})

View File

@ -14,6 +14,10 @@ import {
titleHasAgentName,
titleHasAnyLegacyAgentName
} from './agent-name-token-match'
import {
getPiCompatibleSyntheticAgentLabel,
getPiCompatibleSyntheticAgentStatus
} from './pi-compatible-synthetic-title'
// Re-export so existing `agent-detection` importers keep working.
export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match'
@ -319,6 +323,12 @@ export function getAgentLabel(title: string): string | null {
if (isGeminiTerminalTitle(title)) {
return 'Gemini CLI'
}
// Why: Pi-compatible synthetic titles can carry braille spinners, which the
// generic agent-title heuristics would otherwise claim first.
const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title)
if (piCompatibleSyntheticAgentLabel) {
return piCompatibleSyntheticAgentLabel
}
// Why: Pi working titles include a braille spinner prefix, which would be
// mistaken for Claude Code if we checked `isClaudeAgent` first.
if (isPiAgentTitle(title)) {
@ -417,6 +427,13 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
return 'idle'
}
// Why: resolve synthetic Pi/OMP permission/idle labels before the broader
// Pi and braille-spinner checks below.
const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title)
if (piCompatibleSyntheticAgentStatus) {
return piCompatibleSyntheticAgentStatus
}
// Claude Code uses ✳ prefix for idle — must check before braille/agent-name
// because the title text is the task description, not "Claude Code".
if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) {

View File

@ -0,0 +1,49 @@
export type PiCompatibleSyntheticAgentLabel = 'Pi' | 'OMP'
export type PiCompatibleSyntheticAgentStatus = 'working' | 'permission' | 'idle'
const PI_COMPATIBLE_SYNTHETIC_TITLE_RE =
/^\s*(?:[\u2800-\u28ff]\s+)?(pi|omp)(?:\s+-\s+action required|\s+(?:ready|idle|done))?\s*$/i
const PI_COMPATIBLE_IDLE_RE = /(?<![\w./\\-])(?:ready|idle|done)(?![\w-])/i
function containsBrailleSpinner(title: string): boolean {
for (const char of title) {
const codePoint = char.codePointAt(0)
if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) {
return true
}
}
return false
}
export function getPiCompatibleSyntheticAgentLabel(
title: string
): PiCompatibleSyntheticAgentLabel | null {
const match = PI_COMPATIBLE_SYNTHETIC_TITLE_RE.exec(title)
if (!match) {
return null
}
return match[1].toLowerCase() === 'omp' ? 'OMP' : 'Pi'
}
export function getPiCompatibleSyntheticAgentStatus(
title: string
): PiCompatibleSyntheticAgentStatus | null {
if (!getPiCompatibleSyntheticAgentLabel(title)) {
return null
}
if (containsBrailleSpinner(title)) {
return 'working'
}
const lower = title.toLowerCase()
if (
lower.includes('action required') ||
lower.includes('permission') ||
lower.includes('waiting')
) {
return 'permission'
}
if (PI_COMPATIBLE_IDLE_RE.test(title)) {
return 'idle'
}
return null
}

View File

@ -327,6 +327,12 @@ export type RuntimeFilePreviewResult = {
mimeType?: string
}
export type RuntimeFileReadChunkResult = {
contentBase64: string
bytesRead: number
eof: boolean
}
export type RuntimeTerminalSummary = {
handle: string
ptyId: string | null

View File

@ -20,4 +20,16 @@ describe('synthetic agent titles', () => {
expect(getSyntheticAgentTerminalTitle('devin', 'waiting')).toBe('Devin - action required')
expect(shouldDriveSyntheticAgentTitleFromHook('devin', 'working')).toBe(true)
})
it('provides Pi-compatible OMP titles for hook-driven status updates', () => {
expect(getSyntheticAgentTerminalTitle('omp', 'done')).toBe('OMP ready')
expect(getSyntheticAgentTerminalTitle('omp', 'waiting')).toBe('OMP - action required')
expect(shouldDriveSyntheticAgentTitleFromHook('omp', 'working')).toBe(true)
})
it('provides Pi titles for hook-driven status updates', () => {
expect(getSyntheticAgentTerminalTitle('pi', 'done')).toBe('Pi ready')
expect(getSyntheticAgentTerminalTitle('pi', 'waiting')).toBe('Pi - action required')
expect(shouldDriveSyntheticAgentTitleFromHook('pi', 'working')).toBe(true)
})
})

View File

@ -26,6 +26,16 @@ export const SYNTHETIC_AGENT_TITLE_PROFILES: Record<string, SyntheticAgentTitleP
permissionLabel: 'OpenCode - action required',
idleLabel: 'OpenCode ready'
},
pi: {
workingLabel: 'Pi',
permissionLabel: 'Pi - action required',
idleLabel: 'Pi ready'
},
omp: {
workingLabel: 'OMP',
permissionLabel: 'OMP - action required',
idleLabel: 'OMP ready'
},
droid: {
workingLabel: 'Droid',
permissionLabel: 'Droid - action required',

View File

@ -0,0 +1,197 @@
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
} from './helpers/terminal'
import {
cleanupDockerSshRelayTarget,
DOCKER_SSH_RELAY_REMOTE_REPO_PATH,
startDockerSshRelayTarget,
type DockerSshRelayTarget
} from './helpers/docker-ssh-relay-target'
const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
type ConnectedDockerRemote = {
targetId: string
repoId: string
worktreeId: string
}
type RuntimeTerminalStatus = {
isRunningAgent: boolean
status: string | null
}
type RuntimeTerminalSummary = {
handle: string
ptyId: string | null
title: string | null
}
async function connectDockerRemote(
page: Page,
target: DockerSshRelayTarget
): Promise<ConnectedDockerRemote> {
return await page.evaluate(
async ({ target, remotePath }) => {
const store = window.__store
if (!store) {
throw new Error('Store unavailable')
}
const credentialUnsub = window.api.ssh.onCredentialRequest((request) => {
void window.api.ssh.submitCredential({ requestId: request.requestId, value: null })
})
try {
const createdTarget = await window.api.ssh.addTarget({
target: {
label: `Docker SSH Pi-Compatible Agent ${Date.now()}`,
host: '127.0.0.1',
port: target.port,
username: 'root',
identityFile: target.identityFile,
identitiesOnly: true,
relayGracePeriodSeconds: 1
}
})
const state = await window.api.ssh.connect({ targetId: createdTarget.id })
if (!state || state.status !== 'connected') {
throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`)
}
store.getState().setSshConnectionState(createdTarget.id, state)
const labels = new Map(store.getState().sshTargetLabels)
labels.set(createdTarget.id, createdTarget.label)
store.getState().setSshTargetLabels(labels)
const result = await window.api.repos.addRemote({
connectionId: createdTarget.id,
remotePath,
displayName: 'Docker SSH Pi-Compatible Agent'
})
if ('error' in result) {
throw new Error(result.error)
}
await store.getState().fetchRepos()
await store.getState().fetchWorktrees(result.repo.id)
const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? [])[0]
if (!worktree) {
throw new Error(`No remote worktree found for ${result.repo.path}`)
}
store.getState().setActiveWorktree(worktree.id)
if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) {
store.getState().createTab(worktree.id)
}
store.getState().setActiveTabType('terminal')
return {
targetId: createdTarget.id,
repoId: result.repo.id,
worktreeId: worktree.id
}
} finally {
credentialUnsub()
}
},
{ target, remotePath: DOCKER_SSH_RELAY_REMOTE_REPO_PATH }
)
}
async function emitOscTitle(page: Page, ptyId: string, title: string): Promise<void> {
await sendToTerminal(page, ptyId, `printf '\\033]0;${title}\\007'\r`)
}
async function findTerminalByPtyId(page: Page, ptyId: string): Promise<string> {
return page.evaluate(async (ptyId) => {
const response = await window.api.runtime.call({
method: 'terminal.list',
params: { limit: 50 }
})
if (!response.ok) {
throw new Error(response.error.message)
}
const terminals = (response.result as { terminals: RuntimeTerminalSummary[] }).terminals
const terminal = terminals.find((candidate) => candidate.ptyId === ptyId)
if (!terminal) {
throw new Error(
`No runtime terminal for PTY ${ptyId}; terminals=${JSON.stringify(terminals)}`
)
}
return terminal.handle
}, ptyId)
}
async function readTerminalAgentStatus(
page: Page,
terminalHandle: string
): Promise<RuntimeTerminalStatus> {
return page.evaluate(async (terminal) => {
const response = await window.api.runtime.call({
method: 'terminal.agentStatus',
params: { terminal }
})
if (!response.ok) {
throw new Error(response.error.message)
}
return (response.result as { agentStatus: RuntimeTerminalStatus }).agentStatus
}, terminalHandle)
}
test.describe('Docker SSH Pi-compatible agent titles', () => {
test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH relay tests.')
test.skip(process.platform === 'win32', 'Docker SSH relay tests use POSIX ssh tooling.')
test('classifies OMP and Pi title transitions from a remote terminal', async ({
orcaPage
}, testInfo) => {
test.slow()
let target: DockerSshRelayTarget | null = null
try {
target = startDockerSshRelayTarget(testInfo)
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
const remote = await connectDockerRemote(orcaPage, target)
await ensureTerminalVisible(orcaPage, 45_000)
await waitForActiveTerminalManager(orcaPage, 60_000)
const ptyId = await waitForActivePanePtyId(orcaPage, 60_000)
const terminalHandle = await findTerminalByPtyId(orcaPage, ptyId)
const marker = `PI_COMPATIBLE_TITLE_READY_${Date.now()}`
await sendToTerminal(orcaPage, ptyId, `printf '${marker}\\n'\r`)
await waitForTerminalOutput(orcaPage, marker, 20_000, 60_000)
await emitOscTitle(orcaPage, ptyId, '\u280b OMP')
await expect
.poll(async () => readTerminalAgentStatus(orcaPage, terminalHandle), {
timeout: 10_000,
message: 'Remote OMP working title did not classify as an agent status'
})
.toMatchObject({ isRunningAgent: true, status: 'working' })
await emitOscTitle(orcaPage, ptyId, 'OMP ready')
await expect
.poll(async () => readTerminalAgentStatus(orcaPage, terminalHandle), {
timeout: 10_000,
message: 'Remote OMP ready title did not classify as idle'
})
.toMatchObject({ isRunningAgent: true, status: 'idle' })
await emitOscTitle(orcaPage, ptyId, '\u280b Pi')
await expect
.poll(async () => readTerminalAgentStatus(orcaPage, terminalHandle), {
timeout: 10_000,
message: 'Remote Pi working title did not classify as an agent status'
})
.toMatchObject({ isRunningAgent: true, status: 'working' })
testInfo.annotations.push({
type: 'docker-ssh-pi-compatible-title',
description: `target=${remote.targetId} repo=${remote.repoId} worktree=${remote.worktreeId} pty=${ptyId} terminal=${terminalHandle}`
})
} finally {
cleanupDockerSshRelayTarget(target)
}
})
})