diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml
index 157a1ba773e..0d31113a0e0 100644
--- a/.github/workflows/mobile.yml
+++ b/.github/workflows/mobile.yml
@@ -9,6 +9,9 @@ on:
- ready_for_review
paths:
- 'mobile/**'
+ # Why: the mobile terminal link parsers are conformance-tested against
+ # these shared fixtures; desktop-side fixture edits must re-run this suite.
+ - 'src/shared/terminal-file-link-conformance.ts'
jobs:
verify:
diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml
index df152e9fb31..b792dd9cf47 100644
--- a/.github/workflows/release-cut.yml
+++ b/.github/workflows/release-cut.yml
@@ -1,6 +1,6 @@
name: Cut Release
-# Why: single entry point for manually and scheduled cutting releases.
+# Why: single entry point for manually cutting releases.
# Replaces the old local `pnpm release:*` scripts and the standalone scheduled
# RC workflow so releases are always reproducible from CI and can never be
# accidentally tagged against an uncommitted or non-main working tree.
@@ -37,16 +37,10 @@ on:
type: string
default: main
dry_run:
- description: Validate a scheduled-RC run without creating a tag
+ description: Validate an RC release cut without creating a tag
required: false
default: false
type: boolean
- schedule:
- # Why: GitHub scheduled workflows are not guaranteed to start exactly on
- # time and can be delayed or dropped around high-load periods. We retry
- # across the full target hour and dedupe per PT slot. Cron is UTC, so run
- # during both PST and PDT equivalents and let the PT-hour gate decide.
- - cron: '*/5 10,11,22,23 * * *'
permissions:
contents: write
@@ -182,8 +176,8 @@ jobs:
- name: Publish complete release-cut RC drafts from prior runs
id: publish_drafts
- # Why: this must run even when the current slot already cut a tag; a
- # later cron retry may be the first chance to unstick a complete RC draft.
+ # Why: a manual RC dispatch should unstick any complete RC draft before
+ # deciding whether to cut another tag.
if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -889,6 +883,11 @@ jobs:
if: matrix.platform == 'win'
shell: pwsh
run: |
+ # Why: Some hosted Windows images start without PSGallery registered.
+ if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
+ Register-PSRepository -Default -InstallationPolicy Trusted
+ }
+
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
$trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
diff --git a/README.md b/README.md
index e5f11ec0b90..f5cace99b1e 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.18](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [Download APK 0.0.18](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
+- **Android:** [Download APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
---
diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs
index 5ee9f1a3017..c106b166feb 100644
--- a/config/scripts/package-electron-runtime-contract.test.mjs
+++ b/config/scripts/package-electron-runtime-contract.test.mjs
@@ -194,6 +194,10 @@ describe('Electron runtime package contract', () => {
expect(installStep.if).toBe("matrix.platform == 'win'")
expect(installStep.shell).toBe('pwsh')
+ expect(installRun).toContain(
+ 'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))'
+ )
+ expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted')
expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted')
expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/)
expect(installRun).toContain(
@@ -217,6 +221,23 @@ describe('Electron runtime package contract', () => {
expect(installRun).not.toMatch(/throw\s+\$_/)
})
+ it('temporarily allows publishing Windows after verifying the signed installer only', () => {
+ const releaseWorkflow = readFileSync(
+ join(projectDir, '.github/workflows/release-cut.yml'),
+ 'utf8'
+ )
+ const parsedWorkflow = parse(releaseWorkflow)
+ const steps = parsedWorkflow.jobs.build.steps
+ const stepNames = steps.map((step) => step.name)
+ const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer')
+ const innerVerifyIndex = stepNames.indexOf('Verify signed Windows inner executable')
+ const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts')
+
+ expect(outerVerifyIndex).toBeGreaterThan(-1)
+ expect(innerVerifyIndex).toBe(-1)
+ expect(publishIndex).toBe(outerVerifyIndex + 1)
+ })
+
it('publishes both Linux release matrix entries', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
diff --git a/config/scripts/repro-windows-apphang-terminal-activation.mjs b/config/scripts/repro-windows-apphang-terminal-activation.mjs
new file mode 100644
index 00000000000..73e7bd4a13f
--- /dev/null
+++ b/config/scripts/repro-windows-apphang-terminal-activation.mjs
@@ -0,0 +1,196 @@
+#!/usr/bin/env node
+
+import { mkdirSync, writeFileSync } from 'node:fs'
+import path from 'node:path'
+import process from 'node:process'
+import {
+ createWslFixture,
+ listWslDistros,
+ removeWslFixture
+} from './windows-apphang-repro/wsl-workspace-fixture.mjs'
+import { runGpuMode } from './windows-apphang-repro/terminal-activation-scenario.mjs'
+import { summarizeResult } from './windows-apphang-repro/apphang-report-summary.mjs'
+
+const defaultCycles = 14
+const defaultOutputLines = 1_600
+
+function parseArgs() {
+ const args = {
+ cycles: defaultCycles,
+ distro: null,
+ expect: 'none',
+ gpuModes: ['on'],
+ keep: false,
+ outputLines: defaultOutputLines,
+ reportPath: null,
+ sourceControl: true,
+ deadPtyReactivate: true
+ }
+
+ for (const arg of process.argv.slice(2)) {
+ if (arg === '--help' || arg === '-h') {
+ printHelp()
+ process.exit(0)
+ }
+ if (arg === '--keep') {
+ args.keep = true
+ continue
+ }
+ if (arg === '--no-source-control') {
+ args.sourceControl = false
+ continue
+ }
+ if (arg === '--no-dead-pty-reactivate') {
+ args.deadPtyReactivate = false
+ continue
+ }
+ const [name, value] = arg.split('=', 2)
+ if (name === '--cycles') {
+ args.cycles = parsePositiveInt(name, value)
+ continue
+ }
+ if (name === '--distro') {
+ args.distro = value?.trim() || null
+ continue
+ }
+ if (name === '--expect') {
+ if (!['none', 'repro', 'pass'].includes(value)) {
+ throw new Error(`Unsupported --expect=${value}. Use none, repro, or pass.`)
+ }
+ args.expect = value
+ continue
+ }
+ if (name === '--gpu') {
+ const modes = (value ?? '')
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter(Boolean)
+ if (modes.length === 0 || modes.some((mode) => !['on', 'off', 'auto'].includes(mode))) {
+ throw new Error(`Unsupported --gpu=${value}. Use on, off, auto, or a comma-list.`)
+ }
+ args.gpuModes = modes
+ continue
+ }
+ if (name === '--output-lines') {
+ args.outputLines = parsePositiveInt(name, value)
+ continue
+ }
+ if (name === '--report') {
+ args.reportPath = value?.trim() || null
+ if (!args.reportPath) {
+ throw new Error('--report requires a file path.')
+ }
+ continue
+ }
+ throw new Error(`Unknown argument: ${arg}`)
+ }
+
+ return args
+}
+
+function printHelp() {
+ console.log(`Usage:
+ node config/scripts/repro-windows-apphang-terminal-activation.mjs [options]
+
+Options:
+ --expect=none|repro|pass none prints measurements, repro exits 0 only when hang evidence is observed,
+ pass exits 1 if hang evidence is observed. Default: none.
+ --gpu=on|off|auto[,mode...] Terminal GPU setting(s) to run. Default: on.
+ --cycles=N Activation/output cycles per GPU mode. Default: ${defaultCycles}.
+ --output-lines=N Lines emitted by each terminal stress command. Default: ${defaultOutputLines}.
+ --report=PATH Write full JSON evidence to PATH and print a compact summary to stdout.
+ --distro=NAME WSL distro to use. Default: first non docker-desktop distro.
+ --no-source-control Do not open Source Control during the stress loop.
+ --no-dead-pty-reactivate Do not kill PTYs and revisit workspaces after initial activation.
+ --keep Keep disposable WSL/userData fixtures after the run.`)
+}
+
+function parsePositiveInt(name, value) {
+ const parsed = Number.parseInt(value ?? '', 10)
+ if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== value) {
+ throw new Error(`${name} requires a positive integer.`)
+ }
+ return parsed
+}
+
+async function main() {
+ if (process.platform !== 'win32') {
+ throw new Error('This repro harness is intentionally Windows-only.')
+ }
+ const args = parseArgs()
+ const distros = listWslDistros()
+ const distro = args.distro ?? distros[0]
+ if (!distro) {
+ throw new Error('No user WSL distro found. Install/enable WSL or pass --distro=NAME.')
+ }
+ console.log(
+ `[apphang-repro] issue=https://github.com/stablyai/orca/issues/6874 distro=${distro} gpuModes=${args.gpuModes.join(',')} cycles=${args.cycles}`
+ )
+ const fixture = createWslFixture(distro)
+ console.log(
+ `[apphang-repro] fixture repo=${fixture.repoUncPath} plain=${fixture.plainUncPath} base=${fixture.baseLinuxPath}`
+ )
+
+ const results = []
+ try {
+ for (const gpuMode of args.gpuModes) {
+ results.push(await runGpuMode(gpuMode, args, fixture))
+ if (args.expect === 'repro' && results.at(-1)?.reproduced) {
+ break
+ }
+ }
+ } finally {
+ if (!args.keep) {
+ removeWslFixture(fixture)
+ }
+ }
+
+ const payload = {
+ issue: 'https://github.com/stablyai/orca/issues/6874',
+ expect: args.expect,
+ distro,
+ fixture: args.keep ? fixture : { removed: true, baseLinuxPath: fixture.baseLinuxPath },
+ summary: results.map(summarizeResult),
+ results
+ }
+ if (args.reportPath) {
+ const reportPath = path.resolve(args.reportPath)
+ mkdirSync(path.dirname(reportPath), { recursive: true })
+ writeFileSync(reportPath, `${JSON.stringify(payload, null, 2)}\n`)
+ console.log(JSON.stringify({ reportPath, summary: payload.summary }, null, 2))
+ } else {
+ console.log(JSON.stringify(payload, null, 2))
+ }
+
+ // Why: a broken harness run (CDP/setup/selector failure) proves nothing —
+ // it must fail both --expect=repro and --expect=pass rather than being
+ // miscounted as hang evidence or as a clean pass.
+ const harnessErrors = results.filter((result) => result.harnessError)
+ if (harnessErrors.length > 0) {
+ const harnessErrorLines = harnessErrors
+ .map((result) => ` gpu=${result.gpuMode}: ${result.harnessError}`)
+ .join('\n')
+ console.error(
+ `[apphang-repro] Harness failed in ${harnessErrors.length} run(s); result inconclusive:\n${harnessErrorLines}`
+ )
+ if (args.expect !== 'none') {
+ process.exit(1)
+ }
+ }
+ const reproduced = results.some((result) => result.reproduced)
+ if (args.expect === 'repro' && !reproduced) {
+ console.error(
+ '[apphang-repro] Expected to reproduce the hang, but no hang evidence was observed.'
+ )
+ process.exit(1)
+ }
+ if (args.expect === 'pass' && reproduced) {
+ console.error('[apphang-repro] Expected a clean pass, but hang evidence was observed.')
+ process.exit(1)
+ }
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.stack || error.message : String(error))
+ process.exit(1)
+})
diff --git a/config/scripts/verify-windows-inner-signature.mjs b/config/scripts/verify-windows-inner-signature.mjs
new file mode 100644
index 00000000000..e0c5e303dbc
--- /dev/null
+++ b/config/scripts/verify-windows-inner-signature.mjs
@@ -0,0 +1,206 @@
+import { spawnSync } from 'node:child_process'
+import { existsSync, statSync } from 'node:fs'
+import { pathToFileURL } from 'node:url'
+
+export const DEFAULT_EXPECTED_SIGNER =
+ 'CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US'
+
+const POWERSHELL_SIGNATURE_SCRIPT = String.raw`
+$ErrorActionPreference = 'Stop'
+$signature = Get-AuthenticodeSignature -FilePath $env:ORCA_WINDOWS_INNER_EXECUTABLE
+$certificate = $signature.SignerCertificate
+[pscustomobject]@{
+ status = $signature.Status.ToString()
+ statusMessage = $signature.StatusMessage
+ signerSubject = if ($null -eq $certificate) { $null } else { $certificate.Subject }
+ signerIssuer = if ($null -eq $certificate) { $null } else { $certificate.Issuer }
+ signerThumbprint = if ($null -eq $certificate) { $null } else { $certificate.Thumbprint }
+ notBefore = if ($null -eq $certificate) { $null } else { $certificate.NotBefore.ToString('o') }
+ notAfter = if ($null -eq $certificate) { $null } else { $certificate.NotAfter.ToString('o') }
+} | ConvertTo-Json -Compress
+`
+
+export function normalizeSignerSubject(subject) {
+ if (typeof subject !== 'string') {
+ return ''
+ }
+
+ return subject
+ .split(',')
+ .map((part) => part.trim().replace(/\s*=\s*/u, '='))
+ .filter(Boolean)
+ .join(', ')
+}
+
+export function normalizeThumbprint(thumbprint) {
+ if (typeof thumbprint !== 'string') {
+ return ''
+ }
+
+ return thumbprint.replace(/[^0-9a-f]/giu, '').toUpperCase()
+}
+
+export function parseExpectedSigners(value = process.env.ORCA_WINDOWS_EXPECTED_SIGNERS) {
+ const source = typeof value === 'string' && value.trim() !== '' ? value : DEFAULT_EXPECTED_SIGNER
+
+ return source
+ .split(/[\r\n;]+/u)
+ .map(normalizeSignerSubject)
+ .filter(Boolean)
+}
+
+export function parseExpectedThumbprints(value = process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS) {
+ if (typeof value !== 'string' || value.trim() === '') {
+ return []
+ }
+
+ return value
+ .split(/[\r\n,;]+/u)
+ .map(normalizeThumbprint)
+ .filter(Boolean)
+}
+
+export function parseSignatureJson(stdout) {
+ const trimmed = typeof stdout === 'string' ? stdout.trim() : ''
+ if (trimmed === '') {
+ throw new Error('PowerShell did not return signature JSON.')
+ }
+
+ try {
+ return JSON.parse(trimmed)
+ } catch (error) {
+ throw new Error(`PowerShell returned malformed signature JSON: ${error.message}`)
+ }
+}
+
+export function classifySignature(signature, options = {}) {
+ const expectedSigners = options.expectedSigners ?? parseExpectedSigners()
+ const expectedThumbprints = options.expectedThumbprints ?? parseExpectedThumbprints()
+ const status = typeof signature?.status === 'string' ? signature.status : ''
+ const signerSubject = normalizeSignerSubject(signature?.signerSubject)
+ const signerThumbprint = normalizeThumbprint(signature?.signerThumbprint)
+ const subjectAllowed = expectedSigners.includes(signerSubject)
+ const thumbprintAllowed =
+ expectedThumbprints.length > 0 &&
+ signerThumbprint !== '' &&
+ expectedThumbprints.includes(signerThumbprint)
+
+ if (status !== 'Valid') {
+ return {
+ ok: false,
+ message: `Windows inner executable signature status is ${status || ''}.`,
+ signature
+ }
+ }
+
+ if (!subjectAllowed && !thumbprintAllowed) {
+ return {
+ ok: false,
+ message: `Unexpected Windows inner executable signer: ${signerSubject || ''}.`,
+ signature
+ }
+ }
+
+ return { ok: true, signature }
+}
+
+export function formatSignatureSummary(signature) {
+ return [
+ `Status: ${signature.status ?? ''}`,
+ `Subject: ${normalizeSignerSubject(signature.signerSubject) || ''}`,
+ `Issuer: ${signature.signerIssuer ?? ''}`,
+ `Thumbprint: ${normalizeThumbprint(signature.signerThumbprint) || ''}`,
+ `NotBefore: ${signature.notBefore ?? ''}`,
+ `NotAfter: ${signature.notAfter ?? ''}`
+ ].join('\n')
+}
+
+export function validateExecutablePath(executablePath) {
+ if (typeof executablePath !== 'string' || executablePath.trim() === '') {
+ throw new Error('Usage: node config/scripts/verify-windows-inner-signature.mjs ')
+ }
+
+ if (!existsSync(executablePath)) {
+ throw new Error(`Windows inner executable does not exist: ${executablePath}`)
+ }
+
+ if (!statSync(executablePath).isFile()) {
+ throw new Error(`Windows inner executable path is not a file: ${executablePath}`)
+ }
+}
+
+export function getPowerShellSignatureJson(executablePath, spawnSyncImpl = spawnSync) {
+ // Why: pwsh -Command does not reliably expose trailing process args to string commands.
+ const result = spawnSyncImpl(
+ 'pwsh',
+ [
+ '-NoLogo',
+ '-NoProfile',
+ '-NonInteractive',
+ '-ExecutionPolicy',
+ 'Bypass',
+ '-Command',
+ POWERSHELL_SIGNATURE_SCRIPT
+ ],
+ {
+ encoding: 'utf8',
+ env: {
+ ...process.env,
+ ORCA_WINDOWS_INNER_EXECUTABLE: executablePath
+ }
+ }
+ )
+
+ if (result.error) {
+ throw result.error
+ }
+
+ if (result.stderr?.trim()) {
+ throw new Error(`PowerShell wrote to stderr while checking signature:\n${result.stderr.trim()}`)
+ }
+
+ if (result.status !== 0) {
+ throw new Error(
+ `PowerShell signature check failed with exit code ${result.status ?? ''}.`
+ )
+ }
+
+ return result.stdout
+}
+
+export function verifyWindowsInnerSignature({
+ executablePath,
+ platform = process.platform,
+ spawnSyncImpl = spawnSync,
+ expectedSigners = parseExpectedSigners(),
+ expectedThumbprints = parseExpectedThumbprints()
+}) {
+ validateExecutablePath(executablePath)
+
+ if (platform !== 'win32') {
+ throw new Error('Windows inner executable signature verification requires Windows.')
+ }
+
+ const signature = parseSignatureJson(getPowerShellSignatureJson(executablePath, spawnSyncImpl))
+ const classification = classifySignature(signature, { expectedSigners, expectedThumbprints })
+ if (!classification.ok) {
+ throw new Error(`${classification.message}\n${formatSignatureSummary(signature)}`)
+ }
+
+ return signature
+}
+
+export function main(argv = process.argv.slice(2)) {
+ try {
+ const signature = verifyWindowsInnerSignature({ executablePath: argv[0] })
+ console.log('Verified Windows inner executable signature.')
+ console.log(formatSignatureSummary(signature))
+ } catch (error) {
+ console.error(error.message)
+ process.exitCode = 1
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main()
+}
diff --git a/config/scripts/verify-windows-inner-signature.test.mjs b/config/scripts/verify-windows-inner-signature.test.mjs
new file mode 100644
index 00000000000..d833fb64774
--- /dev/null
+++ b/config/scripts/verify-windows-inner-signature.test.mjs
@@ -0,0 +1,218 @@
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+
+import {
+ DEFAULT_EXPECTED_SIGNER,
+ classifySignature,
+ getPowerShellSignatureJson,
+ normalizeSignerSubject,
+ normalizeThumbprint,
+ parseExpectedSigners,
+ parseExpectedThumbprints,
+ parseSignatureJson,
+ validateExecutablePath,
+ verifyWindowsInnerSignature
+} from './verify-windows-inner-signature.mjs'
+
+const validSignature = {
+ status: 'Valid',
+ statusMessage: 'Signature verified.',
+ signerSubject: DEFAULT_EXPECTED_SIGNER,
+ signerIssuer: 'CN=SignPath Foundation Root',
+ signerThumbprint: 'aa bb cc dd',
+ notBefore: '2026-01-01T00:00:00.0000000Z',
+ notAfter: '2027-01-01T00:00:00.0000000Z'
+}
+
+function withTempFile(callback) {
+ const dir = mkdtempSync(join(tmpdir(), 'orca-inner-signature-'))
+ const filePath = join(dir, 'Orca.exe')
+ writeFileSync(filePath, 'placeholder executable')
+
+ try {
+ return callback(filePath, dir)
+ } finally {
+ rmSync(dir, { force: true, recursive: true })
+ }
+}
+
+describe('verify-windows-inner-signature', () => {
+ const originalExpectedSigners = process.env.ORCA_WINDOWS_EXPECTED_SIGNERS
+ const originalExpectedThumbprints = process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS
+
+ beforeEach(() => {
+ delete process.env.ORCA_WINDOWS_EXPECTED_SIGNERS
+ delete process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS
+ })
+
+ afterEach(() => {
+ if (originalExpectedSigners === undefined) {
+ delete process.env.ORCA_WINDOWS_EXPECTED_SIGNERS
+ } else {
+ process.env.ORCA_WINDOWS_EXPECTED_SIGNERS = originalExpectedSigners
+ }
+
+ if (originalExpectedThumbprints === undefined) {
+ delete process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS
+ } else {
+ process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS = originalExpectedThumbprints
+ }
+ })
+
+ it('normalizes signer subjects without widening exact matching', () => {
+ expect(
+ normalizeSignerSubject(
+ ' CN = SignPath Foundation , O=SignPath Foundation,L=Lewes,S=Delaware,C=US '
+ )
+ ).toBe(DEFAULT_EXPECTED_SIGNER)
+ expect(
+ normalizeSignerSubject(
+ 'CN=Different Signer, O=SignPath Foundation, L=Lewes, S=Delaware, C=US'
+ )
+ ).not.toBe(DEFAULT_EXPECTED_SIGNER)
+ })
+
+ it('defaults the expected signer allowlist to SignPath Foundation', () => {
+ expect(parseExpectedSigners('')).toEqual([DEFAULT_EXPECTED_SIGNER])
+ expect(parseExpectedSigners(' ')).toEqual([DEFAULT_EXPECTED_SIGNER])
+ })
+
+ it('parses semicolon and newline separated signer allowlists', () => {
+ expect(parseExpectedSigners(`${DEFAULT_EXPECTED_SIGNER};\nCN=Backup, O=Backup`)).toEqual([
+ DEFAULT_EXPECTED_SIGNER,
+ 'CN=Backup, O=Backup'
+ ])
+ })
+
+ it('normalizes optional thumbprint allowlists', () => {
+ expect(normalizeThumbprint('aa bb:cc')).toBe('AABBCC')
+ expect(parseExpectedThumbprints('aa bb cc,11:22:33')).toEqual(['AABBCC', '112233'])
+ })
+
+ it('rejects missing, nonexistent, and directory executable paths before PowerShell', () => {
+ expect(() => validateExecutablePath('')).toThrow(/Usage:/)
+ expect(() => validateExecutablePath(join(tmpdir(), 'missing-Orca.exe'))).toThrow(
+ /does not exist/
+ )
+
+ withTempFile((filePath, dir) => {
+ expect(() => validateExecutablePath(dir)).toThrow(/not a file/)
+ expect(() => validateExecutablePath(filePath)).not.toThrow()
+ })
+ })
+
+ it('parses the exact JSON emitted by PowerShell', () => {
+ expect(parseSignatureJson(JSON.stringify(validSignature))).toEqual(validSignature)
+ expect(() => parseSignatureJson('')).toThrow(/did not return/)
+ expect(() => parseSignatureJson(`${JSON.stringify(validSignature)}\nextra`)).toThrow(
+ /malformed/
+ )
+ })
+
+ it('accepts a valid signature with an exact normalized signer subject', () => {
+ const result = classifySignature({
+ ...validSignature,
+ signerSubject: ' CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US '
+ })
+
+ expect(result.ok).toBe(true)
+ })
+
+ it('rejects invalid status and unexpected signer subjects', () => {
+ expect(classifySignature({ ...validSignature, status: 'NotSigned' }).message).toMatch(
+ /status is NotSigned/
+ )
+ expect(
+ classifySignature({
+ ...validSignature,
+ signerSubject: 'CN=SignPath Foundation Test, O=SignPath Foundation, L=Vienna, C=AT'
+ }).message
+ ).toMatch(/Unexpected Windows inner executable signer/)
+ })
+
+ it('accepts an expected thumbprint as an alternate explicit allowlist', () => {
+ expect(classifySignature({ ...validSignature, signerThumbprint: '00' }).ok).toBe(true)
+ expect(
+ classifySignature(
+ { ...validSignature, signerSubject: 'CN=Rotated Signer, O=Rotated' },
+ {
+ expectedSigners: [DEFAULT_EXPECTED_SIGNER],
+ expectedThumbprints: ['AABBCCDD']
+ }
+ ).ok
+ ).toBe(true)
+ expect(
+ classifySignature(
+ { ...validSignature, signerSubject: 'CN=Rotated Signer, O=Rotated' },
+ {
+ expectedSigners: [DEFAULT_EXPECTED_SIGNER],
+ expectedThumbprints: ['001122']
+ }
+ ).message
+ ).toMatch(/Unexpected Windows inner executable signer/)
+ expect(
+ classifySignature(validSignature, {
+ expectedSigners: [DEFAULT_EXPECTED_SIGNER],
+ expectedThumbprints: ['001122']
+ }).ok
+ ).toBe(true)
+ })
+
+ it('runs PowerShell with an argument array and fails on stderr or nonzero exit', () => {
+ const calls = []
+ const spawnSyncImpl = (command, args, options) => {
+ calls.push({ command, args, options })
+ return { status: 0, stdout: JSON.stringify(validSignature), stderr: '' }
+ }
+
+ expect(getPowerShellSignatureJson('C:\\Path With Spaces\\Orca.exe', spawnSyncImpl)).toBe(
+ JSON.stringify(validSignature)
+ )
+ expect(calls[0].command).toBe('pwsh')
+ expect(calls[0].args).toContain('-Command')
+ expect(calls[0].args.at(-1)).not.toBe('C:\\Path With Spaces\\Orca.exe')
+ expect(calls[0].options).toEqual(
+ expect.objectContaining({
+ encoding: 'utf8',
+ env: expect.objectContaining({
+ ORCA_WINDOWS_INNER_EXECUTABLE: 'C:\\Path With Spaces\\Orca.exe'
+ })
+ })
+ )
+
+ expect(() =>
+ getPowerShellSignatureJson('Orca.exe', () => ({ status: 0, stdout: '{}', stderr: 'warning' }))
+ ).toThrow(/stderr/)
+ expect(() =>
+ getPowerShellSignatureJson('Orca.exe', () => ({ status: 7, stdout: '', stderr: '' }))
+ ).toThrow(/exit code 7/)
+ })
+
+ it('verifies with injected Windows platform and spawn implementation', () => {
+ withTempFile((filePath) => {
+ const signature = verifyWindowsInnerSignature({
+ executablePath: filePath,
+ platform: 'win32',
+ spawnSyncImpl: () => ({ status: 0, stdout: JSON.stringify(validSignature), stderr: '' })
+ })
+
+ expect(signature).toEqual(validSignature)
+ })
+ })
+
+ it('does not attempt real Authenticode verification outside Windows', () => {
+ withTempFile((filePath) => {
+ expect(() =>
+ verifyWindowsInnerSignature({
+ executablePath: filePath,
+ platform: 'linux',
+ spawnSyncImpl: () => {
+ throw new Error('should not spawn')
+ }
+ })
+ ).toThrow(/requires Windows/)
+ })
+ })
+})
diff --git a/config/scripts/windows-apphang-repro/apphang-report-summary.mjs b/config/scripts/windows-apphang-repro/apphang-report-summary.mjs
new file mode 100644
index 00000000000..b6d222cb4de
--- /dev/null
+++ b/config/scripts/windows-apphang-repro/apphang-report-summary.mjs
@@ -0,0 +1,77 @@
+import { existsSync, readFileSync } from 'node:fs'
+import path from 'node:path'
+
+export function summarizeTrace(userDataDir) {
+ const tracePath = path.join(userDataDir, 'logs', 'main.trace.ndjson')
+ if (!existsSync(tracePath)) {
+ return { tracePath, exists: false }
+ }
+ const lines = readFileSync(tracePath, 'utf8').trim().split(/\r?\n/).filter(Boolean)
+ const parsed = []
+ for (const line of lines.slice(-400)) {
+ try {
+ parsed.push(JSON.parse(line))
+ } catch {
+ parsed.push({ raw: line })
+ }
+ }
+ const interesting = parsed.filter((entry) => {
+ const name = String(entry.name ?? entry.event ?? entry.type ?? '')
+ const text = JSON.stringify(entry)
+ return (
+ name.includes('git.exec') ||
+ name.includes('renderer_memory') ||
+ name.includes('sidebar_worktree_activate') ||
+ text.includes('git.exec remote') ||
+ text.includes('git.exec worktree') ||
+ text.includes('sidebar_worktree_activate')
+ )
+ })
+ return {
+ tracePath,
+ exists: true,
+ totalLines: lines.length,
+ interestingTail: interesting.slice(-40)
+ }
+}
+
+export function summarizeAppLogs(logs) {
+ const webglContextWarnings = logs.filter((entry) =>
+ entry.line.includes('Too many active WebGL contexts')
+ )
+ const webglContextLosses = logs.filter((entry) =>
+ entry.line.toLowerCase().includes('webgl context lost')
+ )
+ const gpuProcessEvents = logs.filter((entry) => /gpu|d3d|angle/i.test(entry.line))
+ return {
+ lineCount: logs.length,
+ webglContextWarningCount: webglContextWarnings.length,
+ webglContextWarningsTail: webglContextWarnings.slice(-10),
+ webglContextLossCount: webglContextLosses.length,
+ webglContextLossesTail: webglContextLosses.slice(-10),
+ gpuProcessEventCount: gpuProcessEvents.length,
+ gpuProcessEventsTail: gpuProcessEvents.slice(-20)
+ }
+}
+
+export function summarizeResult(result) {
+ const samples = result.samples.filter((sample) => sample && typeof sample === 'object')
+ const maxActivationMs = Math.max(0, ...samples.map((sample) => sample.activationMs ?? 0))
+ const maxPtyWaitMs = Math.max(0, ...samples.map((sample) => sample.ptyWaitMs ?? 0))
+ const maxMainIpcMs = Math.max(0, ...samples.map((sample) => sample.mainIpcMs ?? 0))
+ const maxRendererDriftMs = Math.max(0, ...samples.map((sample) => sample.rendererMaxDriftMs ?? 0))
+ return {
+ gpuMode: result.gpuMode,
+ reproduced: result.reproduced,
+ reproductionReason: result.reproductionReason,
+ harnessError: result.harnessError ?? null,
+ elapsedMs: result.elapsedMs,
+ sampleCount: samples.length,
+ maxActivationMs,
+ maxPtyWaitMs,
+ maxMainIpcMs,
+ maxRendererDriftMs,
+ finalWebglContextCounts: result.finalDiagnostics?.webglContextCounts ?? null,
+ appLogSummary: result.appLogSummary
+ }
+}
diff --git a/config/scripts/windows-apphang-repro/electron-dev-session.mjs b/config/scripts/windows-apphang-repro/electron-dev-session.mjs
new file mode 100644
index 00000000000..89fddf4ef99
--- /dev/null
+++ b/config/scripts/windows-apphang-repro/electron-dev-session.mjs
@@ -0,0 +1,331 @@
+import { chromium } from '@playwright/test'
+import { spawn } from 'node:child_process'
+import { mkdtempSync } from 'node:fs'
+import net from 'node:net'
+import os from 'node:os'
+import path from 'node:path'
+import process from 'node:process'
+import { fileURLToPath } from 'node:url'
+import {
+ appShutdownTimeoutMs,
+ cdpPollTimeoutMs,
+ delay,
+ pollUntil,
+ rendererActionTimeoutMs,
+ runWithTimeout
+} from './repro-timing.mjs'
+import { createCompletedOnboardingProfile } from './wsl-workspace-fixture.mjs'
+
+const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url)))
+
+function isPortFree(port) {
+ return new Promise((resolve) => {
+ const server = net.createServer()
+ server.once('error', () => {
+ try {
+ server.close()
+ } catch {}
+ resolve(false)
+ })
+ server.once('listening', () => server.close(() => resolve(true)))
+ server.listen(port, '127.0.0.1')
+ })
+}
+
+export async function pickFreePort() {
+ for (let port = 9533; port < 9633; port += 1) {
+ if (await isPortFree(port)) {
+ return port
+ }
+ }
+ throw new Error('Could not find a free CDP port in 9533..9632.')
+}
+
+export function createGpuUserDataDirectory(gpuMode) {
+ const userDataDir = mkdtempSync(path.join(os.tmpdir(), `orca-apphang-${gpuMode}-userdata-`))
+ createCompletedOnboardingProfile(userDataDir)
+ return userDataDir
+}
+
+export function launchDevApp({ cdpPort, userDataDir }) {
+ const env = { ...process.env }
+ delete env.ELECTRON_RUN_AS_NODE
+ Object.assign(env, {
+ ELECTRON_ENABLE_LOGGING: '1',
+ ELECTRON_ENABLE_STACK_DUMPING: '1',
+ NODE_ENV: 'development',
+ ORCA_DEV_USER_DATA_PATH: userDataDir,
+ ORCA_SKIP_DEV_WEB_PREPARE: '1',
+ ORCA_STARTUP_DIAGNOSTICS: '1',
+ REMOTE_DEBUGGING_PORT: String(cdpPort),
+ VITE_EXPOSE_STORE: 'true'
+ })
+ const child = spawn(
+ process.execPath,
+ [path.join('config', 'scripts', 'run-electron-vite-dev.mjs')],
+ {
+ cwd: rootDir,
+ env,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ windowsHide: true
+ }
+ )
+ const logs = []
+ const collect = (source) => (chunk) => {
+ const text = chunk.toString()
+ for (const line of text.split(/\r?\n/).filter(Boolean)) {
+ logs.push({ source, line, at: Date.now() })
+ console.log(`[apphang-repro:${source}] ${line}`)
+ }
+ }
+ child.stdout?.on('data', collect('stdout'))
+ child.stderr?.on('data', collect('stderr'))
+ return { child, logs }
+}
+
+export async function stopDevApp(child) {
+ if (!child?.pid || child.exitCode !== null || child.signalCode !== null) {
+ return
+ }
+ await new Promise((resolve) => {
+ const timer = setTimeout(resolve, appShutdownTimeoutMs)
+ child.once('exit', () => {
+ clearTimeout(timer)
+ resolve()
+ })
+ if (process.platform === 'win32') {
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
+ stdio: 'ignore',
+ windowsHide: true
+ })
+ killer.once('exit', () => undefined)
+ return
+ }
+ child.kill('SIGTERM')
+ })
+}
+
+async function waitForCdp(port) {
+ const url = `http://127.0.0.1:${port}/json`
+ const startedAt = Date.now()
+ while (Date.now() - startedAt < cdpPollTimeoutMs) {
+ try {
+ const response = await fetch(url)
+ if (response.ok) {
+ const targets = await response.json()
+ if (Array.isArray(targets) && targets.some((target) => target.type)) {
+ return targets
+ }
+ }
+ } catch {}
+ await delay(500)
+ }
+ throw new Error(`Timed out waiting for CDP targets on ${url}`)
+}
+
+async function getMainPage(browser) {
+ const startedAt = Date.now()
+ while (Date.now() - startedAt < cdpPollTimeoutMs) {
+ for (const context of browser.contexts()) {
+ const pages = context.pages()
+ const page =
+ pages.find((candidate) =>
+ /^https?:\/\/127\.0\.0\.1:|^https?:\/\/localhost:/.test(candidate.url())
+ ) ?? pages[0]
+ if (page) {
+ return page
+ }
+ }
+ await delay(250)
+ }
+ throw new Error('Timed out waiting for the Electron renderer page.')
+}
+
+export async function connectToApp(cdpPort) {
+ await waitForCdp(cdpPort)
+ const browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`)
+ const page = await getMainPage(browser)
+ await page.waitForLoadState('domcontentloaded', { timeout: 30_000 })
+ return { browser, page }
+}
+
+export async function installRendererProbe(page) {
+ await page.evaluate(() => {
+ if (globalThis.__orcaApphangProbe) {
+ return
+ }
+ const probe = {
+ intervalMs: 50,
+ last: performance.now(),
+ maxDriftMs: 0,
+ samples: 0,
+ startedAt: performance.now(),
+ lastTickAt: performance.now()
+ }
+ const timer = setInterval(() => {
+ const now = performance.now()
+ const drift = Math.max(0, now - probe.last - probe.intervalMs)
+ probe.maxDriftMs = Math.max(probe.maxDriftMs, drift)
+ probe.last = now
+ probe.lastTickAt = now
+ probe.samples += 1
+ }, probe.intervalMs)
+ globalThis.__orcaApphangProbe = { probe, timer }
+ })
+}
+
+export async function waitForStoreReady(page) {
+ await pollUntil(
+ 'renderer store exposure',
+ () => page.evaluate(() => Boolean(window.__store && window.api)),
+ Boolean,
+ 30_000
+ )
+ await pollUntil(
+ 'workspace session hydration',
+ () =>
+ page.evaluate(() => {
+ const state = window.__store?.getState?.()
+ return Boolean(state?.workspaceSessionReady && state?.hydrationSucceeded)
+ }),
+ Boolean,
+ 45_000
+ )
+}
+
+export async function collectRendererDiagnostics(page) {
+ if (!page) {
+ return null
+ }
+ try {
+ return await runWithTimeout(
+ 'renderer diagnostics',
+ () =>
+ page.evaluate(async () => {
+ const timed = (label, promise) =>
+ Promise.race([
+ Promise.resolve(promise).then(
+ (value) => ({ value }),
+ (error) => ({ error: error instanceof Error ? error.message : String(error) })
+ ),
+ new Promise((resolve) =>
+ setTimeout(() => resolve({ error: `Timed out collecting ${label}` }), 1_000)
+ )
+ ])
+ const readWebglIdentity = () => {
+ const canvas = document.createElement('canvas')
+ let gl = null
+ try {
+ gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl')
+ if (!gl) {
+ return { available: false, vendor: null, renderer: null }
+ }
+ const debugInfo = gl.getExtension('WEBGL_debug_renderer_info')
+ if (!debugInfo) {
+ return { available: true, vendor: null, renderer: null }
+ }
+ return {
+ available: true,
+ vendor: String(gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) ?? '') || null,
+ renderer: String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) ?? '') || null
+ }
+ } catch (error) {
+ return {
+ available: false,
+ error: error instanceof Error ? error.message : String(error)
+ }
+ } finally {
+ try {
+ gl?.getExtension('WEBGL_lose_context')?.loseContext()
+ } catch {}
+ canvas.width = 0
+ canvas.height = 0
+ }
+ }
+ const allPaneManagersDiagnostics = Array.from(
+ window.__paneManagers?.entries?.() ?? []
+ ).map(([managerTabId, paneManager]) => ({
+ tabId: managerTabId,
+ diagnostics: paneManager?.getRenderingDiagnostics?.() ?? []
+ }))
+ const webglContextCounts = allPaneManagersDiagnostics.reduce(
+ (acc, entry) => {
+ acc.managerCount += 1
+ acc.paneCount += entry.diagnostics.length
+ for (const diagnostic of entry.diagnostics) {
+ if (diagnostic.hasWebgl) {
+ acc.attachedWebglCount += 1
+ }
+ if (diagnostic.webglAttachmentDeferred) {
+ acc.deferredWebglCount += 1
+ }
+ }
+ return acc
+ },
+ { attachedWebglCount: 0, deferredWebglCount: 0, managerCount: 0, paneCount: 0 }
+ )
+ const state = window.__store?.getState?.()
+ const worktreeId = state?.activeWorktreeId ?? null
+ const tabId =
+ state?.activeTabType === 'terminal'
+ ? state.activeTabId
+ : worktreeId
+ ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
+ : null
+ const manager = tabId ? window.__paneManagers?.get(tabId) : null
+ const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
+ const buffer = activePane?.terminal?.buffer?.active ?? null
+ return {
+ hasStore: Boolean(window.__store),
+ workspaceSessionReady: state?.workspaceSessionReady ?? null,
+ hydrationSucceeded: state?.hydrationSucceeded ?? null,
+ activeView: state?.activeView ?? null,
+ activeRepoId: state?.activeRepoId ?? null,
+ activeWorktreeId: worktreeId,
+ activeTabType: state?.activeTabType ?? null,
+ activeTabId: tabId,
+ terminalGpuAcceleration: state?.settings?.terminalGpuAcceleration ?? null,
+ repoCount: state?.repos?.length ?? null,
+ worktreeCountsByRepo: Object.fromEntries(
+ Object.entries(state?.worktreesByRepo ?? {}).map(([repoId, worktrees]) => [
+ repoId,
+ worktrees.length
+ ])
+ ),
+ ptyIdsByTabId: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : [],
+ paneManagerCount: window.__paneManagers?.size ?? null,
+ activePane: activePane
+ ? {
+ id: activePane.id ?? null,
+ leafId: activePane.leafId ?? null,
+ ptyId: activePane.container?.dataset?.ptyId ?? null,
+ cols: activePane.terminal?.cols ?? null,
+ rows: activePane.terminal?.rows ?? null,
+ buffer: buffer
+ ? {
+ baseY: buffer.baseY,
+ viewportY: buffer.viewportY,
+ cursorY: buffer.cursorY,
+ length: buffer.length
+ }
+ : null
+ }
+ : null,
+ renderingDiagnostics: manager?.getRenderingDiagnostics?.() ?? null,
+ allPaneManagersDiagnostics,
+ webglContextCounts,
+ webglIdentity: readWebglIdentity(),
+ rendererProbe: globalThis.__orcaApphangProbe?.probe ?? null,
+ ptySessions: await timed('PTY sessions', window.api?.pty?.listSessions?.()),
+ rendererDeliveryDebug: await timed(
+ 'renderer delivery debug',
+ window.api?.pty?.getRendererDeliveryDebugSnapshot?.()
+ )
+ }
+ }),
+ rendererActionTimeoutMs
+ )
+ } catch (error) {
+ return { error: error instanceof Error ? error.message : String(error) }
+ }
+}
diff --git a/config/scripts/windows-apphang-repro/repro-timing.mjs b/config/scripts/windows-apphang-repro/repro-timing.mjs
new file mode 100644
index 00000000000..9f335d61435
--- /dev/null
+++ b/config/scripts/windows-apphang-repro/repro-timing.mjs
@@ -0,0 +1,47 @@
+export const cdpPollTimeoutMs = 90_000
+export const rendererActionTimeoutMs = 5_000
+export const activationTimeoutMs = 15_000
+export const ptyWaitTimeoutMs = 20_000
+export const terminalMarkerTimeoutMs = 45_000
+export const setupTimeoutMs = 90_000
+export const appShutdownTimeoutMs = 8_000
+export const severeRendererDriftMs = 2_000
+export const severeActivationMs = 5_000
+export const severeMainIpcMs = 2_000
+export const severePtyWaitMs = 10_000
+
+export function delay(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+export async function runWithTimeout(label, action, timeoutMs) {
+ const actionPromise = Promise.resolve().then(action)
+ actionPromise.catch(() => undefined)
+ const result = await Promise.race([
+ actionPromise.then(
+ (value) => ({ timedOut: false, value }),
+ (error) => ({ timedOut: false, error })
+ ),
+ delay(timeoutMs).then(() => ({ timedOut: true }))
+ ])
+ if (result.timedOut) {
+ throw new Error(`Timed out during ${label} after ${timeoutMs}ms.`)
+ }
+ if ('error' in result) {
+ throw result.error
+ }
+ return result.value
+}
+
+export async function pollUntil(label, read, predicate, timeoutMs, intervalMs = 100) {
+ const startedAt = Date.now()
+ let lastValue = null
+ while (Date.now() - startedAt < timeoutMs) {
+ lastValue = await runWithTimeout(label, read, rendererActionTimeoutMs)
+ if (predicate(lastValue)) {
+ return lastValue
+ }
+ await delay(intervalMs)
+ }
+ throw new Error(`Timed out waiting for ${label}; last value: ${JSON.stringify(lastValue)}`)
+}
diff --git a/config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs b/config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs
new file mode 100644
index 00000000000..db92c7cdf31
--- /dev/null
+++ b/config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs
@@ -0,0 +1,486 @@
+import {
+ collectRendererDiagnostics,
+ connectToApp,
+ createGpuUserDataDirectory,
+ installRendererProbe,
+ launchDevApp,
+ pickFreePort,
+ stopDevApp,
+ waitForStoreReady
+} from './electron-dev-session.mjs'
+import { summarizeAppLogs, summarizeTrace } from './apphang-report-summary.mjs'
+import {
+ activationTimeoutMs,
+ pollUntil,
+ ptyWaitTimeoutMs,
+ rendererActionTimeoutMs,
+ runWithTimeout,
+ setupTimeoutMs,
+ severeActivationMs,
+ severeMainIpcMs,
+ severePtyWaitMs,
+ severeRendererDriftMs,
+ terminalMarkerTimeoutMs
+} from './repro-timing.mjs'
+import { safeRemoveLocalDirectory } from './wsl-workspace-fixture.mjs'
+
+class ReproductionObservedError extends Error {
+ constructor(message, evidence) {
+ super(message)
+ this.name = 'ReproductionObservedError'
+ this.evidence = evidence
+ }
+}
+
+async function setupAppFixture(page, fixture, gpuMode, sourceControl) {
+ return await runWithTimeout(
+ 'fixture registration in Orca',
+ () =>
+ page.evaluate(
+ async ({ repoPath, plainPath, importedWorktreePaths, mode, openSourceControl }) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('window.__store is unavailable.')
+ }
+ const state = store.getState()
+ await state.fetchSettings?.()
+ await store.getState().updateSettings({ terminalGpuAcceleration: mode })
+
+ const addResult = await window.api.repos.add({ path: repoPath, kind: 'git' })
+ if ('error' in addResult) {
+ throw new Error(addResult.error)
+ }
+ await store.getState().fetchRepos()
+ let nextState = store.getState()
+ const repo =
+ nextState.repos.find((candidate) => candidate.path === repoPath) ?? addResult.repo
+ await nextState.updateRepo(repo.id, {
+ externalWorktreeVisibility: 'show',
+ externalWorktreeVisibilityPromptDismissedAt: Date.now(),
+ importedExternalWorktreePaths: importedWorktreePaths,
+ externalWorktreeInboxBaselinePaths: importedWorktreePaths
+ })
+ await store.getState().fetchWorktrees(repo.id, { requireAuthoritative: true })
+
+ const plainRepo = await store.getState().addNonGitFolder(plainPath)
+ if (!plainRepo) {
+ throw new Error('addNonGitFolder returned null.')
+ }
+ await store.getState().fetchWorktrees(plainRepo.id, { requireAuthoritative: true })
+
+ nextState = store.getState()
+ nextState.setSidebarOpen(true)
+ nextState.setGroupBy('none')
+ nextState.setSortBy('recent')
+ nextState.setShowActiveOnly(false)
+ nextState.setActiveView('terminal')
+ if (openSourceControl) {
+ nextState.setRightSidebarOpen(true)
+ nextState.setRightSidebarTab('source-control')
+ }
+
+ const gitWorktrees = nextState.worktreesByRepo[repo.id] ?? []
+ const plainWorktrees = nextState.worktreesByRepo[plainRepo.id] ?? []
+ return {
+ repoId: repo.id,
+ repoPath: repo.path,
+ plainRepoId: plainRepo.id,
+ gitWorktrees: gitWorktrees.map((worktree) => ({
+ id: worktree.id,
+ path: worktree.path,
+ displayName: worktree.displayName,
+ branch: worktree.branch,
+ isMainWorktree: worktree.isMainWorktree
+ })),
+ plainWorktrees: plainWorktrees.map((worktree) => ({
+ id: worktree.id,
+ path: worktree.path,
+ displayName: worktree.displayName,
+ branch: worktree.branch,
+ isMainWorktree: worktree.isMainWorktree
+ }))
+ }
+ },
+ {
+ repoPath: fixture.repoUncPath,
+ plainPath: fixture.plainUncPath,
+ importedWorktreePaths: fixture.worktreeUncPaths,
+ mode: gpuMode,
+ openSourceControl: sourceControl
+ }
+ ),
+ setupTimeoutMs
+ )
+}
+
+async function clickWorktreeCard(page, worktreeId) {
+ const rect = await runWithTimeout(
+ `locate worktree card ${worktreeId}`,
+ () =>
+ page.evaluate((id) => {
+ const rows = Array.from(document.querySelectorAll('[data-worktree-id]'))
+ const row = rows.find((candidate) => candidate.getAttribute('data-worktree-id') === id)
+ if (!row) {
+ return null
+ }
+ row.scrollIntoView({ block: 'center', inline: 'nearest' })
+ const surface = row.querySelector('[data-worktree-card-surface="true"]') ?? row
+ const bounds = surface.getBoundingClientRect()
+ if (bounds.width <= 0 || bounds.height <= 0) {
+ return null
+ }
+ return {
+ x: bounds.left + bounds.width / 2,
+ y: bounds.top + bounds.height / 2,
+ width: bounds.width,
+ height: bounds.height
+ }
+ }, worktreeId),
+ rendererActionTimeoutMs
+ )
+ if (!rect) {
+ throw new Error(`Could not find rendered worktree card for ${worktreeId}`)
+ }
+ await runWithTimeout(
+ `click worktree card ${worktreeId}`,
+ () => page.mouse.click(rect.x, rect.y),
+ rendererActionTimeoutMs
+ )
+}
+
+async function waitForActiveWorktree(page, worktreeId) {
+ return await pollUntil(
+ `active worktree ${worktreeId}`,
+ () =>
+ page.evaluate((id) => {
+ const state = window.__store?.getState?.()
+ return {
+ activeWorktreeId: state?.activeWorktreeId ?? null,
+ activeTabId: state?.activeTabId ?? null,
+ activeTabType: state?.activeTabType ?? null,
+ tabs: state?.tabsByWorktree?.[id]?.map((tab) => tab.id) ?? []
+ }
+ }, worktreeId),
+ (value) => value?.activeWorktreeId === worktreeId && value.activeTabType === 'terminal',
+ activationTimeoutMs
+ )
+}
+
+async function waitForActivePty(page, worktreeId) {
+ const startedAt = Date.now()
+ const value = await pollUntil(
+ `active PTY for ${worktreeId}`,
+ () =>
+ page.evaluate((id) => {
+ const state = window.__store?.getState?.()
+ const tabId =
+ state?.activeWorktreeId === id && state.activeTabType === 'terminal'
+ ? state.activeTabId
+ : (state?.activeTabIdByWorktree?.[id] ?? null)
+ const manager = tabId ? window.__paneManagers?.get(tabId) : null
+ const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
+ const ptyId = pane?.container?.dataset?.ptyId ?? null
+ return {
+ tabId,
+ ptyId,
+ hasManager: Boolean(manager),
+ paneCount: manager?.getPanes?.()?.length ?? 0,
+ ptyIdsByTab: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : []
+ }
+ }, worktreeId),
+ (value) => Boolean(value?.ptyId),
+ ptyWaitTimeoutMs
+ )
+ return { ...value, waitMs: Date.now() - startedAt }
+}
+
+function makeOutputCommand(marker, outputLines) {
+ const payload = 'x'.repeat(180)
+ return `printf '${marker}_START\\n'; i=1; while [ "$i" -le ${outputLines} ]; do printf '${marker}_%05d ${payload}\\n' "$i"; i=$((i+1)); done; printf '${marker}_DONE\\n'\r`
+}
+
+async function getTerminalContent(page, charLimit = 20_000) {
+ return await runWithTimeout(
+ 'terminal content',
+ () =>
+ page.evaluate((limit) => {
+ const state = window.__store?.getState?.()
+ const worktreeId = state?.activeWorktreeId
+ const tabId =
+ state?.activeTabType === 'terminal'
+ ? state.activeTabId
+ : worktreeId
+ ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
+ : null
+ const manager = tabId ? window.__paneManagers?.get(tabId) : null
+ const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
+ return (pane?.serializeAddon?.serialize?.() ?? '').slice(-limit)
+ }, charLimit),
+ rendererActionTimeoutMs
+ )
+}
+
+async function sendOutputStress(page, ptyId, marker, outputLines) {
+ await runWithTimeout(
+ `write terminal stress ${marker}`,
+ () =>
+ page.evaluate(
+ ({ id, command }) => {
+ window.api.pty.write(id, command)
+ },
+ { id: ptyId, command: makeOutputCommand(marker, outputLines) }
+ ),
+ rendererActionTimeoutMs
+ )
+ await pollUntil(
+ `terminal stress marker ${marker}`,
+ async () => (await getTerminalContent(page)).includes(`${marker}_DONE`),
+ Boolean,
+ terminalMarkerTimeoutMs,
+ 200
+ )
+}
+
+async function pingMainIpc(page) {
+ const startedAt = Date.now()
+ await runWithTimeout(
+ 'main IPC ping',
+ () => page.evaluate(() => window.api.pty.listSessions()),
+ rendererActionTimeoutMs
+ )
+ return Date.now() - startedAt
+}
+
+async function readRendererProbe(page) {
+ return await runWithTimeout(
+ 'renderer probe read',
+ () => page.evaluate(() => globalThis.__orcaApphangProbe?.probe ?? null),
+ rendererActionTimeoutMs
+ )
+}
+
+async function killActivePty(page) {
+ return await runWithTimeout(
+ 'kill active PTY',
+ () =>
+ page.evaluate(async () => {
+ const state = window.__store?.getState?.()
+ const tabId = state?.activeTabId ?? null
+ const ptyIds = tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : []
+ const ptyId = ptyIds[0] ?? null
+ if (!ptyId) {
+ return null
+ }
+ await window.api.pty.kill(ptyId)
+ return ptyId
+ }),
+ rendererActionTimeoutMs
+ )
+}
+
+async function resetRendererProbe(page) {
+ // Why: probe drift is cumulative since installation; without a per-cycle
+ // reset, one startup/setup stall gets re-reported as a hang in every
+ // later cycle's rendererMaxDriftMs check.
+ await runWithTimeout(
+ 'renderer probe reset',
+ () =>
+ page.evaluate(() => {
+ const probe = globalThis.__orcaApphangProbe?.probe
+ if (!probe) {
+ return
+ }
+ const now = performance.now()
+ probe.last = now
+ probe.lastTickAt = now
+ probe.startedAt = now
+ probe.maxDriftMs = 0
+ probe.samples = 0
+ }),
+ rendererActionTimeoutMs
+ )
+}
+
+async function runActivationCycle(page, target, args) {
+ await resetRendererProbe(page)
+ const marker = `ORCA_APPHANG_${target.index}_${Date.now()}`
+ const cycleStartedAt = Date.now()
+ const activationStartedAt = Date.now()
+ await clickWorktreeCard(page, target.id)
+ const activation = await waitForActiveWorktree(page, target.id)
+ const activationMs = Date.now() - activationStartedAt
+ const activePty = await waitForActivePty(page, target.id)
+ const diagnosticsBeforeOutput = await collectRendererDiagnostics(page)
+ const outputStartedAt = Date.now()
+ await sendOutputStress(page, activePty.ptyId, marker, args.outputLines)
+ const outputMs = Date.now() - outputStartedAt
+ const mainIpcMs = await pingMainIpc(page)
+ const rendererProbe = await readRendererProbe(page)
+ const diagnosticsAfterOutput = await collectRendererDiagnostics(page)
+ const elapsedMs = Date.now() - cycleStartedAt
+ const sample = {
+ index: target.index,
+ kind: target.kind,
+ worktreeId: target.id,
+ displayName: target.displayName,
+ elapsedMs,
+ activationMs,
+ outputMs,
+ activation,
+ ptyWaitMs: activePty.waitMs,
+ ptyId: activePty.ptyId,
+ tabId: activePty.tabId,
+ mainIpcMs,
+ rendererMaxDriftMs: rendererProbe?.maxDriftMs ?? null,
+ renderingDiagnostics: diagnosticsAfterOutput?.renderingDiagnostics ?? null,
+ webglIdentity: diagnosticsAfterOutput?.webglIdentity ?? null,
+ diagnosticsBeforeOutput,
+ diagnosticsAfterOutput
+ }
+
+ const reproducedReasons = []
+ if (activationMs > severeActivationMs) {
+ reproducedReasons.push(`worktree activation took ${activationMs}ms`)
+ }
+ if (activePty.waitMs > severePtyWaitMs) {
+ reproducedReasons.push(`PTY binding took ${activePty.waitMs}ms`)
+ }
+ if (mainIpcMs > severeMainIpcMs) {
+ reproducedReasons.push(`main IPC ping took ${mainIpcMs}ms`)
+ }
+ if ((rendererProbe?.maxDriftMs ?? 0) > severeRendererDriftMs) {
+ reproducedReasons.push(`renderer timer drift reached ${Math.round(rendererProbe.maxDriftMs)}ms`)
+ }
+ if (reproducedReasons.length > 0) {
+ throw new ReproductionObservedError(reproducedReasons.join('; '), sample)
+ }
+ return sample
+}
+
+async function runDeadPtyReactivation(page, targets, args) {
+ const samples = []
+ for (const target of targets) {
+ await clickWorktreeCard(page, target.id)
+ await waitForActiveWorktree(page, target.id)
+ await waitForActivePty(page, target.id)
+ const killedPtyId = await killActivePty(page)
+ samples.push({ worktreeId: target.id, killedPtyId })
+ }
+ for (const target of targets) {
+ samples.push(
+ await runActivationCycle(page, { ...target, kind: `${target.kind}:dead-pty` }, args)
+ )
+ }
+ return samples
+}
+
+function selectTargets(setupResult, cycles) {
+ const gitWorktrees = setupResult.gitWorktrees
+ .filter((worktree) => worktree.path)
+ .sort((a, b) => Number(b.isMainWorktree) - Number(a.isMainWorktree))
+ .slice(0, 5)
+ .map((worktree) => ({ ...worktree, kind: 'git-wsl' }))
+ const plainWorktrees = setupResult.plainWorktrees.map((worktree) => ({
+ ...worktree,
+ kind: 'plain-folder-wsl'
+ }))
+ const baseTargets = [...gitWorktrees, ...plainWorktrees]
+ if (baseTargets.length === 0) {
+ throw new Error('No worktrees were discovered for the repro fixture.')
+ }
+ return Array.from({ length: cycles }, (_, index) => ({
+ ...baseTargets[index % baseTargets.length],
+ index: index + 1
+ }))
+}
+
+export async function runGpuMode(gpuMode, args, fixture) {
+ const cdpPort = await pickFreePort()
+ const userDataDir = createGpuUserDataDirectory(gpuMode)
+ const launched = launchDevApp({ cdpPort, userDataDir })
+ let browser = null
+ let page = null
+ const startedAt = Date.now()
+ const result = {
+ gpuMode,
+ reproduced: false,
+ reproductionReason: null,
+ harnessError: null,
+ startedAt,
+ elapsedMs: null,
+ cdpPort,
+ userDataDir,
+ appPid: launched.child.pid ?? null,
+ setup: null,
+ samples: [],
+ finalDiagnostics: null,
+ trace: null,
+ appLogSummary: null,
+ appLogsTail: null,
+ cleanupErrors: []
+ }
+
+ try {
+ const connected = await connectToApp(cdpPort)
+ browser = connected.browser
+ page = connected.page
+ await waitForStoreReady(page)
+ await installRendererProbe(page)
+ const identity = await runWithTimeout(
+ 'app identity',
+ () => page.evaluate(() => window.api.app.getIdentity?.()),
+ rendererActionTimeoutMs
+ ).catch(() => null)
+ console.log(
+ `[apphang-repro] connected gpu=${gpuMode} pid=${result.appPid} identity=${JSON.stringify(identity)}`
+ )
+ result.setup = await setupAppFixture(page, fixture, gpuMode, args.sourceControl)
+ const targets = selectTargets(result.setup, args.cycles)
+ console.log(
+ `[apphang-repro] targets gpu=${gpuMode}: ${targets
+ .map((target) => `${target.kind}:${target.displayName}`)
+ .join(', ')}`
+ )
+ for (const target of targets) {
+ console.log(`[apphang-repro] cycle=${target.index} gpu=${gpuMode} kind=${target.kind}`)
+ result.samples.push(await runActivationCycle(page, target, args))
+ }
+ if (args.deadPtyReactivate) {
+ const uniqueTargets = []
+ const seen = new Set()
+ for (const target of targets) {
+ if (!seen.has(target.id)) {
+ seen.add(target.id)
+ uniqueTargets.push(target)
+ }
+ }
+ console.log(`[apphang-repro] dead-pty-reactivation gpu=${gpuMode}`)
+ result.samples.push(...(await runDeadPtyReactivation(page, uniqueTargets, args)))
+ }
+ } catch (error) {
+ if (error instanceof ReproductionObservedError) {
+ result.reproduced = true
+ result.reproductionReason = error.message
+ result.samples.push(error.evidence)
+ } else {
+ // Why: setup/CDP/selector failures are inconclusive, not hang evidence.
+ // Conflating them with `reproduced` lets --expect=repro pass on a broken
+ // harness; callers treat harnessError as a hard failure instead.
+ result.harnessError = error instanceof Error ? error.stack || error.message : String(error)
+ }
+ } finally {
+ result.elapsedMs = Date.now() - startedAt
+ result.finalDiagnostics = await collectRendererDiagnostics(page)
+ result.trace = summarizeTrace(userDataDir)
+ result.appLogSummary = summarizeAppLogs(launched.logs)
+ result.appLogsTail = launched.logs.slice(-120)
+ if (browser) {
+ await browser.close().catch(() => undefined)
+ }
+ await stopDevApp(launched.child)
+ if (!args.keep) {
+ safeRemoveLocalDirectory(userDataDir, result.cleanupErrors)
+ }
+ }
+ return result
+}
diff --git a/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs b/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs
new file mode 100644
index 00000000000..cd38f400d47
--- /dev/null
+++ b/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs
@@ -0,0 +1,135 @@
+import { execFileSync } from 'node:child_process'
+import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url)))
+
+function runWsl(distro, script, options = {}) {
+ return execFileSync('wsl.exe', ['-d', distro, '--', 'bash', '-se'], {
+ cwd: rootDir,
+ encoding: 'utf8',
+ input: script,
+ timeout: options.timeoutMs ?? 120_000,
+ stdio: ['pipe', 'pipe', 'pipe']
+ })
+}
+
+export function listWslDistros() {
+ const output = execFileSync('wsl.exe', ['--list', '--quiet'], {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ timeout: 10_000
+ })
+ return output
+ .replaceAll(String.fromCharCode(0), '')
+ .split(/\r?\n/)
+ .map((line) => line.trim().replace(/^\*\s*/, ''))
+ .filter((line) => line && !line.toLowerCase().startsWith('docker-desktop'))
+}
+
+function linuxPathToWslUnc(distro, linuxPath) {
+ if (!linuxPath.startsWith('/')) {
+ throw new Error(`Expected absolute Linux path, got ${linuxPath}`)
+ }
+ return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}`
+}
+
+export function createWslFixture(distro) {
+ const script = String.raw`
+set -euo pipefail
+base="$(mktemp -d /tmp/orca-apphang-repro.XXXXXX)"
+repo="$base/repo"
+mkdir -p "$repo"
+cd "$repo"
+git init -q
+git config user.email apphang-repro@test.local
+git config user.name "AppHang Repro"
+mkdir -p src docs
+printf '# Orca Windows AppHang repro\n' > README.md
+for n in $(seq 1 25); do
+ printf 'line %03d %s\n' "$n" "abcdefghijklmnopqrstuvwxyz0123456789" >> src/payload.txt
+done
+git add -A
+git commit -q -m "Initial repro fixture"
+for n in 1 2 3 4; do
+ git branch "repro-wt-$n"
+ git worktree add -q "$base/wt-$n" "repro-wt-$n"
+ mkdir -p "$base/wt-$n/docs"
+ printf 'worktree %s\n' "$n" > "$base/wt-$n/docs/wt.txt"
+done
+plain="$base/plain-folder"
+mkdir -p "$plain/subdir"
+printf 'plain folder for Orca AppHang repro\n' > "$plain/README.txt"
+printf '%s\n' "$base" "$repo" "$base/wt-1" "$base/wt-2" "$base/wt-3" "$base/wt-4" "$plain"
+`
+ const lines = runWsl(distro, script, { timeoutMs: 120_000 }).trim().split(/\r?\n/).filter(Boolean)
+ if (lines.length < 7) {
+ throw new Error(`WSL fixture creation returned unexpected output: ${JSON.stringify(lines)}`)
+ }
+ const [base, repo, ...rest] = lines
+ const plain = rest.at(-1)
+ const worktrees = rest.slice(0, -1)
+ return {
+ distro,
+ baseLinuxPath: base,
+ repoLinuxPath: repo,
+ plainLinuxPath: plain,
+ worktreeLinuxPaths: worktrees,
+ repoUncPath: linuxPathToWslUnc(distro, repo),
+ plainUncPath: linuxPathToWslUnc(distro, plain),
+ worktreeUncPaths: worktrees.map((entry) => linuxPathToWslUnc(distro, entry))
+ }
+}
+
+export function removeWslFixture(fixture) {
+ if (!fixture?.baseLinuxPath) {
+ return
+ }
+ const quoted = fixture.baseLinuxPath.replaceAll("'", "'\\''")
+ runWsl(fixture.distro, `rm -rf '${quoted}'`, { timeoutMs: 30_000 })
+}
+
+export function createCompletedOnboardingProfile(userDataDir) {
+ mkdirSync(userDataDir, { recursive: true })
+ const profile = {
+ settings: {
+ telemetry: {
+ // Why: synthetic harness/benchmark activity must not send telemetry.
+ // Explicit false with existedBeforeTelemetryRelease=false also keeps
+ // the first-launch consent surface from blocking automation.
+ optedIn: false,
+ installId: '00000000-0000-4000-8000-000000000000',
+ existedBeforeTelemetryRelease: false
+ }
+ },
+ onboarding: {
+ flowVersion: 4,
+ closedAt: 1,
+ outcome: 'completed',
+ lastCompletedStep: 5
+ },
+ ui: {
+ contextualToursAutoEligible: false,
+ contextualToursSeenIds: [
+ 'workspace-board',
+ 'browser',
+ 'tasks',
+ 'automations',
+ 'workspace-creation'
+ ],
+ featureTipsSeenIds: [],
+ featureInteractions: {},
+ projectOrderManualDefaultNoticeDismissed: true
+ }
+ }
+ writeFileSync(path.join(userDataDir, 'orca-data.json'), `${JSON.stringify(profile, null, 2)}\n`)
+}
+
+export function safeRemoveLocalDirectory(dir, cleanupErrors) {
+ try {
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
+ } catch (error) {
+ cleanupErrors.push(error instanceof Error ? error.message : String(error))
+ }
+}
diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json
index 955567a21fa..a7c4761a0ec 100644
--- a/config/tsconfig.cli.json
+++ b/config/tsconfig.cli.json
@@ -11,7 +11,9 @@
"../src/main/claude/hook-settings.ts",
"../src/main/claude/hook-service.ts",
"../src/main/codex/codex-config-mirror.ts",
+ "../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-home-paths.ts",
+ "../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
"../src/main/codex-accounts/fs-utils.ts",
diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg
index 79f778f0a38..ff7c9fd7520 100644
--- a/docs/assets/readme-downloads.svg
+++ b/docs/assets/readme-downloads.svg
@@ -1,5 +1,5 @@
-
- downloads: 3.2m
+
+ downloads: 3.4m
@@ -15,7 +15,7 @@
downloads
downloads
- 3.2m
- 3.2m
+ 3.4m
+ 3.4m
diff --git a/docs/assets/wechat-qr.png b/docs/assets/wechat-qr.png
index 3c71c4952ea..bf270d857e8 100644
Binary files a/docs/assets/wechat-qr.png and b/docs/assets/wechat-qr.png differ
diff --git a/mobile/app.json b/mobile/app.json
index 9394a9a63dc..3a09a5531c8 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
- "version": "0.0.18",
+ "version": "0.0.20",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
diff --git a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx
index 8ab79117ce8..65412d51958 100644
--- a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx
@@ -7,6 +7,11 @@ export default function MobileFilePreviewRoute() {
hostId?: string | string[]
worktreeId?: string | string[]
relativePath?: string | string[]
+ source?: string | string[]
+ absolutePath?: string | string[]
+ grantId?: string | string[]
+ line?: string | string[]
+ column?: string | string[]
name?: string | string[]
worktreeName?: string | string[]
}>()
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 1912f7a04a4..a934334b8f2 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -1,8 +1,6 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Animated, AppState, Linking, type AppStateStatus } from 'react-native'
import * as Clipboard from 'expo-clipboard'
-import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'
-import { File as FsFile, Paths } from 'expo-file-system'
import {
BackHandler,
FlatList,
@@ -48,7 +46,6 @@ import {
X
} from 'lucide-react-native'
import type { RpcClient } from '../../../../src/transport/rpc-client'
-import type { RuntimeTerminalPathResolution } from '../../../../../src/shared/runtime-types'
import { loadHosts } from '../../../../src/transport/host-store'
import {
loadTerminalAutocompleteEnabled,
@@ -95,14 +92,16 @@ import {
getVisibleTerminalAccessoryKeys,
loadTerminalAccessoryLayout
} from '../../../../src/terminal/terminal-accessory-layout'
+import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
+import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target'
import {
clearTerminalLiveInputFocusTimer,
- defaultTerminalLiveInputHandles,
- getTerminalLiveSpecialKeyBytes,
isTerminalLiveInputWithinByteLimit,
- pruneTerminalLiveInputHandles,
scheduleTerminalLiveInputFocus
} from '../../../../src/terminal/terminal-live-input'
+import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
+import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
+import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
import {
getTerminalCommandKeyboardType,
getTerminalLiveInputKeyboardType
@@ -162,16 +161,13 @@ import {
type MobileNewTabAgentOption,
type MobileNewTabAgentSettings
} from '../../../../src/session/mobile-new-tab-agent-options'
-import {
- buildMobileImagePastePayload,
- prepareMobileClipboardImageBase64,
- saveMobileClipboardImageAsTempFile,
- type MobileClipboardImageResizer
-} from '../../../../src/session/mobile-clipboard-image'
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
+import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
+import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference'
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
+import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open'
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
import {
acceptSessionSnapshot,
@@ -233,52 +229,9 @@ import type {
TerminalGestureInputQueue
} from './mobile-session-route-types'
-const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i
-const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
+type TerminalLiveAccessoryInput = ReturnType
-// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and
-// photos can exceed the upload byte budget; resize the raster down to fit before upload.
-// The image is staged to a temp file first because the iOS ImageManipulator loader
-// (Data(contentsOf:)) cannot decode large base64 data URIs — it needs a file:// URI.
-const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => {
- const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '')
- const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`)
- let context: ReturnType | null = null
- let rendered: Awaited<
- ReturnType['renderAsync']>
- > | null = null
- let resultUri: string | null = null
- try {
- file.create({ overwrite: true })
- file.write(base64, { encoding: 'base64' })
- context = ImageManipulator.manipulate(file.uri)
- context.resize({ width: target.width, height: target.height })
- rendered = await context.renderAsync()
- const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true })
- resultUri = result.uri
- // Why: empty base64 would pass the downstream base64 check and upload a corrupt
- // image, so fail loudly here instead of silently sending an invalid payload.
- if (!result.base64) {
- throw new Error('Failed to encode resized clipboard image')
- }
- return { data: result.base64, width: result.width, height: result.height }
- } finally {
- rendered?.release()
- context?.release()
- if (resultUri) {
- try {
- new FsFile(resultUri).delete()
- } catch {
- // Best-effort cleanup; ImageManipulator saves into cache for every retry.
- }
- }
- try {
- file.delete()
- } catch {
- // Best-effort cleanup; the OS reclaims the cache directory regardless.
- }
- }
-}
+const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
function getActiveTabIdForHandle(
tabs: MobileSessionTab[],
@@ -835,6 +788,21 @@ function FileReader({
return renderSourceText(doc.content)
}
+function updateTerminalCwdFromStreamEvent(
+ handle: string,
+ data: Record,
+ terminalCwd: Map
+): void {
+ if (!('cwd' in data)) {
+ return
+ }
+ if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) {
+ terminalCwd.set(handle, data.cwd)
+ return
+ }
+ terminalCwd.delete(handle)
+}
+
export default function SessionScreen() {
const {
hostId,
@@ -927,11 +895,14 @@ export default function SessionScreen() {
const [terminalLinkOpenMode, setTerminalLinkOpenMode] =
useState('orca-browser')
const [liveInputCapture, setLiveInputCapture] = useState('')
- const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>(
- () => new Set()
- )
- const liveInputTerminalHandlesRef = useRef>(new Set())
- const defaultedLiveInputTerminalHandlesRef = useRef>(new Set())
+ const {
+ clearTerminalLiveInputDefault,
+ defaultTerminalHandlesToLiveInput,
+ liveInputTerminalHandles,
+ liveInputTerminalHandlesRef,
+ pruneTerminalHandlesFromLiveInput,
+ toggleTerminalLiveInput
+ } = useTerminalLiveInputModePreference({ hostId, worktreeId })
const [activeHandle, setActiveHandle] = useState(null)
const [activeSessionTabId, setActiveSessionTabId] = useState(null)
const activeSessionTabIdRef = useRef(null)
@@ -1023,6 +994,7 @@ export default function SessionScreen() {
const terminalGestureInputBucketsRef = useRef>(new Map())
const terminalGestureInputQueuesRef = useRef>(new Map())
const terminalGestureInputInFlightRef = useRef>(new Set())
+ const terminalCwdRef = useRef>(new Map())
const initialModesSeenRef = useRef>(new Set())
const deviceTokenRef = useRef(null)
const clientRef = useRef(null)
@@ -1034,6 +1006,7 @@ export default function SessionScreen() {
const terminalRefs = useRef>(new Map())
const liveInputRef = useRef(null)
const liveInputFocusTimerRef = useRef | null>(null)
+ const sendLiveTerminalInputRef = useRef(async () => false)
const sessionTabActionSheetKeyboardHideSubRef = useRef | null>(null)
@@ -1068,6 +1041,7 @@ export default function SessionScreen() {
// render where client was still null/connecting, silently no-opping the
// in-app-browser open). Route through a ref kept current every render.
const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise) | null>(null)
+
const initialEmptySessionAutoCreateRef = useRef(null)
const markdownSaveSeqRef = useRef>(new Map())
const markdownSaveInFlightRef = useRef>(new Set())
@@ -1094,6 +1068,24 @@ export default function SessionScreen() {
const [terminalFrameWidth, setTerminalFrameWidth] = useState(0)
const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null
+ const {
+ clearPendingLiveInputCommit,
+ flushPendingLiveInputBeforeExternalSend,
+ handleLiveInputAccessoryBytes,
+ handleLiveInputChange,
+ handleLiveInputKeyPress,
+ handleLiveInputSubmit
+ } = useTerminalLiveInputCommit({
+ activeHandle,
+ activeHandleRef,
+ activeSessionTabType: activeSessionTab?.type,
+ activeSessionTabTypeRef,
+ liveInputRef,
+ liveInputTerminalHandles,
+ liveInputTerminalHandlesRef,
+ sendLiveTerminalInputRef,
+ setLiveInputCapture
+ })
const canSend =
connState === 'connected' &&
activeHandle != null &&
@@ -1114,7 +1106,6 @@ export default function SessionScreen() {
sessionTabsRef.current = sessionTabs
activeSessionTabIdRef.current = activeSessionTabId
markdownDocsRef.current = markdownDocs
- liveInputTerminalHandlesRef.current = liveInputTerminalHandles
const reconciledCreateWarningState = reconcileMobileSessionCreateWarningState(
createWarningState,
initialCreateWarning
@@ -1180,52 +1171,6 @@ export default function SessionScreen() {
[clearToastHideTimer]
)
- // Why: direct input is now the mobile default, but only once per discovered
- // handle so a user's buffered-mode toggle survives tab/list refreshes.
- const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => {
- const result = defaultTerminalLiveInputHandles(
- liveInputTerminalHandlesRef.current,
- defaultedLiveInputTerminalHandlesRef.current,
- handles
- )
- if (!result.changed) {
- return
- }
- const nextEnabledHandles = new Set(result.enabledHandles)
- const nextDefaultedHandles = new Set(result.defaultedHandles)
- liveInputTerminalHandlesRef.current = nextEnabledHandles
- defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
- setLiveInputTerminalHandles(nextEnabledHandles)
- }, [])
-
- const pruneTerminalHandlesFromLiveInput = useCallback((liveHandles: ReadonlySet) => {
- const result = pruneTerminalLiveInputHandles(
- liveInputTerminalHandlesRef.current,
- defaultedLiveInputTerminalHandlesRef.current,
- liveHandles
- )
- if (!result.changed) {
- return
- }
- const nextEnabledHandles = new Set(result.enabledHandles)
- const nextDefaultedHandles = new Set(result.defaultedHandles)
- liveInputTerminalHandlesRef.current = nextEnabledHandles
- defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
- setLiveInputTerminalHandles(nextEnabledHandles)
- }, [])
-
- const clearTerminalLiveInputDefault = useCallback(
- (handle: string) => {
- const liveHandles = new Set([
- ...liveInputTerminalHandlesRef.current,
- ...defaultedLiveInputTerminalHandlesRef.current
- ])
- liveHandles.delete(handle)
- pruneTerminalHandlesFromLiveInput(liveHandles)
- },
- [pruneTerminalHandlesFromLiveInput]
- )
-
const dictation = useMobileDictation({
client,
enabled: canSend,
@@ -1244,8 +1189,16 @@ export default function SessionScreen() {
if (!insertHandle) {
return
}
- sendLiveTerminalInput(insertHandle, route.text)
- showToast('Dictation inserted')
+ void (async () => {
+ const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(insertHandle)
+ if (!flushedPendingInput) {
+ return
+ }
+ const sent = await sendLiveTerminalInput(insertHandle, route.text)
+ if (sent) {
+ showToast('Dictation inserted')
+ }
+ })()
return
}
setInput((current) => appendBufferedDictation(current, route.text))
@@ -1361,6 +1314,7 @@ export default function SessionScreen() {
webReadyHandlesRef.current.clear()
subscribeSeqRef.current.clear()
layoutSeqRef.current.clear()
+ terminalCwdRef.current.clear()
setTerminalKeyboardMetrics(new Map())
for (const term of terminalRefs.current.values()) {
term.clear()
@@ -1460,6 +1414,7 @@ export default function SessionScreen() {
if (initializedHandlesRef.current.has(handle)) {
return
}
+ updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const scrollbackCols = cols
@@ -1555,7 +1510,10 @@ export default function SessionScreen() {
}
})()
}
+ } else if (data.type === 'metadata') {
+ updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
} else if (data.type === 'data') {
+ updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
// Why: log when data arrives but the WebView ref is missing
// — this is the most likely cause of "blank but input works":
// server stream is alive, sends flow, but writes are dropped
@@ -1578,6 +1536,7 @@ export default function SessionScreen() {
}
dataRef.write(data.chunk as string)
} else if (data.type === 'resized') {
+ updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
// Why: inline resize event — the server changed the PTY dimensions
// (mode toggle, desktop restore, or a width reflow). When the server
// includes a fresh full-buffer snapshot (width reflow), reinitialize
@@ -2680,19 +2639,23 @@ export default function SessionScreen() {
terminalsRef.current = []
setSessionTabs([])
setActiveSessionTabId(null)
- setLiveInputCapture('')
- liveInputTerminalHandlesRef.current = new Set()
- defaultedLiveInputTerminalHandlesRef.current = new Set()
- setLiveInputTerminalHandles(new Set())
+ clearPendingLiveInputCommit()
setMarkdownDocs(new Map())
setFileDocs(new Map())
clearDelayedActionTimers()
return () => {
sessionTabActionSheetRequestSeqRef.current += 1
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
+ clearPendingLiveInputCommit()
clearDelayedActionTimers()
}
- }, [clearDelayedActionTimers, clearTerminalCache, hostId, worktreeId])
+ }, [
+ clearDelayedActionTimers,
+ clearPendingLiveInputCommit,
+ clearTerminalCache,
+ hostId,
+ worktreeId
+ ])
useEffect(() => {
if (connState !== 'connected') {
@@ -3097,46 +3060,62 @@ export default function SessionScreen() {
}
}
- async function handleAccessoryKey(bytes: string) {
+ async function handleAccessoryKey(input: TerminalLiveAccessoryInput) {
if (!client || !activeHandle || !canSend) {
return
}
-
- try {
- await client.sendRequest('terminal.send', {
- terminal: activeHandle,
- text: bytes,
+ const targetHandle = activeHandle
+ const accessoryCommit = await handleLiveInputAccessoryBytes(input)
+ if (accessoryCommit.kind !== 'allow-raw') {
+ return
+ }
+ const currentClient = clientRef.current
+ // Why: async IME flushing can outlive the original terminal selection.
+ const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
+ targetHandle,
+ activeHandle: activeHandleRef.current,
+ activeSessionTabType: activeSessionTabTypeRef.current
+ })
+ if (!currentClient || !rawSendTarget || connStateRef.current !== 'connected') {
+ return
+ }
+ await currentClient
+ .sendRequest('terminal.send', {
+ terminal: rawSendTarget,
+ text: input.bytes,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
- } catch {
- // Transient failure
- }
+ .then(
+ () => undefined,
+ () => undefined
+ )
}
const sendLiveTerminalInput = useCallback(
- (handle: string, bytes: string) => {
+ async (handle: string, bytes: string): Promise => {
const text = normalizeTerminalTextInput(bytes)
if (text.length === 0) {
- return
+ return false
}
if (!isTerminalLiveInputWithinByteLimit(text)) {
triggerError()
showToast('Input too large (max 256 KiB)', 1500)
- return
+ return false
}
const rpc = clientRef.current
+ // Why: callers suppress follow-up controls/toasts when this live send is stale.
if (
!rpc ||
connStateRef.current !== 'connected' ||
handle !== activeHandleRef.current ||
activeSessionTabTypeRef.current !== 'terminal'
) {
- return
+ return false
}
- void rpc
+ return rpc
.sendRequest('terminal.send', {
terminal: handle,
text,
@@ -3145,12 +3124,11 @@ export default function SessionScreen() {
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
- .catch(() => {
- // Transient failure
- })
+ .then(isTerminalSendRpcAccepted, () => false)
},
[showToast]
)
+ sendLiveTerminalInputRef.current = sendLiveTerminalInput
const focusLiveInput = useCallback(() => {
if (!canSend || !liveInputEnabled) {
@@ -3238,82 +3216,42 @@ export default function SessionScreen() {
// Tap on a file path in terminal output → resolve it on the host and open it
// as a file tab (mirrors desktop Cmd/Ctrl-click). Silent on a miss; the
// WebView only emits this when the tap landed on a detected path.
+ const handleFileTapActivationSeqRef = useRef(0)
const handleFileTap = useCallback(
- (handle: string, pathText: string) => {
+ (handle: string, pathText: string, line: number | null, column: number | null) => {
if (handle !== activeHandleRef.current || !client) {
return
}
- void (async () => {
- try {
- const worktree = `id:${worktreeId}`
- const response = await client.sendRequest(
- 'files.resolveTerminalPath',
- { worktree, pathText },
- { timeoutMs: 10_000 }
- )
- if (!response.ok) {
- return
- }
- const resolved = (response as RpcSuccess).result as RuntimeTerminalPathResolution
- if (!resolved.exists || resolved.isDirectory || !resolved.relativePath) {
- return
- }
- // Confirm the tap landed on something openable before giving feedback.
- triggerSelection()
- // Why: HTML opens in a browser pane (streamed from the desktop),
- // matching desktop's terminal-click behavior, instead of a file view.
- if (classifyMobileArtifact(resolved.relativePath) === 'html' && resolved.absolutePath) {
- void handleCreateBrowser('file://' + resolved.absolutePath)
- return
- }
- const openResponse = await client.sendRequest(
- 'files.open',
- { worktree, relativePath: resolved.relativePath },
- { timeoutMs: 15_000 }
- )
- if (!openResponse.ok) {
- return
- }
- // Why: the host opens the file as a markdown/file/image tab (the type
- // depends on the file — .md opens as a 'markdown' tab), and from a terminal
- // the active tab stays on the terminal. Once the new tab syncs in, switch to
- // it by relativePath across ANY openable type. Poll since it arrives async.
- const openedPath = resolved.relativePath
- // Why: retries poll for the async-arriving tab, but once activation lands
- // a later retry would steal focus back from the user — short-circuit the
- // remaining ones once the opened tab is (or becomes) the active tab.
- let activated = false
- const activateOpenedTab = async (): Promise => {
- if (activated) {
- return
- }
- await fetchSessionTabs()
- if (activated) {
- return
- }
- const opened = sessionTabsRef.current.find(
- (tab): tab is Extract =>
- 'relativePath' in tab && tab.relativePath === openedPath
- )
- if (!opened) {
- return
- }
- if (activeSessionTabIdRef.current === opened.id) {
- activated = true
- return
- }
- switchSessionTabRef.current?.(opened)
- activated = true
- }
- scheduleDelayedAction(() => void activateOpenedTab(), 300)
- scheduleDelayedAction(() => void activateOpenedTab(), 900)
- scheduleDelayedAction(() => void activateOpenedTab(), 1800)
- } catch {
- // Resolution/open is best-effort; a failed tap silently no-ops.
- }
- })()
+ const activationSeq = ++handleFileTapActivationSeqRef.current
+ openMobileTerminalFileTap({
+ client,
+ hostId,
+ worktreeId,
+ worktreeName: routeWorktreeName,
+ terminalHandle: handle,
+ pathText,
+ cwd: terminalCwdRef.current.get(handle) ?? null,
+ line,
+ column,
+ pushPreviewRoute: (href) => router.push(href),
+ openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
+ triggerOpenFeedback: triggerSelection,
+ fetchSessionTabs,
+ getSessionTabs: () => sessionTabsRef.current,
+ getActiveSessionTabId: () => activeSessionTabIdRef.current,
+ getActivationState: (activated) => ({
+ activated,
+ activationSeq,
+ latestActivationSeq: handleFileTapActivationSeqRef.current,
+ sourceTerminalHandle: handle,
+ activeTerminalHandle: activeHandleRef.current,
+ activeTabType: activeSessionTabTypeRef.current
+ }),
+ switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
+ scheduleDelayedAction
+ })
},
- [client, worktreeId, scheduleDelayedAction, fetchSessionTabs]
+ [client, fetchSessionTabs, hostId, routeWorktreeName, router, scheduleDelayedAction, worktreeId]
)
const handleOpenedFileDiffActivationSeqRef = useRef(0)
@@ -3376,81 +3314,15 @@ export default function SessionScreen() {
if (!activeHandle) {
return
}
- const nextEnabled = !liveInputTerminalHandles.has(activeHandle)
- setLiveInputTerminalHandles((prev) => {
- const next = new Set(prev)
- if (nextEnabled) {
- next.add(activeHandle)
- } else {
- next.delete(activeHandle)
- }
- liveInputTerminalHandlesRef.current = next
- return next
- })
- setLiveInputCapture('')
+ const nextEnabled = toggleTerminalLiveInput(activeHandle)
+ clearPendingLiveInputCommit()
if (nextEnabled) {
scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus())
} else {
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
liveInputRef.current?.blur()
}
- }, [activeHandle, liveInputTerminalHandles])
-
- const handleLiveInputChange = useCallback(
- (text: string) => {
- if (!activeHandle) {
- setLiveInputCapture('')
- liveInputRef.current?.setNativeProps({ text: '' })
- return
- }
- if (!liveInputTerminalHandles.has(activeHandle)) {
- setLiveInputCapture('')
- liveInputRef.current?.setNativeProps({ text: '' })
- return
- }
- const normalizedText = normalizeTerminalTextInput(text)
- if (normalizedText.length > 0) {
- sendLiveTerminalInput(activeHandle, normalizedText)
- }
- setLiveInputCapture('')
- // Why: the field is only a keyboard capture surface. Clearing the
- // native value prevents subsequent phone-keyboard events from replaying
- // already-sent characters when React state remains the empty string.
- liveInputRef.current?.setNativeProps({ text: '' })
- },
- [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]
- )
-
- const handleLiveInputKeyPress = useCallback(
- (event: { nativeEvent: { key: string } }) => {
- if (!activeHandle) {
- return
- }
- if (!liveInputTerminalHandles.has(activeHandle)) {
- return
- }
- const bytes = getTerminalLiveSpecialKeyBytes(event.nativeEvent.key)
- if (!bytes) {
- return
- }
- sendLiveTerminalInput(activeHandle, bytes)
- setLiveInputCapture('')
- liveInputRef.current?.setNativeProps({ text: '' })
- },
- [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]
- )
-
- const handleLiveInputSubmit = useCallback(() => {
- if (!activeHandle) {
- return
- }
- if (!liveInputTerminalHandles.has(activeHandle)) {
- return
- }
- sendLiveTerminalInput(activeHandle, '\r')
- setLiveInputCapture('')
- liveInputRef.current?.setNativeProps({ text: '' })
- }, [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput])
+ }, [activeHandle, clearPendingLiveInputCommit, toggleTerminalLiveInput])
const allowTerminalGestureInput = useCallback(
(handle: string, sequenceCount: number): boolean => {
@@ -3650,11 +3522,11 @@ export default function SessionScreen() {
}
}, [])
const startAccessoryRepeat = useCallback(
- (bytes: string) => {
+ (input: TerminalLiveAccessoryInput) => {
stopAccessoryRepeat()
repeatTimeoutRef.current = setTimeout(() => {
repeatIntervalRef.current = setInterval(() => {
- void handleAccessoryKeyRef.current(bytes)
+ void handleAccessoryKeyRef.current(input)
}, 45)
}, 400)
},
@@ -3673,11 +3545,13 @@ export default function SessionScreen() {
clearToastHideTimer()
clearDelayedActionTimers()
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
+ clearPendingLiveInputCommit()
sessionTabActionSheetRequestSeqRef.current += 1
clearSessionTabActionSheetKeyboardListener()
stopAccessoryRepeat()
},
[
+ clearPendingLiveInputCommit,
clearDelayedActionTimers,
clearSessionTabActionSheetKeyboardListener,
clearTerminalCache,
@@ -3801,84 +3675,38 @@ export default function SessionScreen() {
})
}, [])
- const handlePaste = useCallback(async () => {
- if (!client || !activeHandle || !canSend) {
- return
- }
- try {
- const text = await Clipboard.getStringAsync()
- let payload: string | null = null
- if (text.length > 0) {
- const modes = ptyModesRef.current.get(activeHandle) || {
- bracketedPasteMode: false,
- altScreen: false,
- mouseTrackingMode: 'none',
- sgrMouseMode: false,
- sgrMousePixelsMode: false
- }
- const wrap = modes.bracketedPasteMode && !modes.altScreen
- // Why: strip embedded bracketed-paste markers from clipboard text so a
- // malicious copy containing `\x1b[201~` can't terminate paste mode early
- // and have the trailing bytes interpreted as shell commands. Matches
- // xterm.js / iTerm2 behavior.
- // eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping
- const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text
- payload = wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized
- } else {
- const image = await Clipboard.getImageAsync({ format: 'png' })
- if (!image) {
- refreshCanPaste()
- return
- }
- const connectionId = await getActiveWorktreeConnectionId()
- const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage)
- const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, {
- connectionId
- })
- payload = buildMobileImagePastePayload(imagePath)
- }
-
- const wrappedBytes = new TextEncoder().encode(payload).byteLength
- if (wrappedBytes > 256 * 1024) {
- triggerError()
- // eslint-disable-next-line no-console
- console.warn('[mobile-clip] paste oversized', { wrappedBytes })
- showToast('Paste too large (max 256 KiB)', 1500)
- return
- }
- await client.sendRequest('terminal.send', {
- terminal: activeHandle,
- text: payload,
- enter: false,
- ...(deviceTokenRef.current
- ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
- : {})
- })
- triggerSelection()
- refreshCanPaste()
- } catch (e) {
- triggerError()
- const err = e as { name?: string; message?: string }
- const isDisconnected = connState !== 'connected'
- // eslint-disable-next-line no-console
- console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
- if (isDisconnected) {
- showToast('Paste failed (disconnected)', 1500)
- } else if (err.message === 'Clipboard image is too large') {
- showToast('Image too large to paste', 1500)
- } else {
- showToast('Paste failed', 1500)
- }
- }
- }, [
+ const handlePaste = useMobileTerminalPaste({
client,
activeHandle,
+ activeHandleRef,
+ activeSessionTabTypeRef,
canSend,
connState,
+ connStateRef,
+ clientRef,
+ deviceTokenRef,
+ flushPendingLiveInputBeforeExternalSend,
getActiveWorktreeConnectionId,
+ onError: triggerError,
+ onSuccess: triggerSelection,
+ ptyModesRef,
refreshCanPaste,
showToast
- ])
+ })
+
+ const flushPendingLiveInputBeforeAttachmentSend = useCallback(
+ async (targetHandle: string): Promise => {
+ const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle)
+ // Why: image picking/upload and IME flushing can outlive the original tab.
+ return (
+ flushedPendingInput &&
+ connStateRef.current === 'connected' &&
+ targetHandle === activeHandleRef.current &&
+ activeSessionTabTypeRef.current === 'terminal'
+ )
+ },
+ [flushPendingLiveInputBeforeExternalSend]
+ )
const { attachImage, isAttaching } = useMobileImageAttachment({
client,
@@ -3886,6 +3714,7 @@ export default function SessionScreen() {
canSend,
connState,
deviceTokenRef,
+ beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
getActiveWorktreeConnectionId,
showToast,
onSuccess: triggerSelection,
@@ -5046,8 +4875,9 @@ export default function SessionScreen() {
if (!key.repeatable) {
return
}
- void handleAccessoryKey(key.bytes)
- startAccessoryRepeat(key.bytes)
+ const input = createTerminalLiveAccessoryInput(key)
+ void handleAccessoryKey(input)
+ startAccessoryRepeat(input)
}}
onPressOut={() => {
if (key.repeatable) {
@@ -5058,7 +4888,7 @@ export default function SessionScreen() {
if (key.repeatable) {
return
}
- void handleAccessoryKey(key.bytes)
+ void handleAccessoryKey(createTerminalLiveAccessoryInput(key))
}}
accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`}
>
@@ -5082,7 +4912,7 @@ export default function SessionScreen() {
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
- onPress={() => void handleAccessoryKey(key.bytes)}
+ onPress={() => void handleAccessoryKey({ bytes: key.bytes })}
onLongPress={() => {
triggerMediumImpact()
setDeleteKeyTarget(key)
diff --git a/mobile/package.json b/mobile/package.json
index 734519e9747..9d75502765f 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -60,10 +60,12 @@
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-native": "^0.73.0",
+ "@types/react-test-renderer": "19.1.0",
"@types/ws": "^8.18.1",
"expo-module-scripts": "^55.0.2",
"oxfmt": "^0.52.0",
"oxlint": "^1.71.0",
+ "react-test-renderer": "19.2.6",
"tsx": "^4.22.4",
"typescript": "^5.9.3",
"vite": "^8.0.16",
diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml
index 7785a4d05c6..aecf79fe0ee 100644
--- a/mobile/pnpm-lock.yaml
+++ b/mobile/pnpm-lock.yaml
@@ -67,7 +67,7 @@ importers:
version: 55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-router:
specifier: ^55.0.14
- version: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e)
+ version: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa)
expo-secure-store:
specifier: ^55.0.13
version: 55.0.13(expo@55.0.23)
@@ -135,18 +135,24 @@ importers:
'@types/react-native':
specifier: ^0.73.0
version: 0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
+ '@types/react-test-renderer':
+ specifier: 19.1.0
+ version: 19.1.0
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
expo-module-scripts:
specifier: ^55.0.2
- version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
+ version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
oxfmt:
specifier: ^0.52.0
version: 0.52.0
oxlint:
specifier: ^1.71.0
version: 1.71.0
+ react-test-renderer:
+ specifier: 19.2.6
+ version: 19.2.6(react@19.2.6)
tsx:
specifier: ^4.22.4
version: 4.22.4
@@ -1724,48 +1730,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
@@ -1838,48 +1852,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.71.0':
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.71.0':
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.71.0':
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.71.0':
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.71.0':
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@oxlint/binding-openharmony-arm64@1.71.0':
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
@@ -2341,36 +2363,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.1.3':
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.1.3':
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.1.3':
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.1.3':
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-openharmony-arm64@1.1.3':
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
@@ -4632,24 +4660,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -5424,6 +5456,11 @@ packages:
peerDependencies:
react: ^19.2.0
+ react-test-renderer@19.2.6:
+ resolution: {integrity: sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw==}
+ peerDependencies:
+ react: ^19.2.6
+
react@19.2.6:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
@@ -7825,7 +7862,7 @@ snapshots:
ws: 8.21.0
zod: 3.25.76
optionalDependencies:
- expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e)
+ expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa)
react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -8093,7 +8130,7 @@ snapshots:
react: 19.2.6
optionalDependencies:
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
- expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e)
+ expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa)
react-dom: 19.2.6(react@19.2.6)
transitivePeerDependencies:
- supports-color
@@ -9055,14 +9092,14 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
- '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)':
+ '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
jest-matcher-utils: 30.3.0
picocolors: 1.1.1
pretty-format: 30.3.0
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
- react-test-renderer: 19.2.0(react@19.2.6)
+ react-test-renderer: 19.2.6(react@19.2.6)
redent: 3.0.0
optionalDependencies:
jest: 29.7.0(@types/node@25.9.3)
@@ -10651,7 +10688,7 @@ snapshots:
expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-json-utils: 55.0.2
- expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6):
+ expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@babel/cli': 7.28.6(@babel/core@7.29.7)
'@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7)
@@ -10659,7 +10696,7 @@ snapshots:
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.7)
'@expo/npm-proofread': 1.0.1
'@expo/spawn-async': 1.7.2
- '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
+ '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
'@tsconfig/node18': 18.2.6
'@types/jest': 29.5.14
babel-plugin-dynamic-import-node: 2.3.3
@@ -10734,7 +10771,7 @@ snapshots:
- supports-color
- typescript
- expo-router@55.0.14(ce047ddbdcb481fe003f0510f5198c7e):
+ expo-router@55.0.14(e0a8113e3689d84edb7af23d00a72ffa):
dependencies:
'@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -10771,7 +10808,7 @@ snapshots:
use-latest-callback: 0.2.6(react@19.2.6)
vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
optionalDependencies:
- '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
+ '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
react-native-gesture-handler: 2.31.2(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
react-native-reanimated: 4.3.0(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -12948,6 +12985,12 @@ snapshots:
react-is: 19.2.6
scheduler: 0.27.0
+ react-test-renderer@19.2.6(react@19.2.6):
+ dependencies:
+ react: 19.2.6
+ react-is: 19.2.6
+ scheduler: 0.27.0
+
react@19.2.6: {}
readdirp@3.6.0:
diff --git a/mobile/src/files/MobileFileMarkdownPreview.tsx b/mobile/src/files/MobileFileMarkdownPreview.tsx
index b6e32f47e99..0923cf1f9a0 100644
--- a/mobile/src/files/MobileFileMarkdownPreview.tsx
+++ b/mobile/src/files/MobileFileMarkdownPreview.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
import { Pressable, ScrollView, View } from 'react-native'
import { Code, Pencil } from 'lucide-react-native'
import { MobileMarkdown } from '../components/MobileMarkdown'
@@ -14,10 +14,20 @@ type Props = {
content: string
truncated: boolean
byteLength: number
+ initialLine?: number
}
-export function MobileFileMarkdownPreview({ relativePath, content, truncated, byteLength }: Props) {
- const [mode, setMode] = useState<'preview' | 'source'>('preview')
+export function MobileFileMarkdownPreview({
+ relativePath,
+ content,
+ truncated,
+ byteLength,
+ initialLine
+}: Props) {
+ const [mode, setMode] = useState<'preview' | 'source'>(() => (initialLine ? 'source' : 'preview'))
+ useEffect(() => {
+ setMode(initialLine ? 'source' : 'preview')
+ }, [initialLine, relativePath])
const previewSelected = mode === 'preview'
const sourceSelected = mode === 'source'
@@ -62,6 +72,7 @@ export function MobileFileMarkdownPreview({ relativePath, content, truncated, by
content={content}
truncated={truncated}
byteLength={byteLength}
+ initialLine={initialLine}
/>
)}
diff --git a/mobile/src/files/MobileFilePreviewBody.tsx b/mobile/src/files/MobileFilePreviewBody.tsx
new file mode 100644
index 00000000000..75f08a329cd
--- /dev/null
+++ b/mobile/src/files/MobileFilePreviewBody.tsx
@@ -0,0 +1,130 @@
+import { ActivityIndicator, Image, Pressable, ScrollView, Text, View } from 'react-native'
+import { colors } from '../theme/mobile-theme'
+import type { MobileFilePreviewResult } from './mobile-file-preview-request'
+import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview'
+import { MobileFilePreviewEditableSource } from './MobileFilePreviewEditableSource'
+import { MobileFilePreviewSourceText } from './MobileFilePreviewSourceText'
+import type { MobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
+import { filePreviewStyles as styles } from './mobile-file-preview-styles'
+
+type Props = {
+ preview: MobileFilePreviewResult
+ relativePath: string
+ title: string
+ editable: boolean
+ draftContent: string
+ saveError: string
+ lineColumn: MobileFilePreviewLineColumn | null
+ imageWidth: number
+ imageHeight: number
+ onDraftChange: (content: string) => void
+ onImageError: () => void
+ onRetry: () => void
+}
+
+export function MobileFilePreviewBody({ preview, ...options }: Props) {
+ if (preview.status === 'loading') {
+ return (
+
+
+ {preview.message}
+
+ )
+ }
+ if (preview.status === 'error' || preview.status === 'waiting') {
+ return (
+
+ {preview.message}
+
+ Retry
+
+
+ )
+ }
+ if (preview.status === 'empty') {
+ return options.editable ? (
+
+ ) : (
+
+ Empty file
+
+ )
+ }
+ if (preview.kind === 'image') {
+ return (
+
+
+
+
+
+ )
+ }
+ if (preview.kind === 'markdown') {
+ return options.editable ? (
+
+ ) : (
+
+ )
+ }
+ if (preview.kind === 'html') {
+ return options.editable ? (
+
+ ) : (
+
+ )
+ }
+ if (options.editable) {
+ return
+ }
+ return (
+
+ )
+}
+
+function EditablePreviewSource(options: {
+ title: string
+ draftContent: string
+ saveError: string
+ lineColumn: MobileFilePreviewLineColumn | null
+ onDraftChange: (content: string) => void
+}) {
+ return (
+
+ )
+}
diff --git a/mobile/src/files/MobileFilePreviewEditableSource.tsx b/mobile/src/files/MobileFilePreviewEditableSource.tsx
new file mode 100644
index 00000000000..38e1ce6ba64
--- /dev/null
+++ b/mobile/src/files/MobileFilePreviewEditableSource.tsx
@@ -0,0 +1,56 @@
+import { useEffect, useState } from 'react'
+import { Text, TextInput, View } from 'react-native'
+import type { MobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
+import { textOffsetForLineColumn } from './mobile-file-preview-line-column'
+import { filePreviewStyles as styles } from './mobile-file-preview-styles'
+
+type Props = {
+ title: string
+ draftContent: string
+ lineColumn: MobileFilePreviewLineColumn | null
+ saveError?: string
+ onDraftChange: (content: string) => void
+}
+
+export function MobileFilePreviewEditableSource({
+ title,
+ draftContent,
+ lineColumn,
+ saveError,
+ onDraftChange
+}: Props) {
+ const selectionTargetKey = lineColumn
+ ? `${title}:${lineColumn.line}:${lineColumn.column ?? ''}`
+ : ''
+ const [selection, setSelection] = useState<{ start: number; end: number } | null>(null)
+ const [revealedTargetKey, setRevealedTargetKey] = useState('')
+
+ // Why: line/column opens should reveal once, then user cursor movement owns selection.
+ useEffect(() => {
+ if (!lineColumn || !selectionTargetKey || revealedTargetKey === selectionTargetKey) {
+ return
+ }
+ const offset = textOffsetForLineColumn(draftContent, lineColumn)
+ const initialSelection = { start: offset, end: offset }
+ setSelection(initialSelection)
+ setRevealedTargetKey(selectionTargetKey)
+ }, [draftContent, lineColumn, revealedTargetKey, selectionTargetKey])
+
+ return (
+
+ {saveError ? {saveError} : null}
+ setSelection(null)}
+ accessibilityLabel={`${title} editor`}
+ />
+
+ )
+}
diff --git a/mobile/src/files/MobileFilePreviewScreen.test.ts b/mobile/src/files/MobileFilePreviewScreen.test.ts
new file mode 100644
index 00000000000..1595454a12a
--- /dev/null
+++ b/mobile/src/files/MobileFilePreviewScreen.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from 'vitest'
+import { sourceKeyForPreview } from './mobile-file-preview-source'
+import {
+ hasUnsavedMobileTerminalArtifactDraft,
+ isEditableMobileTerminalArtifactPreview,
+ shouldKeepDirtyDraftOnPreviewLoadResult
+} from './mobile-file-preview-editability'
+
+describe('MobileFilePreviewScreen', () => {
+ it('treats empty terminal artifact text previews as editable', () => {
+ expect(isEditableMobileTerminalArtifactPreview({ status: 'empty', kind: 'text' })).toBe(true)
+ expect(
+ isEditableMobileTerminalArtifactPreview({
+ status: 'ready',
+ kind: 'text',
+ content: '',
+ truncated: false,
+ byteLength: 0
+ })
+ ).toBe(true)
+ expect(
+ isEditableMobileTerminalArtifactPreview({
+ status: 'ready',
+ kind: 'image',
+ dataUri: 'data:image/png;base64,aW1n'
+ })
+ ).toBe(false)
+ })
+
+ it('treats truncated terminal artifact text previews as read-only', () => {
+ expect(
+ isEditableMobileTerminalArtifactPreview({
+ status: 'ready',
+ kind: 'text',
+ content: 'partial',
+ truncated: true,
+ byteLength: 1024
+ })
+ ).toBe(false)
+ })
+
+ it('keeps dirty terminal artifact drafts protected while preview is waiting for reconnect', () => {
+ const sourceKey = sourceKeyForPreview({
+ source: 'terminalArtifact',
+ worktreeId: 'wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1'
+ })
+
+ expect(
+ hasUnsavedMobileTerminalArtifactDraft({
+ source: 'terminalArtifact',
+ draftSourceKey: sourceKey,
+ previewSourceKey: sourceKey,
+ draftContent: '{"ok":false}',
+ savedContent: '{"ok":true}'
+ })
+ ).toBe(true)
+ })
+
+ it('keeps dirty terminal artifact drafts when a reload fails or waits', () => {
+ expect(
+ shouldKeepDirtyDraftOnPreviewLoadResult(true, {
+ status: 'error',
+ message: 'Unable to reach desktop filesystem',
+ reconnect: true
+ })
+ ).toBe(true)
+ expect(
+ shouldKeepDirtyDraftOnPreviewLoadResult(true, {
+ status: 'waiting',
+ message: 'Waiting for desktop...',
+ reconnect: true
+ })
+ ).toBe(true)
+ expect(
+ shouldKeepDirtyDraftOnPreviewLoadResult(false, {
+ status: 'error',
+ message: 'Unable to load preview',
+ reconnect: false
+ })
+ ).toBe(false)
+ })
+})
diff --git a/mobile/src/files/MobileFilePreviewScreen.tsx b/mobile/src/files/MobileFilePreviewScreen.tsx
index 13bd7054769..0879e02ec7e 100644
--- a/mobile/src/files/MobileFilePreviewScreen.tsx
+++ b/mobile/src/files/MobileFilePreviewScreen.tsx
@@ -1,30 +1,30 @@
-import { useCallback, useEffect, useState } from 'react'
-import {
- ActivityIndicator,
- Image,
- Pressable,
- ScrollView,
- Text,
- View,
- useWindowDimensions
-} from 'react-native'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Alert, BackHandler, Pressable, Text, View, useWindowDimensions } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
-import { ChevronLeft } from 'lucide-react-native'
+import { ChevronLeft, Save } from 'lucide-react-native'
import { getWorktreeLabel } from '../session/worktree-label'
import { colors, spacing } from '../theme/mobile-theme'
import { useForceReconnect, useHostClient } from '../transport/client-context'
import {
loadMobileFilePreview,
previewError,
+ saveMobileTerminalArtifactPreview,
+ type MobileFilePreviewSource,
type MobileFilePreviewResult
} from './mobile-file-preview-request'
-import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview'
-import { MobileFilePreviewSourceText } from './MobileFilePreviewSourceText'
+import { MobileFilePreviewBody } from './MobileFilePreviewBody'
import {
displayNameFromPreviewPath,
type MobileFilePreviewRouteState
} from './mobile-file-preview-route'
+import { previewSourceFromRoute, sourceKeyForPreview } from './mobile-file-preview-source'
+import { normalizeMobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
+import {
+ hasUnsavedMobileTerminalArtifactDraft,
+ isEditableMobileTerminalArtifactPreview,
+ shouldKeepDirtyDraftOnPreviewLoadResult
+} from './mobile-file-preview-editability'
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
type Props = {
@@ -39,30 +39,116 @@ export function MobileFilePreviewScreen({ route }: Props) {
const [preview, setPreview] = useState(() =>
route.ok ? { status: 'loading', message: 'Loading preview...' } : previewError(route.message)
)
+ const [draftContent, setDraftContent] = useState('')
+ const [savedContent, setSavedContent] = useState('')
+ const [saveError, setSaveError] = useState('')
+ const [saving, setSaving] = useState(false)
+ const draftContentRef = useRef(draftContent)
+ const savedContentRef = useRef(savedContent)
+ const draftSourceKeyRef = useRef(null)
const { width, height } = useWindowDimensions()
+ const routePreviewSource = useMemo(
+ () => (previewParams ? previewSourceFromRoute(previewParams) : null),
+ [previewParams]
+ )
+ const [previewSource, setPreviewSource] = useState(
+ routePreviewSource
+ )
+ const previewSourceKey = useMemo(() => sourceKeyForPreview(previewSource), [previewSource])
+ const routePreviewSourceKey = useMemo(
+ () => sourceKeyForPreview(routePreviewSource),
+ [routePreviewSource]
+ )
+ const previewSourceKeyRef = useRef(previewSourceKey)
+ const lineColumn = useMemo(
+ () =>
+ previewParams
+ ? normalizeMobileFilePreviewLineColumn(previewParams.line, previewParams.column)
+ : null,
+ [previewParams]
+ )
+
+ useEffect(() => {
+ setPreviewSource(routePreviewSource)
+ draftSourceKeyRef.current = null
+ }, [routePreviewSource])
+
+ useEffect(() => {
+ previewSourceKeyRef.current = previewSourceKey
+ }, [previewSourceKey])
+
+ useEffect(() => {
+ draftContentRef.current = draftContent
+ }, [draftContent])
+
+ useEffect(() => {
+ savedContentRef.current = savedContent
+ }, [savedContent])
const loadPreview = useCallback(async () => {
- if (!previewParams) {
+ const loadSourceKey = previewSourceKey
+ if (!previewParams || !previewSource || loadSourceKey !== routePreviewSourceKey) {
setPreview(previewError(route.ok ? 'Unable to load preview' : route.message))
return
}
+ const preserveDirtyDraft =
+ draftSourceKeyRef.current === previewSourceKey &&
+ draftContentRef.current !== savedContentRef.current
if (!client || connState !== 'connected') {
+ if (preserveDirtyDraft) {
+ setSaveError('Waiting for desktop...')
+ return
+ }
setPreview({ status: 'waiting', message: 'Waiting for desktop...', reconnect: true })
return
}
- setPreview({ status: 'loading', message: 'Loading preview...' })
+ if (!preserveDirtyDraft) {
+ setPreview({ status: 'loading', message: 'Loading preview...' })
+ }
+ setSaveError('')
try {
- const result = await loadMobileFilePreview(
- client,
- previewParams.worktreeId,
- previewParams.relativePath
- )
+ const result = await loadMobileFilePreview(client, previewSource, undefined, {
+ onTerminalArtifactSourceRefreshed: setPreviewSource,
+ refreshGrant: true
+ })
+ if (previewSourceKeyRef.current !== loadSourceKey) {
+ return
+ }
+ if (shouldKeepDirtyDraftOnPreviewLoadResult(preserveDirtyDraft, result)) {
+ setSaveError(result.message)
+ return
+ }
+ const loadedContent =
+ result.status === 'ready' && result.kind !== 'image'
+ ? result.content
+ : result.status === 'empty'
+ ? ''
+ : null
+ if (loadedContent !== null) {
+ if (!preserveDirtyDraft) {
+ setDraftContent(loadedContent)
+ setSavedContent(loadedContent)
+ }
+ draftSourceKeyRef.current = previewSourceKey
+ }
setPreview(result)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unable to load preview'
+ if (preserveDirtyDraft) {
+ setSaveError(message)
+ return
+ }
setPreview(previewError(message))
}
- }, [client, connState, previewParams, route])
+ }, [
+ client,
+ connState,
+ previewParams,
+ previewSource,
+ previewSourceKey,
+ route,
+ routePreviewSourceKey
+ ])
useEffect(() => {
void loadPreview()
@@ -84,12 +170,70 @@ export function MobileFilePreviewScreen({ route }: Props) {
void loadPreview()
}, [connState, forceReconnect, loadPreview, preview, previewParams])
- const title = previewParams?.name ?? displayNameFromPreviewPath(previewParams?.relativePath ?? '')
+ const displayPath =
+ previewParams?.source === 'terminalArtifact'
+ ? (previewParams.absolutePath ?? '')
+ : (previewParams?.relativePath ?? '')
+ const title = previewParams?.name ?? displayNameFromPreviewPath(displayPath)
const worktreeLabel = getWorktreeLabel(
previewParams?.worktreeName,
previewParams?.worktreeId ?? ''
)
- const meta = previewParams ? `${worktreeLabel} - ${previewParams.relativePath}` : 'Preview'
+ const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : 'Preview'
+ const isEditableTerminalArtifact =
+ previewSource?.source === 'terminalArtifact' && isEditableMobileTerminalArtifactPreview(preview)
+ const canSaveArtifact =
+ isEditableTerminalArtifact &&
+ draftSourceKeyRef.current === previewSourceKey &&
+ draftContent !== savedContent
+ const hasUnsavedTerminalArtifactDraft = hasUnsavedMobileTerminalArtifactDraft({
+ source: previewSource?.source,
+ draftSourceKey: draftSourceKeyRef.current,
+ previewSourceKey,
+ draftContent,
+ savedContent
+ })
+
+ const saveArtifact = useCallback(async () => {
+ if (!client || previewSource?.source !== 'terminalArtifact' || !canSaveArtifact || saving) {
+ return
+ }
+ setSaving(true)
+ setSaveError('')
+ try {
+ const result = await saveMobileTerminalArtifactPreview(client, previewSource, draftContent, {
+ baseContent: savedContent,
+ onTerminalArtifactSourceRefreshed: setPreviewSource
+ })
+ if (result.status === 'saved') {
+ setSavedContent(draftContent)
+ } else {
+ setSaveError(saveErrorMessageFromPreviewResult(result))
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Unable to save file'
+ setSaveError(message)
+ } finally {
+ setSaving(false)
+ }
+ }, [canSaveArtifact, client, draftContent, previewSource, savedContent, saving])
+
+ const requestBack = useCallback(() => {
+ if (!hasUnsavedTerminalArtifactDraft) {
+ router.back()
+ return true
+ }
+ Alert.alert('Discard changes?', 'Unsaved edits will be lost.', [
+ { text: 'Stay', style: 'cancel' },
+ { text: 'Discard', style: 'destructive', onPress: () => router.back() }
+ ])
+ return true
+ }, [hasUnsavedTerminalArtifactDraft, router])
+
+ useEffect(() => {
+ const subscription = BackHandler.addEventListener('hardwareBackPress', requestBack)
+ return () => subscription.remove()
+ }, [requestBack])
return (
@@ -97,7 +241,7 @@ export function MobileFilePreviewScreen({ route }: Props) {
[styles.backButton, pressed && styles.backButtonPressed]}
- onPress={() => router.back()}
+ onPress={requestBack}
hitSlop={8}
accessibilityLabel="Back to files"
>
@@ -111,110 +255,40 @@ export function MobileFilePreviewScreen({ route }: Props) {
{meta}
+ {isEditableTerminalArtifact ? (
+ void saveArtifact()}
+ disabled={!canSaveArtifact || saving}
+ accessibilityLabel="Save terminal artifact"
+ >
+
+
+ ) : null}
- {renderPreviewBody(preview, {
- relativePath: previewParams?.relativePath ?? '',
- title: title || 'File',
- imageWidth: Math.max(1, width - spacing.md * 2),
- imageHeight: Math.max(240, height - 160),
- onImageError: () =>
- setPreview({ status: 'error', message: 'Unable to load preview', reconnect: false }),
- onRetry: retry
- })}
+
+ setPreview({ status: 'error', message: 'Unable to load preview', reconnect: false })
+ }
+ onRetry={retry}
+ />
)
}
-function renderPreviewBody(
- preview: MobileFilePreviewResult,
- options: {
- relativePath: string
- title: string
- imageWidth: number
- imageHeight: number
- onImageError: () => void
- onRetry: () => void
- }
-) {
- if (preview.status === 'loading') {
- return (
-
-
- {preview.message}
-
- )
- }
-
- if (preview.status === 'error' || preview.status === 'waiting') {
- return (
-
- {preview.message}
-
- Retry
-
-
- )
- }
-
- if (preview.status === 'empty') {
- return (
-
- Empty file
-
- )
- }
-
- if (preview.kind === 'image') {
- return (
-
-
-
-
-
- )
- }
-
- if (preview.kind === 'markdown') {
- return (
-
- )
- }
-
- if (preview.kind === 'html') {
- return (
-
- )
- }
-
- return (
-
- )
+function saveErrorMessageFromPreviewResult(result: MobileFilePreviewResult): string {
+ return result.status === 'error' || result.status === 'waiting'
+ ? result.message
+ : 'Unable to save file'
}
diff --git a/mobile/src/files/MobileFilePreviewSourceText.tsx b/mobile/src/files/MobileFilePreviewSourceText.tsx
index 408870d01b8..01ca75d9c87 100644
--- a/mobile/src/files/MobileFilePreviewSourceText.tsx
+++ b/mobile/src/files/MobileFilePreviewSourceText.tsx
@@ -1,7 +1,8 @@
-import { useMemo } from 'react'
+import { useEffect, useMemo, useRef } from 'react'
import { ScrollView, Text } from 'react-native'
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
import { formatPreviewByteLength } from './mobile-file-preview-request'
+import { scrollOffsetForPreviewLine } from './mobile-file-preview-line-column'
import { buildMobileFilePreviewSyntax } from './mobile-file-preview-syntax'
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
@@ -9,19 +10,44 @@ export function MobileFilePreviewSourceText({
relativePath,
content,
truncated,
- byteLength
+ byteLength,
+ initialLine
}: {
relativePath: string
content: string
truncated?: boolean
byteLength?: number
+ initialLine?: number
}) {
+ const scrollRef = useRef(null)
+ const revealedRef = useRef(false)
const syntax = useMemo(
() => buildMobileFilePreviewSyntax(relativePath, content),
[content, relativePath]
)
+
+ useEffect(() => {
+ revealedRef.current = false
+ }, [content, initialLine, relativePath])
+
+ const revealInitialLine = () => {
+ if (!initialLine || revealedRef.current) {
+ return
+ }
+ revealedRef.current = true
+ scrollRef.current?.scrollTo({
+ y: scrollOffsetForPreviewLine(initialLine),
+ animated: false
+ })
+ }
+
return (
-
+
{truncated ? (
) : null}
diff --git a/mobile/src/files/mobile-file-preview-editability.ts b/mobile/src/files/mobile-file-preview-editability.ts
new file mode 100644
index 00000000000..ff0fa18d3cb
--- /dev/null
+++ b/mobile/src/files/mobile-file-preview-editability.ts
@@ -0,0 +1,38 @@
+import type {
+ MobileFilePreviewResult,
+ MobileFilePreviewSource
+} from './mobile-file-preview-request'
+
+export function isEditableMobileTerminalArtifactPreview(preview: MobileFilePreviewResult): boolean {
+ return (
+ (preview.status === 'ready' && preview.kind !== 'image' && !preview.truncated) ||
+ preview.status === 'empty'
+ )
+}
+
+export function hasUnsavedMobileTerminalArtifactDraft({
+ source,
+ draftSourceKey,
+ previewSourceKey,
+ draftContent,
+ savedContent
+}: {
+ source?: MobileFilePreviewSource['source']
+ draftSourceKey: string | null
+ previewSourceKey: string | null
+ draftContent: string
+ savedContent: string
+}): boolean {
+ return (
+ source === 'terminalArtifact' &&
+ draftSourceKey === previewSourceKey &&
+ draftContent !== savedContent
+ )
+}
+
+export function shouldKeepDirtyDraftOnPreviewLoadResult(
+ preserveDirtyDraft: boolean,
+ result: MobileFilePreviewResult
+): result is Extract {
+ return preserveDirtyDraft && (result.status === 'error' || result.status === 'waiting')
+}
diff --git a/mobile/src/files/mobile-file-preview-line-column.test.ts b/mobile/src/files/mobile-file-preview-line-column.test.ts
new file mode 100644
index 00000000000..460d8c8219f
--- /dev/null
+++ b/mobile/src/files/mobile-file-preview-line-column.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest'
+import {
+ normalizeMobileFilePreviewLineColumn,
+ scrollOffsetForPreviewLine,
+ textOffsetForLineColumn
+} from './mobile-file-preview-line-column'
+
+describe('mobile-file-preview-line-column', () => {
+ it('normalizes positive route params and rejects invalid lines', () => {
+ expect(normalizeMobileFilePreviewLineColumn('12', '3')).toEqual({ line: 12, column: 3 })
+ expect(normalizeMobileFilePreviewLineColumn('12', undefined)).toEqual({
+ line: 12,
+ column: null
+ })
+ expect(normalizeMobileFilePreviewLineColumn('0', '3')).toBeNull()
+ expect(normalizeMobileFilePreviewLineColumn('12x', '3')).toBeNull()
+ expect(normalizeMobileFilePreviewLineColumn(undefined, '3')).toBeNull()
+ })
+
+ it('converts one-based line and column values to a text offset', () => {
+ const content = 'alpha\nbravo\ncharlie'
+
+ expect(textOffsetForLineColumn(content, { line: 1, column: 1 })).toBe(0)
+ expect(textOffsetForLineColumn(content, { line: 2, column: 3 })).toBe(8)
+ expect(textOffsetForLineColumn(content, { line: 3, column: null })).toBe(12)
+ })
+
+ it('caps out-of-range line and column values safely', () => {
+ const content = 'alpha\nbravo'
+
+ expect(textOffsetForLineColumn(content, { line: 99, column: 1 })).toBe(content.length)
+ expect(textOffsetForLineColumn(content, { line: 1, column: 99 })).toBe(5)
+ })
+
+ it('maps line numbers to the mono preview scroll offset', () => {
+ expect(scrollOffsetForPreviewLine(1)).toBe(0)
+ expect(scrollOffsetForPreviewLine(4)).toBe(57)
+ })
+})
diff --git a/mobile/src/files/mobile-file-preview-line-column.ts b/mobile/src/files/mobile-file-preview-line-column.ts
new file mode 100644
index 00000000000..171d106a703
--- /dev/null
+++ b/mobile/src/files/mobile-file-preview-line-column.ts
@@ -0,0 +1,51 @@
+export type MobileFilePreviewLineColumn = {
+ line: number
+ column: number | null
+}
+
+const MOBILE_FILE_PREVIEW_TEXT_LINE_HEIGHT = 19
+
+export function normalizeMobileFilePreviewLineColumn(
+ line: string | undefined,
+ column: string | undefined
+): MobileFilePreviewLineColumn | null {
+ const normalizedLine = parsePositiveInteger(line)
+ if (normalizedLine === null) {
+ return null
+ }
+ return {
+ line: normalizedLine,
+ column: parsePositiveInteger(column)
+ }
+}
+
+export function textOffsetForLineColumn(
+ content: string,
+ target: MobileFilePreviewLineColumn
+): number {
+ let lineStart = 0
+ for (let currentLine = 1; currentLine < target.line; currentLine += 1) {
+ const nextBreak = content.indexOf('\n', lineStart)
+ if (nextBreak === -1) {
+ return content.length
+ }
+ lineStart = nextBreak + 1
+ }
+
+ const lineEnd = content.indexOf('\n', lineStart)
+ const cappedLineEnd = lineEnd === -1 ? content.length : lineEnd
+ const columnOffset = Math.max(0, (target.column ?? 1) - 1)
+ return Math.min(cappedLineEnd, lineStart + columnOffset)
+}
+
+export function scrollOffsetForPreviewLine(line: number): number {
+ return Math.max(0, line - 1) * MOBILE_FILE_PREVIEW_TEXT_LINE_HEIGHT
+}
+
+function parsePositiveInteger(value: string | undefined): number | null {
+ if (!value || !/^\d+$/.test(value)) {
+ return null
+ }
+ const parsed = Number.parseInt(value, 10)
+ return Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : null
+}
diff --git a/mobile/src/files/mobile-file-preview-request.test.ts b/mobile/src/files/mobile-file-preview-request.test.ts
index d2690111311..f7f77fabd19 100644
--- a/mobile/src/files/mobile-file-preview-request.test.ts
+++ b/mobile/src/files/mobile-file-preview-request.test.ts
@@ -4,7 +4,8 @@ import {
createMobileFilePreviewRequest,
formatPreviewByteLength,
loadMobileFilePreview,
- normalizeMobileFilePreviewResponse
+ normalizeMobileFilePreviewResponse,
+ saveMobileTerminalArtifactPreview
} from './mobile-file-preview-request'
function ok(result: unknown): RpcSuccess {
@@ -21,6 +22,12 @@ function clientWith(response: RpcResponse) {
}
}
+function clientWithResponses(responses: RpcResponse[]) {
+ return {
+ sendRequest: vi.fn(async () => responses.shift()!)
+ }
+}
+
describe('mobile-file-preview-request', () => {
it('selects readPreview for raster images and read for text-like files', () => {
expect(createMobileFilePreviewRequest('wt-1', 'assets/logo.png')).toEqual({
@@ -54,6 +61,471 @@ describe('mobile-file-preview-request', () => {
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
})
+ it('loads terminal artifacts through grant-scoped artifact RPCs', async () => {
+ const client = clientWith(ok({ content: '{"ok":true}', truncated: false, byteLength: 11 }))
+
+ await expect(
+ loadMobileFilePreview(client, {
+ source: 'terminalArtifact',
+ worktreeId: 'wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1'
+ })
+ ).resolves.toMatchObject({ status: 'ready', kind: 'text', content: '{"ok":true}' })
+
+ expect(client.sendRequest).toHaveBeenCalledWith('files.readTerminalArtifact', {
+ worktree: 'id:wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1'
+ })
+ expect(client.sendRequest).not.toHaveBeenCalledWith('files.read', expect.anything())
+ })
+
+ it('refreshes an expired terminal artifact grant and retries the read once', async () => {
+ const client = clientWithResponses([
+ fail('terminal_file_grant_expired'),
+ ok({
+ worktree: 'wt-1',
+ relativePath: null,
+ absolutePath: '/tmp/result.json',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'absolute-file',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-2'
+ }
+ }),
+ ok({ content: '{"ok":true}', truncated: false, byteLength: 11 })
+ ])
+ const onTerminalArtifactSourceRefreshed = vi.fn()
+
+ await expect(
+ loadMobileFilePreview(
+ client,
+ {
+ source: 'terminalArtifact',
+ worktreeId: 'wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1',
+ terminalHandle: 'term-1',
+ pathText: 'result.json',
+ cwd: '/tmp/run'
+ },
+ undefined,
+ { onTerminalArtifactSourceRefreshed }
+ )
+ ).resolves.toMatchObject({ status: 'ready', kind: 'text', content: '{"ok":true}' })
+
+ expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'files.readTerminalArtifact', {
+ worktree: 'id:wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1'
+ })
+ expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
+ worktree: 'id:wt-1',
+ pathText: 'result.json',
+ cwd: '/tmp/run',
+ terminal: 'term-1'
+ })
+ expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'files.readTerminalArtifact', {
+ worktree: 'id:wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-2'
+ })
+ expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith({
+ source: 'terminalArtifact',
+ worktreeId: 'wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-2',
+ terminalHandle: 'term-1',
+ pathText: 'result.json',
+ cwd: '/tmp/run'
+ })
+ })
+
+ it('refreshes a stale terminal artifact grant and retries the read once', async () => {
+ const client = clientWithResponses([
+ fail('terminal_file_grant_stale'),
+ ok({
+ worktree: 'wt-1',
+ relativePath: null,
+ absolutePath: '/tmp/result.json',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'absolute-file',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-2'
+ }
+ }),
+ ok({ content: '{"ok":true}', truncated: false, byteLength: 11 })
+ ])
+
+ await expect(
+ loadMobileFilePreview(client, {
+ source: 'terminalArtifact',
+ worktreeId: 'wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1',
+ terminalHandle: 'term-1'
+ })
+ ).resolves.toMatchObject({ status: 'ready', kind: 'text', content: '{"ok":true}' })
+
+ expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
+ worktree: 'id:wt-1',
+ pathText: '/tmp/result.json',
+ terminal: 'term-1'
+ })
+ })
+
+ it.each([
+ ['terminal_file_grant_expired'],
+ ['terminal_file_grant_mismatch'],
+ ['terminal_file_grant_stale']
+ ])(
+ 'does not refresh a %s terminal artifact grant when disabled for a dirty editor',
+ async (error) => {
+ const client = clientWithResponses([fail(error)])
+
+ await expect(
+ loadMobileFilePreview(
+ client,
+ {
+ source: 'terminalArtifact',
+ worktreeId: 'wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1'
+ },
+ undefined,
+ { refreshGrant: false }
+ )
+ ).resolves.toEqual({
+ status: 'error',
+ message: 'Reload preview before saving',
+ reconnect: false
+ })
+
+ expect(client.sendRequest).toHaveBeenCalledTimes(1)
+ expect(client.sendRequest).toHaveBeenCalledWith('files.readTerminalArtifact', {
+ worktree: 'id:wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1'
+ })
+ }
+ )
+
+ it('saves terminal artifacts through the same exact-path grant', async () => {
+ const client = clientWith(ok({ ok: true }))
+
+ await expect(
+ saveMobileTerminalArtifactPreview(
+ client,
+ {
+ source: 'terminalArtifact',
+ worktreeId: 'wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1',
+ terminalHandle: 'term-1',
+ pathText: 'result.json',
+ cwd: '/tmp/run'
+ },
+ '{"ok":false}'
+ )
+ ).resolves.toEqual({ status: 'saved' })
+
+ expect(client.sendRequest).toHaveBeenCalledWith('files.writeTerminalArtifact', {
+ worktree: 'id:wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1',
+ content: '{"ok":false}'
+ })
+ })
+
+ it('does not refresh and retry a failed terminal artifact save without a base content check', async () => {
+ const client = clientWithResponses([fail('terminal_file_grant_mismatch')])
+
+ await expect(
+ saveMobileTerminalArtifactPreview(
+ client,
+ {
+ source: 'terminalArtifact',
+ worktreeId: 'wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1',
+ terminalHandle: 'term-1',
+ pathText: 'result.json',
+ cwd: '/tmp/run'
+ },
+ '{"ok":false}'
+ )
+ ).resolves.toEqual({
+ status: 'error',
+ message: 'Reload preview before saving',
+ reconnect: false
+ })
+
+ expect(client.sendRequest).toHaveBeenCalledTimes(1)
+ expect(client.sendRequest).toHaveBeenCalledWith('files.writeTerminalArtifact', {
+ worktree: 'id:wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1',
+ content: '{"ok":false}'
+ })
+ })
+
+ it('refreshes an expired terminal artifact grant and retries save when the file still matches the base content', async () => {
+ const client = clientWithResponses([
+ fail('terminal_file_grant_expired'),
+ ok({
+ worktree: 'wt-1',
+ relativePath: null,
+ absolutePath: '/tmp/result.json',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'absolute-file',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-2'
+ }
+ }),
+ ok({ content: '{"ok":true}', truncated: false, byteLength: 11 }),
+ ok({ ok: true })
+ ])
+ const onTerminalArtifactSourceRefreshed = vi.fn()
+
+ await expect(
+ saveMobileTerminalArtifactPreview(
+ client,
+ {
+ source: 'terminalArtifact',
+ worktreeId: 'wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1',
+ terminalHandle: 'term-1',
+ pathText: 'result.json',
+ cwd: '/tmp/run'
+ },
+ '{"ok":false}',
+ { baseContent: '{"ok":true}', onTerminalArtifactSourceRefreshed }
+ )
+ ).resolves.toEqual({ status: 'saved' })
+
+ expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'files.readTerminalArtifact', {
+ worktree: 'id:wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1'
+ })
+ expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
+ worktree: 'id:wt-1',
+ pathText: 'result.json',
+ cwd: '/tmp/run',
+ terminal: 'term-1'
+ })
+ expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'files.readTerminalArtifact', {
+ worktree: 'id:wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-2'
+ })
+ expect(client.sendRequest).toHaveBeenNthCalledWith(4, 'files.writeTerminalArtifact', {
+ worktree: 'id:wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-2',
+ content: '{"ok":false}'
+ })
+ expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith({
+ source: 'terminalArtifact',
+ worktreeId: 'wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-2',
+ terminalHandle: 'term-1',
+ pathText: 'result.json',
+ cwd: '/tmp/run'
+ })
+ })
+
+ it('keeps the dirty draft when a refreshed terminal artifact save finds changed remote content', async () => {
+ const client = clientWithResponses([
+ fail('terminal_file_grant_stale'),
+ ok({
+ worktree: 'wt-1',
+ relativePath: null,
+ absolutePath: '/tmp/result.json',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'absolute-file',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-2'
+ }
+ }),
+ ok({ content: '{"ok":"changed"}', truncated: false, byteLength: 16 })
+ ])
+ const onTerminalArtifactSourceRefreshed = vi.fn()
+
+ await expect(
+ saveMobileTerminalArtifactPreview(
+ client,
+ {
+ source: 'terminalArtifact',
+ worktreeId: 'wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1',
+ terminalHandle: 'term-1'
+ },
+ '{"ok":false}',
+ { baseContent: '{"ok":true}', onTerminalArtifactSourceRefreshed }
+ )
+ ).resolves.toEqual({
+ status: 'error',
+ message: 'File changed on desktop. Reload preview before saving',
+ reconnect: false
+ })
+
+ expect(client.sendRequest).toHaveBeenCalledTimes(3)
+ expect(client.sendRequest).not.toHaveBeenCalledWith('files.writeTerminalArtifact', {
+ worktree: 'id:wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-2',
+ content: '{"ok":false}'
+ })
+ expect(onTerminalArtifactSourceRefreshed).not.toHaveBeenCalled()
+ })
+
+ it('reports a failed refreshed artifact read instead of treating it as changed desktop content', async () => {
+ const client = clientWithResponses([
+ fail('terminal_file_grant_stale'),
+ ok({
+ worktree: 'wt-1',
+ relativePath: null,
+ absolutePath: '/tmp/result.json',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'absolute-file',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-2'
+ }
+ }),
+ fail('provider unavailable')
+ ])
+
+ await expect(
+ saveMobileTerminalArtifactPreview(
+ client,
+ {
+ source: 'terminalArtifact',
+ worktreeId: 'wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1',
+ terminalHandle: 'term-1'
+ },
+ '{"ok":false}',
+ { baseContent: '{"ok":true}' }
+ )
+ ).resolves.toEqual({
+ status: 'error',
+ message: 'Unable to reach the desktop filesystem',
+ reconnect: true
+ })
+
+ expect(client.sendRequest).toHaveBeenCalledTimes(3)
+ expect(client.sendRequest).not.toHaveBeenCalledWith('files.writeTerminalArtifact', {
+ worktree: 'id:wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-2',
+ content: '{"ok":false}'
+ })
+ })
+
+ it('reports a malformed refreshed artifact read instead of treating it as changed desktop content', async () => {
+ const client = clientWithResponses([
+ fail('terminal_file_grant_stale'),
+ ok({
+ worktree: 'wt-1',
+ relativePath: null,
+ absolutePath: '/tmp/result.json',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'absolute-file',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-2'
+ }
+ }),
+ ok({ truncated: false, byteLength: 11 })
+ ])
+
+ await expect(
+ saveMobileTerminalArtifactPreview(
+ client,
+ {
+ source: 'terminalArtifact',
+ worktreeId: 'wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1',
+ terminalHandle: 'term-1'
+ },
+ '{"ok":false}',
+ { baseContent: '{"ok":true}' }
+ )
+ ).resolves.toEqual({
+ status: 'error',
+ message: 'Unable to load preview',
+ reconnect: false
+ })
+
+ expect(client.sendRequest).toHaveBeenCalledTimes(3)
+ expect(client.sendRequest).not.toHaveBeenCalledWith('files.writeTerminalArtifact', {
+ worktree: 'id:wt-1',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-2',
+ content: '{"ok":false}'
+ })
+ })
+
+ it('does not retry a dirty save when grant refresh resolves to a different artifact', async () => {
+ const client = clientWithResponses([
+ fail('terminal_file_grant_expired'),
+ ok({
+ worktree: 'wt-1',
+ relativePath: null,
+ absolutePath: '/tmp/b.json',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'absolute-file',
+ absolutePath: '/tmp/b.json',
+ grantId: 'grant-2'
+ }
+ })
+ ])
+ const onTerminalArtifactSourceRefreshed = vi.fn()
+
+ await expect(
+ saveMobileTerminalArtifactPreview(
+ client,
+ {
+ source: 'terminalArtifact',
+ worktreeId: 'wt-1',
+ absolutePath: '/tmp/a.json',
+ grantId: 'grant-1',
+ terminalHandle: 'term-1',
+ pathText: '/tmp/link.json'
+ },
+ '{"ok":false}',
+ { baseContent: '{"ok":true}', onTerminalArtifactSourceRefreshed }
+ )
+ ).resolves.toEqual({
+ status: 'error',
+ message: 'Reload preview before saving',
+ reconnect: false
+ })
+
+ expect(client.sendRequest).toHaveBeenCalledTimes(2)
+ expect(onTerminalArtifactSourceRefreshed).not.toHaveBeenCalled()
+ })
+
it.each([
['missing isBinary', { content: 'aW1hZ2U=', isImage: true, mimeType: 'image/png' }],
['missing isImage', { content: 'aW1hZ2U=', isBinary: true, mimeType: 'image/png' }],
@@ -116,6 +588,7 @@ describe('mobile-file-preview-request', () => {
true
],
['provider unavailable', 'Unable to reach the desktop filesystem', true],
+ ['terminal_file_grant_stale', 'Reload preview before saving', false],
['permission denied', 'Unable to load preview', false]
])('maps preview failure %s', (message, expected, reconnect) => {
expect(normalizeMobileFilePreviewResponse('src/app.ts', fail(message))).toEqual({
diff --git a/mobile/src/files/mobile-file-preview-request.ts b/mobile/src/files/mobile-file-preview-request.ts
index d7bdc4d3b65..ff6cd755c50 100644
--- a/mobile/src/files/mobile-file-preview-request.ts
+++ b/mobile/src/files/mobile-file-preview-request.ts
@@ -1,195 +1,256 @@
import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
-import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
+import type { RpcFailure, RpcResponse } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
-import { isMarkdownPath } from './file-tree'
+import {
+ normalizeMobileFilePreviewResponse,
+ previewError,
+ type MobileFilePreviewResult
+} from './mobile-file-preview-response'
+import {
+ refreshTerminalArtifactSourceAfterGrantFailure,
+ type MobileTerminalArtifactPreviewSource,
+ type TerminalArtifactRetryOptions
+} from './mobile-terminal-artifact-grant-refresh'
+
+export {
+ formatPreviewByteLength,
+ normalizeMobileFilePreviewResponse,
+ previewError
+} from './mobile-file-preview-response'
+export type {
+ MobileFilePreviewResult,
+ MobileFilePreviewTextKind
+} from './mobile-file-preview-response'
export type MobileFilePreviewReadMethod = 'files.read' | 'files.readPreview'
+export type MobileTerminalArtifactPreviewReadMethod =
+ | 'files.readTerminalArtifact'
+ | 'files.readTerminalArtifactPreview'
+
+export type MobileFilePreviewSource =
+ | {
+ source: 'worktree'
+ worktreeId: string
+ relativePath: string
+ }
+ | MobileTerminalArtifactPreviewSource
export type MobileFilePreviewRequest = {
- method: MobileFilePreviewReadMethod
+ method: MobileFilePreviewReadMethod | MobileTerminalArtifactPreviewReadMethod
params: {
worktree: string
- relativePath: string
+ relativePath?: string
+ absolutePath?: string
+ grantId?: string
}
}
-export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text'
-
-export type MobileFilePreviewResult =
- | {
- status: 'loading'
- message: string
- }
- | {
- status: 'waiting'
- message: string
- reconnect: true
- }
- | {
- status: 'ready'
- kind: 'image'
- dataUri: string
- }
- | {
- status: 'ready'
- kind: MobileFilePreviewTextKind
- content: string
- truncated: boolean
- byteLength: number
- }
- | {
- status: 'empty'
- kind: MobileFilePreviewTextKind
- }
- | {
- status: 'error'
- message: string
- reconnect: boolean
- }
-
type MobileFilePreviewClient = Pick
+type TerminalArtifactSource = MobileTerminalArtifactPreviewSource
+type TerminalArtifactSaveOptions = TerminalArtifactRetryOptions & {
+ baseContent?: string
+}
export function createMobileFilePreviewRequest(
- worktreeId: string,
- relativePath: string
+ worktreeIdOrSource: string | MobileFilePreviewSource,
+ relativePath?: string
): MobileFilePreviewRequest {
+ const source =
+ typeof worktreeIdOrSource === 'string'
+ ? { source: 'worktree' as const, worktreeId: worktreeIdOrSource, relativePath: relativePath! }
+ : worktreeIdOrSource
+ if (source.source === 'terminalArtifact') {
+ const method =
+ classifyMobileArtifact(source.absolutePath) === 'image'
+ ? 'files.readTerminalArtifactPreview'
+ : 'files.readTerminalArtifact'
+ return {
+ method,
+ params: {
+ worktree: `id:${source.worktreeId}`,
+ absolutePath: source.absolutePath,
+ grantId: source.grantId
+ }
+ }
+ }
return {
- method: classifyMobileArtifact(relativePath) === 'image' ? 'files.readPreview' : 'files.read',
+ method:
+ classifyMobileArtifact(source.relativePath) === 'image' ? 'files.readPreview' : 'files.read',
params: {
- worktree: `id:${worktreeId}`,
- relativePath
+ worktree: `id:${source.worktreeId}`,
+ relativePath: source.relativePath
}
}
}
export async function loadMobileFilePreview(
client: MobileFilePreviewClient,
- worktreeId: string,
- relativePath: string
+ worktreeIdOrSource: string | MobileFilePreviewSource,
+ relativePath?: string,
+ options: TerminalArtifactRetryOptions = {}
): Promise {
- const request = createMobileFilePreviewRequest(worktreeId, relativePath)
- const response = await client.sendRequest(request.method, request.params)
- return normalizeMobileFilePreviewResponse(relativePath, response)
+ let source = worktreeIdOrSource
+ let request = createMobileFilePreviewRequest(source, relativePath)
+ let response = await client.sendRequest(request.method, request.params)
+ if (!response.ok && typeof source !== 'string' && source.source === 'terminalArtifact') {
+ const refreshed = await refreshTerminalArtifactSourceAfterGrantFailure(
+ client,
+ source,
+ response,
+ options
+ )
+ if (refreshed) {
+ source = refreshed
+ options.onTerminalArtifactSourceRefreshed?.(refreshed)
+ request = createMobileFilePreviewRequest(source, relativePath)
+ response = await client.sendRequest(request.method, request.params)
+ }
+ }
+ const previewPath = typeof source === 'string' ? relativePath! : previewPathForSource(source)
+ return normalizeMobileFilePreviewResponse(previewPath, response)
}
-export function normalizeMobileFilePreviewResponse(
- relativePath: string,
- response: RpcResponse
-): MobileFilePreviewResult {
+export async function saveMobileTerminalArtifactPreview(
+ client: MobileFilePreviewClient,
+ source: TerminalArtifactSource,
+ content: string,
+ options: TerminalArtifactSaveOptions = {}
+): Promise {
+ let writeSource = source
+ if (typeof options.baseContent === 'string') {
+ const verified = await verifyTerminalArtifactBaseContent(
+ client,
+ writeSource,
+ options.baseContent,
+ options
+ )
+ if (verified.status === 'error') {
+ return verified.error
+ }
+ writeSource = verified.source
+ if (verified.refreshed) {
+ options.onTerminalArtifactSourceRefreshed?.(verified.source)
+ }
+ }
+ let response = await writeTerminalArtifactPreview(client, writeSource, content)
+ if (response.ok) {
+ return { status: 'saved' }
+ }
+
+ if (typeof options.baseContent !== 'string') {
+ return previewError(
+ (response as RpcFailure).error.message || (response as RpcFailure).error.code
+ )
+ }
+ const refreshed = await refreshTerminalArtifactSourceAfterGrantFailure(
+ client,
+ writeSource,
+ response,
+ options
+ )
+ if (!refreshed) {
+ return previewError(
+ (response as RpcFailure).error.message || (response as RpcFailure).error.code
+ )
+ }
+ const verified = await verifyTerminalArtifactBaseContent(client, refreshed, options.baseContent, {
+ refreshGrant: false
+ })
+ if (verified.status === 'error') {
+ return verified.error
+ }
+ options.onTerminalArtifactSourceRefreshed?.(refreshed)
+ writeSource = verified.source
+ response = await writeTerminalArtifactPreview(client, writeSource, content)
if (!response.ok) {
return previewError(
(response as RpcFailure).error.message || (response as RpcFailure).error.code
)
}
-
- const result = (response as RpcSuccess).result
- if (classifyMobileArtifact(relativePath) === 'image') {
- return normalizeImagePreviewResult(result)
- }
- return normalizeTextPreviewResult(relativePath, result)
+ return { status: 'saved' }
}
-export function previewError(message: string): MobileFilePreviewResult {
- const normalized = message.toLowerCase()
- if (normalized === 'binary_file' || normalized.includes('binary_file')) {
- return { status: 'error', message: 'Binary preview unavailable', reconnect: false }
+async function verifyTerminalArtifactBaseContent(
+ client: MobileFilePreviewClient,
+ source: TerminalArtifactSource,
+ baseContent: string,
+ options: TerminalArtifactRetryOptions
+): Promise<
+ | { status: 'ok'; source: TerminalArtifactSource; refreshed: boolean }
+ | { status: 'error'; error: MobileFilePreviewResult }
+> {
+ let readSource = source
+ let request = createMobileFilePreviewRequest(readSource)
+ let response = await client.sendRequest(request.method, request.params)
+ let refreshed = false
+ if (!response.ok) {
+ const nextSource = await refreshTerminalArtifactSourceAfterGrantFailure(
+ client,
+ readSource,
+ response,
+ options
+ )
+ if (!nextSource) {
+ return {
+ status: 'error',
+ error: previewError(
+ (response as RpcFailure).error.message || (response as RpcFailure).error.code
+ )
+ }
+ }
+ readSource = nextSource
+ refreshed = true
+ request = createMobileFilePreviewRequest(readSource)
+ response = await client.sendRequest(request.method, request.params)
}
- if (normalized === 'file_too_large' || normalized.includes('file_too_large')) {
- return { status: 'error', message: 'File too large for mobile preview', reconnect: false }
+ if (!response.ok) {
+ return {
+ status: 'error',
+ error: previewError(
+ (response as RpcFailure).error.message || (response as RpcFailure).error.code
+ )
+ }
}
- if (
- normalized.includes('remote connection dropped') ||
- normalized.includes('provider unavailable') ||
- normalized.includes('disconnected')
- ) {
- return { status: 'error', message: 'Unable to reach the desktop filesystem', reconnect: true }
+ const latest = normalizeMobileFilePreviewResponse(readSource.absolutePath, response)
+ if (latest.status === 'error' || latest.status === 'waiting') {
+ return { status: 'error', error: latest }
}
- if (
- normalized.includes('enoent') ||
- normalized.includes('no such file') ||
- normalized.includes('not found') ||
- normalized.includes('does not exist')
- ) {
- return { status: 'error', message: 'File not found', reconnect: false }
+ if (!terminalArtifactPreviewMatchesBase(latest, baseContent)) {
+ return {
+ status: 'error',
+ error: {
+ status: 'error',
+ message: 'File changed on desktop. Reload preview before saving',
+ reconnect: false
+ }
+ }
}
- return { status: 'error', message: 'Unable to load preview', reconnect: false }
+ return { status: 'ok', source: readSource, refreshed }
}
-export function formatPreviewByteLength(byteLength: number): string {
- if (!Number.isFinite(byteLength) || byteLength < 0) {
- return 'unknown size'
- }
- if (byteLength < 1024) {
- return `${byteLength} B`
- }
- if (byteLength < 1024 * 1024) {
- return `${Math.round(byteLength / 1024)} KB`
- }
- return `${(byteLength / (1024 * 1024)).toFixed(1)} MB`
+function writeTerminalArtifactPreview(
+ client: MobileFilePreviewClient,
+ source: TerminalArtifactSource,
+ content: string
+): Promise {
+ return client.sendRequest('files.writeTerminalArtifact', {
+ worktree: `id:${source.worktreeId}`,
+ absolutePath: source.absolutePath,
+ grantId: source.grantId,
+ content
+ })
}
-function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult {
- if (!result || typeof result !== 'object') {
- return previewError('binary_file')
- }
- const preview = result as {
- content?: unknown
- isBinary?: unknown
- isImage?: unknown
- mimeType?: unknown
- }
- if (
- preview.isBinary !== true ||
- preview.isImage !== true ||
- typeof preview.mimeType !== 'string' ||
- preview.mimeType.length === 0 ||
- typeof preview.content !== 'string' ||
- preview.content.length === 0
- ) {
- return previewError('binary_file')
- }
- return {
- status: 'ready',
- kind: 'image',
- dataUri: `data:${preview.mimeType};base64,${preview.content}`
+function terminalArtifactPreviewMatchesBase(
+ preview: MobileFilePreviewResult,
+ baseContent: string
+): boolean {
+ if (preview.status === 'empty') {
+ return baseContent.length === 0
}
+ return preview.status === 'ready' && preview.kind !== 'image' && preview.content === baseContent
}
-function normalizeTextPreviewResult(
- relativePath: string,
- result: unknown
-): MobileFilePreviewResult {
- if (!result || typeof result !== 'object') {
- return previewError('Unable to load preview')
- }
- const preview = result as {
- content?: unknown
- truncated?: unknown
- byteLength?: unknown
- isBinary?: unknown
- }
- if (preview.isBinary === true) {
- return previewError('binary_file')
- }
- if (typeof preview.content !== 'string') {
- return previewError('Unable to load preview')
- }
- const kind = textKindForPreviewPath(relativePath)
- if (preview.content.length === 0) {
- return { status: 'empty', kind }
- }
- return {
- status: 'ready',
- kind,
- content: preview.content,
- truncated: preview.truncated === true,
- byteLength: typeof preview.byteLength === 'number' ? preview.byteLength : preview.content.length
- }
-}
-
-function textKindForPreviewPath(relativePath: string): MobileFilePreviewTextKind {
- if (classifyMobileArtifact(relativePath) === 'html') {
- return 'html'
- }
- return isMarkdownPath(relativePath) ? 'markdown' : 'text'
+function previewPathForSource(source: MobileFilePreviewSource): string {
+ return source.source === 'terminalArtifact' ? source.absolutePath : source.relativePath
}
diff --git a/mobile/src/files/mobile-file-preview-response.ts b/mobile/src/files/mobile-file-preview-response.ts
new file mode 100644
index 00000000000..0b7b1b23be8
--- /dev/null
+++ b/mobile/src/files/mobile-file-preview-response.ts
@@ -0,0 +1,164 @@
+import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
+import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
+import { isMarkdownPath } from './file-tree'
+import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error'
+
+export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text'
+
+export type MobileFilePreviewResult =
+ | {
+ status: 'loading'
+ message: string
+ }
+ | {
+ status: 'waiting'
+ message: string
+ reconnect: true
+ }
+ | {
+ status: 'ready'
+ kind: 'image'
+ dataUri: string
+ }
+ | {
+ status: 'ready'
+ kind: MobileFilePreviewTextKind
+ content: string
+ truncated: boolean
+ byteLength: number
+ }
+ | {
+ status: 'empty'
+ kind: MobileFilePreviewTextKind
+ }
+ | {
+ status: 'error'
+ message: string
+ reconnect: boolean
+ }
+
+export function normalizeMobileFilePreviewResponse(
+ relativePath: string,
+ response: RpcResponse
+): MobileFilePreviewResult {
+ if (!response.ok) {
+ return previewError(
+ (response as RpcFailure).error.message || (response as RpcFailure).error.code
+ )
+ }
+
+ const result = (response as RpcSuccess).result
+ if (classifyMobileArtifact(relativePath) === 'image') {
+ return normalizeImagePreviewResult(result)
+ }
+ return normalizeTextPreviewResult(relativePath, result)
+}
+
+export function previewError(message: string): MobileFilePreviewResult {
+ const normalized = message.toLowerCase()
+ if (normalized === 'binary_file' || normalized.includes('binary_file')) {
+ return { status: 'error', message: 'Binary preview unavailable', reconnect: false }
+ }
+ if (normalized === 'file_too_large' || normalized.includes('file_too_large')) {
+ return { status: 'error', message: 'File too large for mobile preview', reconnect: false }
+ }
+ if (isTerminalArtifactGrantError(normalized)) {
+ return { status: 'error', message: 'Reload preview before saving', reconnect: false }
+ }
+ if (
+ normalized.includes('remote connection dropped') ||
+ normalized.includes('provider unavailable') ||
+ normalized.includes('disconnected') ||
+ normalized.includes('reconnect the ssh target')
+ ) {
+ return { status: 'error', message: 'Unable to reach the desktop filesystem', reconnect: true }
+ }
+ if (
+ normalized.includes('enoent') ||
+ normalized.includes('no such file') ||
+ normalized.includes('not found') ||
+ normalized.includes('does not exist')
+ ) {
+ return { status: 'error', message: 'File not found', reconnect: false }
+ }
+ return { status: 'error', message: 'Unable to load preview', reconnect: false }
+}
+
+export function formatPreviewByteLength(byteLength: number): string {
+ if (!Number.isFinite(byteLength) || byteLength < 0) {
+ return 'unknown size'
+ }
+ if (byteLength < 1024) {
+ return `${byteLength} B`
+ }
+ if (byteLength < 1024 * 1024) {
+ return `${Math.round(byteLength / 1024)} KB`
+ }
+ return `${(byteLength / (1024 * 1024)).toFixed(1)} MB`
+}
+
+function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult {
+ if (!result || typeof result !== 'object') {
+ return previewError('binary_file')
+ }
+ const preview = result as {
+ content?: unknown
+ isBinary?: unknown
+ isImage?: unknown
+ mimeType?: unknown
+ }
+ if (
+ preview.isBinary !== true ||
+ preview.isImage !== true ||
+ typeof preview.mimeType !== 'string' ||
+ preview.mimeType.length === 0 ||
+ typeof preview.content !== 'string' ||
+ preview.content.length === 0
+ ) {
+ return previewError('binary_file')
+ }
+ return {
+ status: 'ready',
+ kind: 'image',
+ dataUri: `data:${preview.mimeType};base64,${preview.content}`
+ }
+}
+
+function normalizeTextPreviewResult(
+ relativePath: string,
+ result: unknown
+): MobileFilePreviewResult {
+ if (!result || typeof result !== 'object') {
+ return previewError('Unable to load preview')
+ }
+ const preview = result as {
+ content?: unknown
+ truncated?: unknown
+ byteLength?: unknown
+ isBinary?: unknown
+ }
+ if (preview.isBinary === true) {
+ return previewError('binary_file')
+ }
+ if (typeof preview.content !== 'string') {
+ return previewError('Unable to load preview')
+ }
+ const kind = textKindForPreviewPath(relativePath)
+ if (preview.content.length === 0) {
+ return { status: 'empty', kind }
+ }
+ return {
+ status: 'ready',
+ kind,
+ content: preview.content,
+ truncated: preview.truncated === true,
+ byteLength: typeof preview.byteLength === 'number' ? preview.byteLength : preview.content.length
+ }
+}
+
+function textKindForPreviewPath(relativePath: string): MobileFilePreviewTextKind {
+ if (classifyMobileArtifact(relativePath) === 'html') {
+ return 'html'
+ }
+ return isMarkdownPath(relativePath) ? 'markdown' : 'text'
+}
diff --git a/mobile/src/files/mobile-file-preview-route.test.ts b/mobile/src/files/mobile-file-preview-route.test.ts
index b4cab941805..ac1888ed46f 100644
--- a/mobile/src/files/mobile-file-preview-route.test.ts
+++ b/mobile/src/files/mobile-file-preview-route.test.ts
@@ -22,12 +22,70 @@ describe('mobile-file-preview-route', () => {
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath,
+ source: 'worktree',
+ line: undefined,
+ column: undefined,
name: 'note.md',
worktreeName: 'Orca'
}
})
})
+ it('normalizes worktree preview line and column params', () => {
+ const route = normalizeMobileFilePreviewRouteParams({
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ relativePath: 'src/app.ts',
+ line: '120',
+ column: '7'
+ })
+
+ expect(route).toEqual({
+ ok: true,
+ params: {
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ relativePath: 'src/app.ts',
+ source: 'worktree',
+ line: '120',
+ column: '7',
+ name: undefined,
+ worktreeName: undefined
+ }
+ })
+ })
+
+ it('normalizes terminal artifact params without requiring a relative path', () => {
+ const route = normalizeMobileFilePreviewRouteParams({
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ source: 'terminalArtifact',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1',
+ terminal: 'term-1',
+ pathText: 'result.json',
+ cwd: '/tmp/run',
+ line: '12',
+ column: '3'
+ })
+
+ expect(route).toEqual({
+ ok: true,
+ params: {
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ source: 'terminalArtifact',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1',
+ terminal: 'term-1',
+ pathText: 'result.json',
+ cwd: '/tmp/run',
+ line: '12',
+ column: '3'
+ }
+ })
+ })
+
it('rejects missing, empty, or array-valued required params', () => {
expect(normalizeMobileFilePreviewRouteParams({ hostId: 'h', worktreeId: 'w' })).toEqual({
ok: false,
diff --git a/mobile/src/files/mobile-file-preview-route.ts b/mobile/src/files/mobile-file-preview-route.ts
index b758c1214cf..bbc3d0e8f88 100644
--- a/mobile/src/files/mobile-file-preview-route.ts
+++ b/mobile/src/files/mobile-file-preview-route.ts
@@ -3,7 +3,15 @@ export type MobileFilePreviewParamValue = string | string[] | undefined
export type MobileFilePreviewRouteParams = {
hostId: string
worktreeId: string
- relativePath: string
+ relativePath?: string
+ source?: 'worktree' | 'terminalArtifact'
+ absolutePath?: string
+ grantId?: string
+ terminal?: string
+ pathText?: string
+ cwd?: string
+ line?: string
+ column?: string
name?: string
worktreeName?: string
}
@@ -21,6 +29,14 @@ type RawPreviewRouteParams = {
hostId?: MobileFilePreviewParamValue
worktreeId?: MobileFilePreviewParamValue
relativePath?: MobileFilePreviewParamValue
+ source?: MobileFilePreviewParamValue
+ absolutePath?: MobileFilePreviewParamValue
+ grantId?: MobileFilePreviewParamValue
+ terminal?: MobileFilePreviewParamValue
+ pathText?: MobileFilePreviewParamValue
+ cwd?: MobileFilePreviewParamValue
+ line?: MobileFilePreviewParamValue
+ column?: MobileFilePreviewParamValue
name?: MobileFilePreviewParamValue
worktreeName?: MobileFilePreviewParamValue
}
@@ -39,7 +55,35 @@ export function normalizeMobileFilePreviewRouteParams(
const hostId = singleParam(params.hostId)
const worktreeId = singleParam(params.worktreeId)
const relativePath = singleParam(params.relativePath)
- if (!hostId || !worktreeId || !relativePath) {
+ const source = singleParam(params.source)
+ const absolutePath = singleParam(params.absolutePath)
+ const grantId = singleParam(params.grantId)
+ if (!hostId || !worktreeId) {
+ return { ok: false, message: 'Unable to load preview' }
+ }
+ if (source === 'terminalArtifact') {
+ if (!absolutePath || !grantId) {
+ return { ok: false, message: 'Unable to load preview' }
+ }
+ return {
+ ok: true,
+ params: {
+ hostId,
+ worktreeId,
+ source,
+ absolutePath,
+ grantId,
+ terminal: optionalSingleParam(params.terminal),
+ pathText: optionalSingleParam(params.pathText),
+ cwd: optionalSingleParam(params.cwd),
+ line: optionalSingleParam(params.line),
+ column: optionalSingleParam(params.column),
+ name: optionalSingleParam(params.name),
+ worktreeName: optionalSingleParam(params.worktreeName)
+ }
+ }
+ }
+ if (!relativePath) {
return { ok: false, message: 'Unable to load preview' }
}
return {
@@ -48,6 +92,9 @@ export function normalizeMobileFilePreviewRouteParams(
hostId,
worktreeId,
relativePath,
+ source: 'worktree',
+ line: optionalSingleParam(params.line),
+ column: optionalSingleParam(params.column),
name: optionalSingleParam(params.name),
worktreeName: optionalSingleParam(params.worktreeName)
}
diff --git a/mobile/src/files/mobile-file-preview-source.test.ts b/mobile/src/files/mobile-file-preview-source.test.ts
new file mode 100644
index 00000000000..b5fec782cfc
--- /dev/null
+++ b/mobile/src/files/mobile-file-preview-source.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import { sourceKeyForPreview } from './mobile-file-preview-source'
+
+describe('mobile-file-preview-source', () => {
+ it('uses structured preview keys so colons in paths cannot collide', () => {
+ const first = sourceKeyForPreview({
+ source: 'terminalArtifact',
+ worktreeId: 'wt:1',
+ absolutePath: '/tmp/a',
+ grantId: 'grant-1',
+ terminalHandle: 'b:c'
+ })
+ const second = sourceKeyForPreview({
+ source: 'terminalArtifact',
+ worktreeId: 'wt',
+ absolutePath: '1:/tmp/a:b',
+ grantId: 'grant-2',
+ terminalHandle: 'c'
+ })
+
+ expect(first).toBe(JSON.stringify(['terminal', 'wt:1', '/tmp/a', 'b:c']))
+ expect(second).toBe(JSON.stringify(['terminal', 'wt', '1:/tmp/a:b', 'c']))
+ expect(first).not.toBe(second)
+ expect(
+ sourceKeyForPreview({ source: 'worktree', worktreeId: 'wt:1', relativePath: 'a:b.ts' })
+ ).toBe(JSON.stringify(['worktree', 'wt:1', 'a:b.ts']))
+ })
+})
diff --git a/mobile/src/files/mobile-file-preview-source.ts b/mobile/src/files/mobile-file-preview-source.ts
new file mode 100644
index 00000000000..b1b23757a6e
--- /dev/null
+++ b/mobile/src/files/mobile-file-preview-source.ts
@@ -0,0 +1,39 @@
+import type { MobileFilePreviewRouteParams } from './mobile-file-preview-route'
+import type { MobileFilePreviewSource } from './mobile-file-preview-request'
+
+export function previewSourceFromRoute(
+ params: MobileFilePreviewRouteParams
+): MobileFilePreviewSource | null {
+ if (params.source === 'terminalArtifact') {
+ if (!params.absolutePath || !params.grantId) {
+ return null
+ }
+ return {
+ source: 'terminalArtifact',
+ worktreeId: params.worktreeId,
+ absolutePath: params.absolutePath,
+ grantId: params.grantId,
+ ...(params.terminal ? { terminalHandle: params.terminal } : {}),
+ ...(params.pathText ? { pathText: params.pathText } : {}),
+ ...(params.cwd ? { cwd: params.cwd } : {})
+ }
+ }
+ if (!params.relativePath) {
+ return null
+ }
+ return { source: 'worktree', worktreeId: params.worktreeId, relativePath: params.relativePath }
+}
+
+export function sourceKeyForPreview(source: MobileFilePreviewSource | null): string | null {
+ if (!source) {
+ return null
+ }
+ return source.source === 'terminalArtifact'
+ ? JSON.stringify([
+ 'terminal',
+ source.worktreeId,
+ source.absolutePath,
+ source.terminalHandle ?? ''
+ ])
+ : JSON.stringify(['worktree', source.worktreeId, source.relativePath])
+}
diff --git a/mobile/src/files/mobile-file-preview-styles.ts b/mobile/src/files/mobile-file-preview-styles.ts
index 0e7c2c5db22..5b0df61caa5 100644
--- a/mobile/src/files/mobile-file-preview-styles.ts
+++ b/mobile/src/files/mobile-file-preview-styles.ts
@@ -73,6 +73,17 @@ export const filePreviewStyles = StyleSheet.create({
fontSize: typography.bodySize,
fontWeight: '600'
},
+ saveButton: {
+ width: 36,
+ height: 36,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radii.button,
+ backgroundColor: colors.bgRaised
+ },
+ saveButtonDisabled: {
+ opacity: 0.42
+ },
scroll: {
flex: 1,
backgroundColor: colors.editorSurface
@@ -136,5 +147,23 @@ export const filePreviewStyles = StyleSheet.create({
},
image: {
backgroundColor: colors.editorSurface
+ },
+ editContainer: {
+ flex: 1,
+ backgroundColor: colors.editorSurface,
+ padding: spacing.md
+ },
+ saveErrorText: {
+ marginBottom: spacing.sm,
+ color: colors.statusRed,
+ fontSize: typography.metaSize
+ },
+ editInput: {
+ flex: 1,
+ color: colors.textPrimary,
+ fontFamily: typography.monoFamily,
+ fontSize: 13,
+ lineHeight: 19,
+ padding: 0
}
})
diff --git a/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts b/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts
new file mode 100644
index 00000000000..a8864119be4
--- /dev/null
+++ b/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts
@@ -0,0 +1,88 @@
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
+import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error'
+
+type MobileFilePreviewClient = Pick
+
+export type MobileTerminalArtifactPreviewSource = {
+ source: 'terminalArtifact'
+ worktreeId: string
+ absolutePath: string
+ grantId: string
+ terminalHandle?: string
+ pathText?: string
+ cwd?: string
+}
+
+export type TerminalArtifactRetryOptions = {
+ onTerminalArtifactSourceRefreshed?: (source: MobileTerminalArtifactPreviewSource) => void
+ refreshGrant?: boolean
+}
+
+export async function refreshTerminalArtifactSourceAfterGrantFailure(
+ client: MobileFilePreviewClient,
+ source: MobileTerminalArtifactPreviewSource,
+ response: RpcResponse,
+ options: TerminalArtifactRetryOptions = {}
+): Promise {
+ if (response.ok || !isTerminalArtifactGrantFailure(response, options)) {
+ return null
+ }
+ const refreshed = await client.sendRequest('files.resolveTerminalPath', {
+ worktree: `id:${source.worktreeId}`,
+ pathText: source.pathText ?? source.absolutePath,
+ ...(source.cwd ? { cwd: source.cwd } : {}),
+ ...(source.terminalHandle ? { terminal: source.terminalHandle } : {})
+ })
+ if (!refreshed.ok) {
+ return null
+ }
+ const result = (refreshed as RpcSuccess).result
+ if (!isTerminalArtifactResolution(result)) {
+ return null
+ }
+ if (result.openTarget.absolutePath !== source.absolutePath) {
+ return null
+ }
+ return {
+ source: 'terminalArtifact',
+ worktreeId: source.worktreeId,
+ absolutePath: result.openTarget.absolutePath,
+ grantId: result.openTarget.grantId,
+ ...(source.terminalHandle ? { terminalHandle: source.terminalHandle } : {}),
+ ...(source.pathText ? { pathText: source.pathText } : {}),
+ ...(source.cwd ? { cwd: source.cwd } : {})
+ }
+}
+
+function isTerminalArtifactGrantFailure(
+ response: RpcFailure,
+ options: TerminalArtifactRetryOptions
+): boolean {
+ if (options.refreshGrant === false) {
+ return false
+ }
+ return isTerminalArtifactGrantError(`${response.error.code} ${response.error.message}`)
+}
+
+function isTerminalArtifactResolution(result: unknown): result is {
+ exists: true
+ isDirectory: false
+ openTarget: { kind: 'absolute-file'; absolutePath: string; grantId: string }
+} {
+ if (!result || typeof result !== 'object') {
+ return false
+ }
+ const resolution = result as {
+ exists?: unknown
+ isDirectory?: unknown
+ openTarget?: { kind?: unknown; absolutePath?: unknown; grantId?: unknown }
+ }
+ return (
+ resolution.exists === true &&
+ resolution.isDirectory === false &&
+ resolution.openTarget?.kind === 'absolute-file' &&
+ typeof resolution.openTarget.absolutePath === 'string' &&
+ typeof resolution.openTarget.grantId === 'string'
+ )
+}
diff --git a/mobile/src/files/terminal-artifact-grant-error.ts b/mobile/src/files/terminal-artifact-grant-error.ts
new file mode 100644
index 00000000000..25a33fb8402
--- /dev/null
+++ b/mobile/src/files/terminal-artifact-grant-error.ts
@@ -0,0 +1,11 @@
+// Why: the runtime rejects stale terminal-artifact grants with these codes, but
+// the RPC mux flattens error shapes to code+message strings, so mobile matches
+// by substring. Single source so grant refresh and preview errors can't drift.
+export function isTerminalArtifactGrantError(text: string): boolean {
+ const normalized = text.toLowerCase()
+ return (
+ normalized.includes('terminal_file_grant_expired') ||
+ normalized.includes('terminal_file_grant_mismatch') ||
+ normalized.includes('terminal_file_grant_stale')
+ )
+}
diff --git a/mobile/src/session/mobile-image-attachment.test.ts b/mobile/src/session/mobile-image-attachment.test.ts
index 3a94348aab4..1bf3df59e74 100644
--- a/mobile/src/session/mobile-image-attachment.test.ts
+++ b/mobile/src/session/mobile-image-attachment.test.ts
@@ -118,4 +118,30 @@ describe('attachMobileImageToTerminal', () => {
const sendCall = client.calls.find((c) => c.method === 'terminal.send')
expect(sendCall?.params).not.toHaveProperty('client')
})
+
+ it('waits for pending live input before sending the image payload', async () => {
+ const client = clientWithResponses([
+ {
+ id: 'start',
+ ok: false,
+ error: { code: 'method_not_found', message: 'no' },
+ _meta: { runtimeId: 'r' }
+ },
+ ok('save', '/tmp/pending.png')
+ ])
+ const beforeTerminalSend = vi.fn(async () => false)
+
+ const sent = await attachMobileImageToTerminal('library', {
+ client,
+ terminal: 'term-pending',
+ deviceToken: null,
+ getConnectionId: async () => null,
+ pickImage: vi.fn().mockResolvedValue({ base64: 'DDDD' }),
+ beforeTerminalSend
+ })
+
+ expect(sent).toBe(false)
+ expect(beforeTerminalSend).toHaveBeenCalledWith('term-pending')
+ expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false)
+ })
})
diff --git a/mobile/src/session/mobile-image-attachment.ts b/mobile/src/session/mobile-image-attachment.ts
index 507b58eb671..b7d14d9a04d 100644
--- a/mobile/src/session/mobile-image-attachment.ts
+++ b/mobile/src/session/mobile-image-attachment.ts
@@ -16,6 +16,7 @@ export type AttachMobileImageDeps = {
// start — lets the UI show a sending spinner only for the transfer, not the
// (potentially long) time the picker is open.
readonly onUploadStart?: () => void
+ readonly beforeTerminalSend?: (terminal: string) => Promise
}
// Uploads a picked image to the host and pastes the resulting file path into the
@@ -30,7 +31,8 @@ export async function attachMobileImageToTerminal(
deviceToken,
getConnectionId,
pickImage,
- onUploadStart
+ onUploadStart,
+ beforeTerminalSend
}: AttachMobileImageDeps
): Promise {
const picked = await pickImage(source)
@@ -45,6 +47,9 @@ export async function attachMobileImageToTerminal(
// Why: a generated image path is terminal image injection, so it's always
// bracketed (matching desktop paste) regardless of terminal mode.
const payload = buildMobileImagePastePayload(imagePath)
+ if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) {
+ return false
+ }
await client.sendRequest('terminal.send', {
terminal,
text: payload,
diff --git a/mobile/src/session/mobile-terminal-file-tap-open.test.ts b/mobile/src/session/mobile-terminal-file-tap-open.test.ts
new file mode 100644
index 00000000000..fec1b5b663c
--- /dev/null
+++ b/mobile/src/session/mobile-terminal-file-tap-open.test.ts
@@ -0,0 +1,432 @@
+import { describe, expect, it, vi } from 'vitest'
+import { openMobileTerminalFileTap } from './mobile-terminal-file-tap-open'
+
+function ok(result: unknown) {
+ return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
+}
+
+function createClient(responses: unknown[]) {
+ return {
+ sendRequest: vi.fn(async () => responses.shift())
+ }
+}
+
+function activeTerminalState(activated: boolean) {
+ return {
+ activated,
+ activationSeq: 1,
+ latestActivationSeq: 1,
+ sourceTerminalHandle: 'terminal-1',
+ activeTerminalHandle: 'terminal-1',
+ activeTabType: 'terminal'
+ }
+}
+
+describe('openMobileTerminalFileTap', () => {
+ it('opens absolute terminal artifacts through the grant-backed preview route', async () => {
+ const client = createClient([
+ ok({
+ worktree: 'wt-1',
+ relativePath: null,
+ absolutePath: '/tmp/result.json',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'absolute-file',
+ provider: 'local',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1'
+ }
+ })
+ ])
+ const pushPreviewRoute = vi.fn()
+ const triggerOpenFeedback = vi.fn()
+
+ openMobileTerminalFileTap({
+ client,
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ pathText: '/tmp/result.json',
+ terminalHandle: 'terminal-1',
+ line: 12,
+ column: 3,
+ pushPreviewRoute,
+ openBrowser: vi.fn(),
+ triggerOpenFeedback,
+ fetchSessionTabs: vi.fn(),
+ getSessionTabs: () => [],
+ getActiveSessionTabId: () => null,
+ getActivationState: activeTerminalState,
+ switchSessionTab: vi.fn(),
+ scheduleDelayedAction: vi.fn()
+ })
+ await Promise.resolve()
+
+ expect(client.sendRequest).toHaveBeenCalledWith(
+ 'files.resolveTerminalPath',
+ { worktree: 'id:wt-1', pathText: '/tmp/result.json', terminal: 'terminal-1' },
+ { timeoutMs: 10_000 }
+ )
+ expect(pushPreviewRoute).toHaveBeenCalledWith({
+ pathname: '/h/[hostId]/files/preview/[worktreeId]',
+ params: expect.objectContaining({
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ source: 'terminalArtifact',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1',
+ pathText: '/tmp/result.json',
+ terminal: 'terminal-1',
+ line: '12',
+ column: '3'
+ })
+ })
+ expect(triggerOpenFeedback).toHaveBeenCalledTimes(1)
+ expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
+ })
+
+ it('preserves the worktree-contained files.open flow', async () => {
+ const client = createClient([
+ ok({
+ worktree: 'wt-1',
+ relativePath: 'src/index.ts',
+ absolutePath: '/repo/src/index.ts',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'worktree-file',
+ provider: 'local',
+ relativePath: 'src/index.ts',
+ absolutePath: '/repo/src/index.ts'
+ }
+ }),
+ ok({ opened: true })
+ ])
+ const scheduleDelayedAction = vi.fn((callback: () => void) => callback())
+ const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' }
+ const switchSessionTab = vi.fn()
+
+ openMobileTerminalFileTap({
+ client,
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ pathText: 'src/index.ts',
+ line: null,
+ column: null,
+ pushPreviewRoute: vi.fn(),
+ openBrowser: vi.fn(),
+ triggerOpenFeedback: vi.fn(),
+ fetchSessionTabs: vi.fn(),
+ getSessionTabs: () => [openedTab],
+ getActiveSessionTabId: () => 'terminal-tab',
+ getActivationState: activeTerminalState,
+ switchSessionTab,
+ scheduleDelayedAction
+ })
+ await Promise.resolve()
+ await Promise.resolve()
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(client.sendRequest).toHaveBeenCalledWith(
+ 'files.open',
+ { worktree: 'id:wt-1', relativePath: 'src/index.ts' },
+ { timeoutMs: 15_000 }
+ )
+ expect(switchSessionTab).toHaveBeenCalledWith(openedTab)
+ })
+
+ it('opens worktree-contained line references through the preview route', async () => {
+ const client = createClient([
+ ok({
+ worktree: 'wt-1',
+ relativePath: 'src/index.ts',
+ absolutePath: '/repo/src/index.ts',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'worktree-file',
+ provider: 'local',
+ relativePath: 'src/index.ts',
+ absolutePath: '/repo/src/index.ts'
+ }
+ })
+ ])
+ const pushPreviewRoute = vi.fn()
+ const triggerOpenFeedback = vi.fn()
+
+ openMobileTerminalFileTap({
+ client,
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ worktreeName: 'Orca',
+ pathText: 'src/index.ts:120:7',
+ line: 120,
+ column: 7,
+ pushPreviewRoute,
+ openBrowser: vi.fn(),
+ triggerOpenFeedback,
+ fetchSessionTabs: vi.fn(),
+ getSessionTabs: () => [],
+ getActiveSessionTabId: () => null,
+ getActivationState: activeTerminalState,
+ switchSessionTab: vi.fn(),
+ scheduleDelayedAction: vi.fn()
+ })
+ await Promise.resolve()
+
+ expect(pushPreviewRoute).toHaveBeenCalledWith({
+ pathname: '/h/[hostId]/files/preview/[worktreeId]',
+ params: expect.objectContaining({
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ source: 'worktree',
+ relativePath: 'src/index.ts',
+ line: '120',
+ column: '7',
+ worktreeName: 'Orca'
+ })
+ })
+ expect(triggerOpenFeedback).toHaveBeenCalledTimes(1)
+ expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
+ })
+
+ it('encodes worktree HTML paths before opening a browser tab', async () => {
+ const client = createClient([
+ ok({
+ worktree: 'wt-1',
+ relativePath: 'public/report #1?.html',
+ absolutePath: '/repo/public/report #1?.html',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'worktree-file',
+ provider: 'local',
+ relativePath: 'public/report #1?.html',
+ absolutePath: '/repo/public/report #1?.html'
+ }
+ })
+ ])
+ const openBrowser = vi.fn()
+
+ openMobileTerminalFileTap({
+ client,
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ pathText: 'public/report #1?.html',
+ line: null,
+ column: null,
+ pushPreviewRoute: vi.fn(),
+ openBrowser,
+ triggerOpenFeedback: vi.fn(),
+ fetchSessionTabs: vi.fn(),
+ getSessionTabs: () => [],
+ getActiveSessionTabId: () => null,
+ getActivationState: activeTerminalState,
+ switchSessionTab: vi.fn(),
+ scheduleDelayedAction: vi.fn()
+ })
+ await Promise.resolve()
+
+ expect(openBrowser).toHaveBeenCalledWith('file:///repo/public/report%20%231%3F.html')
+ expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
+ })
+
+ it('passes the terminal cwd when resolving relative taps', async () => {
+ const client = createClient([
+ ok({
+ worktree: 'wt-1',
+ relativePath: 'src/index.ts',
+ absolutePath: '/repo/src/index.ts',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'worktree-file',
+ provider: 'local',
+ relativePath: 'src/index.ts',
+ absolutePath: '/repo/src/index.ts'
+ }
+ }),
+ ok({ opened: true })
+ ])
+
+ openMobileTerminalFileTap({
+ client,
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ pathText: 'index.ts',
+ terminalHandle: 'term-1',
+ cwd: '/repo/src',
+ line: null,
+ column: null,
+ pushPreviewRoute: vi.fn(),
+ openBrowser: vi.fn(),
+ triggerOpenFeedback: vi.fn(),
+ fetchSessionTabs: vi.fn(),
+ getSessionTabs: () => [],
+ getActiveSessionTabId: () => null,
+ getActivationState: activeTerminalState,
+ switchSessionTab: vi.fn(),
+ scheduleDelayedAction: vi.fn()
+ })
+ await Promise.resolve()
+
+ expect(client.sendRequest).toHaveBeenCalledWith(
+ 'files.resolveTerminalPath',
+ { worktree: 'id:wt-1', pathText: 'index.ts', terminal: 'term-1', cwd: '/repo/src' },
+ { timeoutMs: 10_000 }
+ )
+ })
+
+ it('does not open SSH worktree HTML paths as local browser file URLs', async () => {
+ const client = createClient([
+ ok({
+ worktree: 'wt-1',
+ relativePath: 'report.html',
+ absolutePath: '/home/me/repo/report.html',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'worktree-file',
+ provider: 'ssh',
+ relativePath: 'report.html',
+ absolutePath: '/home/me/repo/report.html'
+ }
+ }),
+ ok({ opened: true })
+ ])
+ const openBrowser = vi.fn()
+
+ openMobileTerminalFileTap({
+ client,
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ pathText: 'report.html',
+ line: null,
+ column: null,
+ pushPreviewRoute: vi.fn(),
+ openBrowser,
+ triggerOpenFeedback: vi.fn(),
+ fetchSessionTabs: vi.fn(),
+ getSessionTabs: () => [],
+ getActiveSessionTabId: () => null,
+ getActivationState: activeTerminalState,
+ switchSessionTab: vi.fn(),
+ scheduleDelayedAction: vi.fn()
+ })
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(openBrowser).not.toHaveBeenCalled()
+ expect(client.sendRequest).toHaveBeenCalledWith(
+ 'files.open',
+ { worktree: 'id:wt-1', relativePath: 'report.html' },
+ { timeoutMs: 15_000 }
+ )
+ })
+
+ it('does not navigate an absolute artifact after the user leaves the source terminal', async () => {
+ let resolveRequest: (value: unknown) => void = () => {}
+ const client = {
+ sendRequest: vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveRequest = resolve
+ })
+ )
+ }
+ let activeTerminalHandle: string | null = 'terminal-1'
+ const pushPreviewRoute = vi.fn()
+
+ openMobileTerminalFileTap({
+ client,
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ pathText: '/tmp/result.json',
+ terminalHandle: 'terminal-1',
+ line: null,
+ column: null,
+ pushPreviewRoute,
+ openBrowser: vi.fn(),
+ triggerOpenFeedback: vi.fn(),
+ fetchSessionTabs: vi.fn(),
+ getSessionTabs: () => [],
+ getActiveSessionTabId: () => null,
+ getActivationState: (activated) => ({
+ ...activeTerminalState(activated),
+ activeTerminalHandle
+ }),
+ switchSessionTab: vi.fn(),
+ scheduleDelayedAction: vi.fn()
+ })
+
+ activeTerminalHandle = 'terminal-2'
+ resolveRequest(
+ ok({
+ worktree: 'wt-1',
+ relativePath: null,
+ absolutePath: '/tmp/result.json',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'absolute-file',
+ provider: 'local',
+ absolutePath: '/tmp/result.json',
+ grantId: 'grant-1'
+ }
+ })
+ )
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(pushPreviewRoute).not.toHaveBeenCalled()
+ })
+
+ it('does not activate a worktree file tab after a newer tap supersedes it', async () => {
+ const client = createClient([
+ ok({
+ worktree: 'wt-1',
+ relativePath: 'src/index.ts',
+ absolutePath: '/repo/src/index.ts',
+ exists: true,
+ isDirectory: false,
+ openTarget: {
+ kind: 'worktree-file',
+ provider: 'local',
+ relativePath: 'src/index.ts',
+ absolutePath: '/repo/src/index.ts'
+ }
+ }),
+ ok({ opened: true })
+ ])
+ const callbacks: (() => void)[] = []
+ const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' }
+ const switchSessionTab = vi.fn()
+
+ openMobileTerminalFileTap({
+ client,
+ hostId: 'host-1',
+ worktreeId: 'wt-1',
+ pathText: 'src/index.ts',
+ line: null,
+ column: null,
+ pushPreviewRoute: vi.fn(),
+ openBrowser: vi.fn(),
+ triggerOpenFeedback: vi.fn(),
+ fetchSessionTabs: vi.fn(),
+ getSessionTabs: () => [openedTab],
+ getActiveSessionTabId: () => 'terminal-tab',
+ getActivationState: (activated) => ({
+ ...activeTerminalState(activated),
+ latestActivationSeq: 2
+ }),
+ switchSessionTab,
+ scheduleDelayedAction: (callback) => callbacks.push(callback)
+ })
+ await Promise.resolve()
+ await Promise.resolve()
+ callbacks.forEach((callback) => callback())
+ await Promise.resolve()
+
+ expect(switchSessionTab).not.toHaveBeenCalled()
+ })
+})
diff --git a/mobile/src/session/mobile-terminal-file-tap-open.ts b/mobile/src/session/mobile-terminal-file-tap-open.ts
new file mode 100644
index 00000000000..8606467ee80
--- /dev/null
+++ b/mobile/src/session/mobile-terminal-file-tap-open.ts
@@ -0,0 +1,173 @@
+import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types'
+import { filesystemPathToFileUri } from '../../../src/shared/file-uri-path'
+import { createMobileFilePreviewHref } from '../files/mobile-file-preview-route'
+import { classifyMobileArtifact } from './mobile-artifact-kind'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcSuccess } from '../transport/types'
+import { shouldActivateOpenedMobileSessionTab } from './opened-mobile-session-tab'
+
+type TerminalFileTapSessionTab = {
+ id: string
+ relativePath?: string
+}
+
+type OpenMobileTerminalFileTapOptions = {
+ client: Pick
+ hostId: string
+ worktreeId: string
+ worktreeName?: string
+ terminalHandle?: string | null
+ pathText: string
+ cwd?: string | null
+ line: number | null
+ column: number | null
+ pushPreviewRoute: (href: ReturnType) => void
+ openBrowser: (url: string) => void
+ triggerOpenFeedback: () => void
+ fetchSessionTabs: () => Promise
+ getSessionTabs: () => readonly T[]
+ getActiveSessionTabId: () => string | null
+ getActivationState: (activated: boolean) => {
+ activated: boolean
+ activationSeq: number
+ latestActivationSeq: number
+ sourceTerminalHandle: string
+ activeTerminalHandle: string | null
+ activeTabType: string | null
+ }
+ switchSessionTab: (tab: T) => void
+ scheduleDelayedAction: (callback: () => void, delayMs: number) => unknown
+}
+
+export function openMobileTerminalFileTap(
+ options: OpenMobileTerminalFileTapOptions
+): void {
+ void openMobileTerminalFileTapAsync(options).catch(() => {
+ // Terminal file taps are best-effort: a failed host resolution should leave
+ // terminal focus/input untouched, matching the existing silent miss behavior.
+ })
+}
+
+async function openMobileTerminalFileTapAsync(
+ options: OpenMobileTerminalFileTapOptions
+): Promise {
+ const worktree = `id:${options.worktreeId}`
+ const response = await options.client.sendRequest(
+ 'files.resolveTerminalPath',
+ {
+ worktree,
+ pathText: options.pathText,
+ ...(options.terminalHandle && options.terminalHandle.trim().length > 0
+ ? { terminal: options.terminalHandle }
+ : {}),
+ ...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {})
+ },
+ { timeoutMs: 10_000 }
+ )
+ if (!response.ok) {
+ return
+ }
+ const resolved = (response as RpcSuccess).result as RuntimeTerminalPathResolution
+ if (!resolved.exists || resolved.isDirectory) {
+ return
+ }
+ if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(false))) {
+ return
+ }
+
+ if (resolved.openTarget?.kind === 'absolute-file') {
+ options.triggerOpenFeedback()
+ options.pushPreviewRoute(
+ createMobileFilePreviewHref({
+ hostId: options.hostId,
+ worktreeId: options.worktreeId,
+ source: 'terminalArtifact',
+ absolutePath: resolved.openTarget.absolutePath,
+ grantId: resolved.openTarget.grantId,
+ pathText: options.pathText,
+ ...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {}),
+ ...(options.terminalHandle && options.terminalHandle.trim().length > 0
+ ? { terminal: options.terminalHandle }
+ : {}),
+ name: displayNameFromPath(resolved.openTarget.absolutePath),
+ ...(options.line !== null ? { line: String(options.line) } : {}),
+ ...(options.column !== null ? { column: String(options.column) } : {}),
+ ...(options.worktreeName ? { worktreeName: options.worktreeName } : {})
+ })
+ )
+ return
+ }
+
+ const openedPath =
+ resolved.openTarget?.kind === 'worktree-file'
+ ? resolved.openTarget.relativePath
+ : resolved.relativePath
+ if (!openedPath) {
+ return
+ }
+ options.triggerOpenFeedback()
+ if (options.line !== null || options.column !== null) {
+ options.pushPreviewRoute(
+ createMobileFilePreviewHref({
+ hostId: options.hostId,
+ worktreeId: options.worktreeId,
+ source: 'worktree',
+ relativePath: openedPath,
+ name: displayNameFromPath(openedPath),
+ ...(options.line !== null ? { line: String(options.line) } : {}),
+ ...(options.column !== null ? { column: String(options.column) } : {}),
+ ...(options.worktreeName ? { worktreeName: options.worktreeName } : {})
+ })
+ )
+ return
+ }
+ if (
+ classifyMobileArtifact(openedPath) === 'html' &&
+ resolved.openTarget?.kind === 'worktree-file' &&
+ resolved.openTarget.provider === 'local'
+ ) {
+ options.openBrowser(filesystemPathToFileUri(resolved.openTarget.absolutePath))
+ return
+ }
+ const openResponse = await options.client.sendRequest(
+ 'files.open',
+ { worktree, relativePath: openedPath },
+ { timeoutMs: 15_000 }
+ )
+ if (!openResponse.ok) {
+ return
+ }
+ scheduleOpenedWorktreeTabActivation(options, openedPath)
+}
+
+function scheduleOpenedWorktreeTabActivation(
+ options: OpenMobileTerminalFileTapOptions,
+ openedPath: string
+): void {
+ let activated = false
+ const activateOpenedTab = async (): Promise => {
+ if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(activated))) {
+ return
+ }
+ await options.fetchSessionTabs()
+ if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(activated))) {
+ return
+ }
+ const opened = options.getSessionTabs().find((tab) => tab.relativePath === openedPath)
+ if (!opened) {
+ return
+ }
+ if (options.getActiveSessionTabId() !== opened.id) {
+ options.switchSessionTab(opened)
+ }
+ activated = true
+ }
+
+ options.scheduleDelayedAction(() => void activateOpenedTab(), 300)
+ options.scheduleDelayedAction(() => void activateOpenedTab(), 900)
+ options.scheduleDelayedAction(() => void activateOpenedTab(), 1800)
+}
+
+function displayNameFromPath(path: string): string | undefined {
+ return path.split(/[\\/]/).filter(Boolean).at(-1)
+}
diff --git a/mobile/src/session/use-mobile-image-attachment.ts b/mobile/src/session/use-mobile-image-attachment.ts
index 3894928f4f5..417dd8d699b 100644
--- a/mobile/src/session/use-mobile-image-attachment.ts
+++ b/mobile/src/session/use-mobile-image-attachment.ts
@@ -24,6 +24,7 @@ type UseMobileImageAttachmentArgs = {
readonly showToast: ShowToast
readonly onSuccess: () => void
readonly onError: () => void
+ readonly beforeTerminalSend?: (terminal: string) => Promise
}
type MobileImageAttachment = {
@@ -46,7 +47,8 @@ export function useMobileImageAttachment({
getActiveWorktreeConnectionId,
showToast,
onSuccess,
- onError
+ onError,
+ beforeTerminalSend
}: UseMobileImageAttachmentArgs): MobileImageAttachment {
const [isAttaching, setIsAttaching] = useState(false)
const attachImage = useCallback(
@@ -61,7 +63,8 @@ export function useMobileImageAttachment({
deviceToken: deviceTokenRef.current,
getConnectionId: getActiveWorktreeConnectionId,
pickImage: pickMobileImage,
- onUploadStart: () => setIsAttaching(true)
+ onUploadStart: () => setIsAttaching(true),
+ beforeTerminalSend
})
// Cancelled picker: no error, no toast.
if (sent) {
@@ -88,6 +91,7 @@ export function useMobileImageAttachment({
},
[
activeHandle,
+ beforeTerminalSend,
canSend,
client,
connState,
diff --git a/mobile/src/session/use-mobile-terminal-paste.ts b/mobile/src/session/use-mobile-terminal-paste.ts
new file mode 100644
index 00000000000..e3148be67f0
--- /dev/null
+++ b/mobile/src/session/use-mobile-terminal-paste.ts
@@ -0,0 +1,200 @@
+import { useCallback, type RefObject } from 'react'
+import * as Clipboard from 'expo-clipboard'
+import { File as FsFile, Paths } from 'expo-file-system'
+import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'
+import type { TerminalModes } from '../terminal/TerminalWebView'
+import type { RpcClient } from '../transport/rpc-client'
+import type { ConnectionState } from '../transport/types'
+import {
+ buildMobileImagePastePayload,
+ prepareMobileClipboardImageBase64,
+ saveMobileClipboardImageAsTempFile,
+ type MobileClipboardImageResizer
+} from './mobile-clipboard-image'
+
+const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i
+
+// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and
+// photos can exceed the upload byte budget; resize the raster down to fit before upload.
+// The iOS ImageManipulator loader cannot decode large base64 data URIs, so use a file.
+const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => {
+ const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '')
+ const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`)
+ let context: ReturnType | null = null
+ let rendered: Awaited<
+ ReturnType['renderAsync']>
+ > | null = null
+ let resultUri: string | null = null
+ try {
+ file.create({ overwrite: true })
+ file.write(base64, { encoding: 'base64' })
+ context = ImageManipulator.manipulate(file.uri)
+ context.resize({ width: target.width, height: target.height })
+ rendered = await context.renderAsync()
+ const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true })
+ resultUri = result.uri
+ // Why: empty base64 would pass the downstream base64 check and upload a corrupt
+ // image, so fail loudly here instead of silently sending an invalid payload.
+ if (!result.base64) {
+ throw new Error('Failed to encode resized clipboard image')
+ }
+ return { data: result.base64, width: result.width, height: result.height }
+ } finally {
+ rendered?.release()
+ context?.release()
+ if (resultUri) {
+ try {
+ new FsFile(resultUri).delete()
+ } catch {
+ // Best-effort cleanup; ImageManipulator saves into cache for every retry.
+ }
+ }
+ try {
+ file.delete()
+ } catch {
+ // Best-effort cleanup; the OS reclaims the cache directory regardless.
+ }
+ }
+}
+
+function buildMobileTerminalClipboardTextPayload(
+ text: string,
+ modes: TerminalModes | undefined
+): string {
+ const wrap = modes?.bracketedPasteMode === true && !modes.altScreen
+ // Why: strip embedded bracketed-paste markers so copied text cannot terminate
+ // paste mode early and turn trailing bytes into shell commands.
+ // eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping
+ const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text
+ return wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized
+}
+
+type UseMobileTerminalPasteOptions = {
+ readonly activeHandle: string | null
+ readonly activeHandleRef: RefObject
+ readonly activeSessionTabTypeRef: RefObject
+ readonly canSend: boolean
+ readonly client: RpcClient | null
+ readonly clientRef: RefObject
+ readonly connState: ConnectionState
+ readonly connStateRef: RefObject
+ readonly deviceTokenRef: RefObject
+ readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise
+ readonly getActiveWorktreeConnectionId: () => Promise
+ readonly onError: () => void
+ readonly onSuccess: () => void
+ readonly ptyModesRef: RefObject>
+ readonly refreshCanPaste: () => void
+ readonly showToast: (message: string, durationMs?: number) => void
+}
+
+export function useMobileTerminalPaste({
+ activeHandle,
+ activeHandleRef,
+ activeSessionTabTypeRef,
+ canSend,
+ client,
+ clientRef,
+ connState,
+ connStateRef,
+ deviceTokenRef,
+ flushPendingLiveInputBeforeExternalSend,
+ getActiveWorktreeConnectionId,
+ onError,
+ onSuccess,
+ ptyModesRef,
+ refreshCanPaste,
+ showToast
+}: UseMobileTerminalPasteOptions): () => Promise {
+ return useCallback(async () => {
+ if (!client || !activeHandle || !canSend) {
+ return
+ }
+ const targetHandle = activeHandle
+ try {
+ const text = await Clipboard.getStringAsync()
+ let payload: string | null = null
+ if (text.length > 0) {
+ payload = buildMobileTerminalClipboardTextPayload(
+ text,
+ ptyModesRef.current.get(targetHandle)
+ )
+ } else {
+ const image = await Clipboard.getImageAsync({ format: 'png' })
+ if (!image) {
+ refreshCanPaste()
+ return
+ }
+ const connectionId = await getActiveWorktreeConnectionId()
+ const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage)
+ const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, {
+ connectionId
+ })
+ payload = buildMobileImagePastePayload(imagePath)
+ }
+
+ const wrappedBytes = new TextEncoder().encode(payload).byteLength
+ if (wrappedBytes > 256 * 1024) {
+ onError()
+ // eslint-disable-next-line no-console
+ console.warn('[mobile-clip] paste oversized', { wrappedBytes })
+ showToast('Paste too large (max 256 KiB)', 1500)
+ return
+ }
+ // Why: paste lives in the accessory row and must not overtake pending IME text.
+ const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle)
+ if (!flushedPendingInput) {
+ return
+ }
+ const currentClient = clientRef.current
+ if (
+ !currentClient ||
+ connStateRef.current !== 'connected' ||
+ targetHandle !== activeHandleRef.current ||
+ activeSessionTabTypeRef.current !== 'terminal'
+ ) {
+ return
+ }
+ await currentClient.sendRequest('terminal.send', {
+ terminal: targetHandle,
+ text: payload,
+ enter: false,
+ ...(deviceTokenRef.current
+ ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
+ : {})
+ })
+ onSuccess()
+ refreshCanPaste()
+ } catch (e) {
+ onError()
+ const err = e as { name?: string; message?: string }
+ const isDisconnected = connState !== 'connected'
+ // eslint-disable-next-line no-console
+ console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
+ if (isDisconnected) {
+ showToast('Paste failed (disconnected)', 1500)
+ } else if (err.message === 'Clipboard image is too large') {
+ showToast('Image too large to paste', 1500)
+ } else {
+ showToast('Paste failed', 1500)
+ }
+ }
+ }, [
+ activeHandle,
+ activeHandleRef,
+ activeSessionTabTypeRef,
+ canSend,
+ client,
+ clientRef,
+ connState,
+ connStateRef,
+ deviceTokenRef,
+ flushPendingLiveInputBeforeExternalSend,
+ getActiveWorktreeConnectionId,
+ onError,
+ onSuccess,
+ ptyModesRef,
+ refreshCanPaste,
+ showToast
+ ])
+}
diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.test.ts b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts
new file mode 100644
index 00000000000..d95fab23698
--- /dev/null
+++ b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts
@@ -0,0 +1,138 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ readDisabledTerminalLiveInputHandlesPreference,
+ saveDisabledTerminalLiveInputHandles,
+ type DisabledTerminalLiveInputHandlesPreference
+} from '../storage/preferences'
+import { useTerminalLiveInputModePreference } from './use-terminal-live-input-mode-preference'
+
+vi.mock('../storage/preferences', () => ({
+ readDisabledTerminalLiveInputHandlesPreference: vi.fn(),
+ saveDisabledTerminalLiveInputHandles: vi.fn()
+}))
+
+type TerminalLiveInputModePreferenceHarness = {
+ readonly current: ReturnType
+ readonly unmount: () => void
+}
+
+type Deferred = {
+ readonly promise: Promise
+ readonly resolve: (value: T) => void
+}
+
+function createDeferred(): Deferred {
+ let resolve: ((value: T) => void) | null = null
+ const promise = new Promise((innerResolve) => {
+ resolve = innerResolve
+ })
+ if (!resolve) {
+ throw new Error('deferred resolver was not initialized')
+ }
+ return { promise, resolve }
+}
+
+function suppressReactTestRendererDeprecationWarning(): () => void {
+ const originalConsoleError = console.error
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
+ const firstArg = args[0]
+ if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
+ return
+ }
+ originalConsoleError(...args)
+ })
+ return () => consoleErrorSpy.mockRestore()
+}
+
+function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness {
+ let current: ReturnType | null = null
+ let renderer: ReactTestRenderer | null = null
+
+ function Harness(): null {
+ current = useTerminalLiveInputModePreference({
+ hostId: 'host-1',
+ worktreeId: 'worktree-1'
+ })
+ return null
+ }
+
+ const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
+ try {
+ act(() => {
+ renderer = create(createElement(Harness))
+ })
+ } finally {
+ restoreConsoleError()
+ }
+ if (!current || !renderer) {
+ throw new Error('terminal live input mode preference hook did not render')
+ }
+
+ return {
+ get current() {
+ if (!current) {
+ throw new Error('terminal live input mode preference hook is not mounted')
+ }
+ return current
+ },
+ unmount: () => {
+ act(() => renderer?.unmount())
+ }
+ }
+}
+
+describe('terminal live input mode preference hook', () => {
+ beforeEach(() => {
+ vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReset()
+ vi.mocked(saveDisabledTerminalLiveInputHandles).mockReset()
+ vi.mocked(saveDisabledTerminalLiveInputHandles).mockResolvedValue()
+ })
+
+ it('merges pre-hydration edits with loaded disabled handles', async () => {
+ const load = createDeferred()
+ vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise)
+ const harness = createTerminalLiveInputModePreferenceHarness()
+
+ act(() => {
+ harness.current.defaultTerminalHandlesToLiveInput(['pty-1', 'pty-2'])
+ })
+ act(() => {
+ expect(harness.current.toggleTerminalLiveInput('pty-1')).toBe(true)
+ })
+
+ await act(async () => {
+ load.resolve({ handles: new Set(['pty-2']), loaded: true })
+ await load.promise
+ })
+
+ expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1'])
+ expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledTimes(1)
+ expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledWith(
+ 'host-1',
+ 'worktree-1',
+ new Set(['pty-2'])
+ )
+ harness.unmount()
+ })
+
+ it('does not persist fallback-empty storage reads during clean hydration', async () => {
+ const load = createDeferred()
+ vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise)
+ const harness = createTerminalLiveInputModePreferenceHarness()
+
+ act(() => {
+ harness.current.defaultTerminalHandlesToLiveInput(['pty-1'])
+ })
+
+ await act(async () => {
+ load.resolve({ handles: new Set(), loaded: false })
+ await load.promise
+ })
+
+ expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1'])
+ expect(saveDisabledTerminalLiveInputHandles).not.toHaveBeenCalled()
+ harness.unmount()
+ })
+})
diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.ts b/mobile/src/session/use-terminal-live-input-mode-preference.ts
new file mode 100644
index 00000000000..dd59449b816
--- /dev/null
+++ b/mobile/src/session/use-terminal-live-input-mode-preference.ts
@@ -0,0 +1,212 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import {
+ readDisabledTerminalLiveInputHandlesPreference,
+ saveDisabledTerminalLiveInputHandles
+} from '../storage/preferences'
+import {
+ applyDisabledTerminalLiveInputHandles,
+ defaultTerminalLiveInputHandles,
+ filterTerminalLiveInputDefaultCandidates,
+ pruneTerminalLiveInputHandles
+} from '../terminal/terminal-live-input'
+
+type UseTerminalLiveInputModePreferenceOptions = {
+ readonly hostId: string
+ readonly worktreeId: string
+}
+
+export function useTerminalLiveInputModePreference({
+ hostId,
+ worktreeId
+}: UseTerminalLiveInputModePreferenceOptions) {
+ const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>(
+ () => new Set()
+ )
+ const liveInputTerminalHandlesRef = useRef>(new Set())
+ const defaultedLiveInputTerminalHandlesRef = useRef>(new Set())
+ const disabledLiveInputTerminalHandlesRef = useRef>(new Set())
+ const disabledLiveInputHydratedRef = useRef(false)
+ const pendingDisabledLiveInputHydrationEditsRef = useRef>(new Map())
+ const pendingLiveInputDefaultHandlesRef = useRef>(new Set())
+
+ const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => {
+ // Why: terminal discovery (tab snapshots, list poll, create) can arrive
+ // before the async persisted-disabled load on worktree re-entry.
+ if (!disabledLiveInputHydratedRef.current) {
+ for (const handle of handles) {
+ pendingLiveInputDefaultHandlesRef.current.add(handle)
+ }
+ return
+ }
+ const defaultableHandles = filterTerminalLiveInputDefaultCandidates(
+ handles,
+ disabledLiveInputTerminalHandlesRef.current
+ )
+ const result = defaultTerminalLiveInputHandles(
+ liveInputTerminalHandlesRef.current,
+ defaultedLiveInputTerminalHandlesRef.current,
+ defaultableHandles
+ )
+ if (!result.changed) {
+ return
+ }
+ const nextEnabledHandles = new Set(result.enabledHandles)
+ const nextDefaultedHandles = new Set(result.defaultedHandles)
+ liveInputTerminalHandlesRef.current = nextEnabledHandles
+ defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
+ setLiveInputTerminalHandles(nextEnabledHandles)
+ }, [])
+
+ const persistDisabledLiveInputHandles = useCallback(() => {
+ void saveDisabledTerminalLiveInputHandles(
+ hostId,
+ worktreeId,
+ disabledLiveInputTerminalHandlesRef.current
+ ).catch(() => {})
+ }, [hostId, worktreeId])
+
+ const pruneTerminalHandlesFromLiveInput = useCallback(
+ (liveHandles: ReadonlySet) => {
+ const result = pruneTerminalLiveInputHandles(
+ liveInputTerminalHandlesRef.current,
+ defaultedLiveInputTerminalHandlesRef.current,
+ liveHandles
+ )
+ let prunedDisabledHandles = false
+ for (const handle of disabledLiveInputTerminalHandlesRef.current) {
+ if (liveHandles.has(handle)) {
+ continue
+ }
+ disabledLiveInputTerminalHandlesRef.current.delete(handle)
+ if (!disabledLiveInputHydratedRef.current) {
+ pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false)
+ }
+ prunedDisabledHandles = true
+ }
+ if (prunedDisabledHandles && disabledLiveInputHydratedRef.current) {
+ persistDisabledLiveInputHandles()
+ }
+ if (!result.changed) {
+ return
+ }
+ const nextEnabledHandles = new Set(result.enabledHandles)
+ const nextDefaultedHandles = new Set(result.defaultedHandles)
+ liveInputTerminalHandlesRef.current = nextEnabledHandles
+ defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
+ setLiveInputTerminalHandles(nextEnabledHandles)
+ },
+ [persistDisabledLiveInputHandles]
+ )
+
+ const clearTerminalLiveInputDefault = useCallback(
+ (handle: string) => {
+ const liveHandles = new Set([
+ ...liveInputTerminalHandlesRef.current,
+ ...defaultedLiveInputTerminalHandlesRef.current
+ ])
+ liveHandles.delete(handle)
+ if (!disabledLiveInputHydratedRef.current) {
+ pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false)
+ }
+ if (disabledLiveInputTerminalHandlesRef.current.delete(handle)) {
+ if (disabledLiveInputHydratedRef.current) {
+ persistDisabledLiveInputHandles()
+ }
+ }
+ pruneTerminalHandlesFromLiveInput(liveHandles)
+ },
+ [persistDisabledLiveInputHandles, pruneTerminalHandlesFromLiveInput]
+ )
+
+ const toggleTerminalLiveInput = useCallback(
+ (handle: string): boolean => {
+ const nextEnabled = !liveInputTerminalHandlesRef.current.has(handle)
+ if (nextEnabled) {
+ disabledLiveInputTerminalHandlesRef.current.delete(handle)
+ } else {
+ disabledLiveInputTerminalHandlesRef.current.add(handle)
+ }
+ // Why: pre-hydration edits must patch the loaded set per handle; replacing
+ // the loaded set would erase other persisted opt-outs for this worktree.
+ if (!disabledLiveInputHydratedRef.current) {
+ pendingDisabledLiveInputHydrationEditsRef.current.set(handle, !nextEnabled)
+ }
+ // Why: only persist after hydration; an earlier write would use the
+ // reset-empty ref and overwrite other handles' opt-outs for the worktree.
+ if (disabledLiveInputHydratedRef.current) {
+ persistDisabledLiveInputHandles()
+ }
+ setLiveInputTerminalHandles((prev) => {
+ const next = new Set(prev)
+ if (nextEnabled) {
+ next.add(handle)
+ } else {
+ next.delete(handle)
+ }
+ liveInputTerminalHandlesRef.current = next
+ return next
+ })
+ return nextEnabled
+ },
+ [persistDisabledLiveInputHandles]
+ )
+
+ useEffect(() => {
+ liveInputTerminalHandlesRef.current = new Set()
+ defaultedLiveInputTerminalHandlesRef.current = new Set()
+ disabledLiveInputTerminalHandlesRef.current = new Set()
+ disabledLiveInputHydratedRef.current = false
+ pendingDisabledLiveInputHydrationEditsRef.current = new Map()
+ pendingLiveInputDefaultHandlesRef.current = new Set()
+ setLiveInputTerminalHandles(new Set())
+
+ let disposed = false
+ // Why: load the persisted opt-outs first so defaulting logic (which can
+ // fire immediately from subscriptions) respects prior user choices.
+ void readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId).then((preference) => {
+ if (disposed) {
+ return
+ }
+ const pendingEdits = pendingDisabledLiveInputHydrationEditsRef.current
+ const hydratedDisabledHandles = new Set(preference.handles)
+ for (const [handle, disabled] of pendingEdits) {
+ if (disabled) {
+ hydratedDisabledHandles.add(handle)
+ } else {
+ hydratedDisabledHandles.delete(handle)
+ }
+ }
+ disabledLiveInputTerminalHandlesRef.current = hydratedDisabledHandles
+ disabledLiveInputHydratedRef.current = true
+ pendingDisabledLiveInputHydrationEditsRef.current = new Map()
+ if (preference.loaded && pendingEdits.size > 0) {
+ persistDisabledLiveInputHandles()
+ }
+ const result = applyDisabledTerminalLiveInputHandles(
+ liveInputTerminalHandlesRef.current,
+ defaultedLiveInputTerminalHandlesRef.current,
+ hydratedDisabledHandles
+ )
+ const nextEnabledHandles = new Set(result.enabledHandles)
+ const nextDefaultedHandles = new Set(result.defaultedHandles)
+ liveInputTerminalHandlesRef.current = nextEnabledHandles
+ defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
+ setLiveInputTerminalHandles(nextEnabledHandles)
+ const pendingDefaultHandles = [...pendingLiveInputDefaultHandlesRef.current]
+ pendingLiveInputDefaultHandlesRef.current.clear()
+ defaultTerminalHandlesToLiveInput(pendingDefaultHandles)
+ })
+ return () => {
+ disposed = true
+ }
+ }, [defaultTerminalHandlesToLiveInput, hostId, persistDisabledLiveInputHandles, worktreeId])
+
+ return {
+ clearTerminalLiveInputDefault,
+ defaultTerminalHandlesToLiveInput,
+ liveInputTerminalHandles,
+ liveInputTerminalHandlesRef,
+ pruneTerminalHandlesFromLiveInput,
+ toggleTerminalLiveInput
+ }
+}
diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts
index c0f8596900a..1e5aee95de1 100644
--- a/mobile/src/storage/preferences.test.ts
+++ b/mobile/src/storage/preferences.test.ts
@@ -8,9 +8,12 @@ import {
HOST_SIDEBAR_MIN_WIDTH,
clampHostDockWidth,
clampHostSidebarWidth,
+ loadDisabledTerminalLiveInputHandles,
loadHostSidebarWidth,
loadTerminalAutocompleteEnabled,
loadTerminalLinkOpenMode,
+ readDisabledTerminalLiveInputHandlesPreference,
+ saveDisabledTerminalLiveInputHandles,
saveHostSidebarWidth,
saveTerminalAutocompleteEnabled,
saveTerminalLinkOpenMode
@@ -63,6 +66,62 @@ describe('terminal autocomplete preference', () => {
})
})
+describe('terminal live input disabled handles preference', () => {
+ beforeEach(() => {
+ vi.mocked(AsyncStorage.getItem).mockReset()
+ vi.mocked(AsyncStorage.setItem).mockReset()
+ })
+
+ it('defaults to no disabled handles when unset', async () => {
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
+
+ await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
+ new Set()
+ )
+ await expect(
+ readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1')
+ ).resolves.toEqual({ handles: new Set(), loaded: true })
+ })
+
+ it('loads only string terminal handles from storage', async () => {
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['pty-1', 42, 'pty-2']))
+
+ await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
+ new Set(['pty-1', 'pty-2'])
+ )
+ })
+
+ it('falls back to no disabled handles for invalid or unreadable storage', async () => {
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json')
+
+ await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
+ new Set()
+ )
+
+ vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
+
+ await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
+ new Set()
+ )
+ await expect(
+ readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1')
+ ).resolves.toEqual({ handles: new Set(), loaded: false })
+ })
+
+ it('persists disabled handles per host and worktree', async () => {
+ await saveDisabledTerminalLiveInputHandles(
+ 'host/one',
+ 'folder:C:\\repo',
+ new Set(['pty-2', 'pty-1'])
+ )
+
+ expect(AsyncStorage.setItem).toHaveBeenCalledWith(
+ 'orca:terminalLiveInputDisabled:host%2Fone:folder%3AC%3A%5Crepo',
+ JSON.stringify(['pty-2', 'pty-1'])
+ )
+ })
+})
+
describe('host sidebar width preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts
index 5151397a7f0..d5b0db3a6e7 100644
--- a/mobile/src/storage/preferences.ts
+++ b/mobile/src/storage/preferences.ts
@@ -73,6 +73,53 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise
await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled))
}
+const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:'
+
+export type DisabledTerminalLiveInputHandlesPreference = {
+ readonly handles: Set
+ readonly loaded: boolean
+}
+
+function terminalLiveInputDisabledKey(hostId: string, worktreeId: string): string {
+ return `${TERMINAL_LIVE_INPUT_DISABLED_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(
+ worktreeId
+ )}`
+}
+
+export async function readDisabledTerminalLiveInputHandlesPreference(
+ hostId: string,
+ worktreeId: string
+): Promise {
+ try {
+ const raw = await AsyncStorage.getItem(terminalLiveInputDisabledKey(hostId, worktreeId))
+ if (!raw) {
+ return { handles: new Set(), loaded: true }
+ }
+ return { handles: new Set(stringArray(JSON.parse(raw))), loaded: true }
+ } catch {
+ return { handles: new Set(), loaded: false }
+ }
+}
+
+export async function loadDisabledTerminalLiveInputHandles(
+ hostId: string,
+ worktreeId: string
+): Promise> {
+ const preference = await readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId)
+ return preference.handles
+}
+
+export async function saveDisabledTerminalLiveInputHandles(
+ hostId: string,
+ worktreeId: string,
+ handles: ReadonlySet
+): Promise {
+ await AsyncStorage.setItem(
+ terminalLiveInputDisabledKey(hostId, worktreeId),
+ JSON.stringify([...handles])
+ )
+}
+
const SIDEBAR_WIDTH_KEY = 'orca:hostSidebarWidth'
// Bounds for the draggable host worktree-list sidebar on tablet/foldable
diff --git a/mobile/src/terminal/terminal-file-url-tap.ts b/mobile/src/terminal/terminal-file-url-tap.ts
new file mode 100644
index 00000000000..b27260b61cb
--- /dev/null
+++ b/mobile/src/terminal/terminal-file-url-tap.ts
@@ -0,0 +1,95 @@
+import type { TappedFilePath } from './terminal-path-tap'
+import { parsePathWithOptionalLineColumn } from './terminal-path-tap'
+
+export function resolveTerminalFileUrlTap(uri: string): TappedFilePath | null {
+ let parsed: URL
+ try {
+ parsed = new URL(uri)
+ } catch {
+ return null
+ }
+ const filePath = terminalFileUriToPathText(parsed)
+ if (!filePath) {
+ return null
+ }
+ const hashTarget = parseFileUrlLineHash(parsed.hash)
+ if (hashTarget) {
+ return { pathText: filePath, line: hashTarget.line, column: hashTarget.column }
+ }
+ if (/%3a/i.test(parsed.pathname)) {
+ return { pathText: filePath, line: null, column: null }
+ }
+ return (
+ parseFilePathTrailingLineTarget(filePath) ?? { pathText: filePath, line: null, column: null }
+ )
+}
+
+export function resolveTerminalOscFileTap(uri: string): TappedFilePath | null {
+ return resolveTerminalFileUrlTap(uri) ?? parseOscPathLikeTarget(uri)
+}
+
+function terminalFileUriToPathText(url: URL): string | null {
+ if (url.protocol !== 'file:') {
+ return null
+ }
+ let decodedPath: string
+ try {
+ decodedPath = decodeURIComponent(url.pathname)
+ } catch {
+ return null
+ }
+ if (url.hostname && !isLocalFileUriHostname(url.hostname)) {
+ return `//${url.hostname}${decodedPath}`
+ }
+ if (/^\/[A-Za-z]:\//.test(decodedPath)) {
+ return decodedPath.slice(1)
+ }
+ return decodedPath || null
+}
+
+function isLocalFileUriHostname(hostname: string): boolean {
+ const normalized = hostname.toLowerCase()
+ return (
+ normalized === 'localhost' ||
+ normalized === '127.0.0.1' ||
+ normalized === '::1' ||
+ normalized === '[::1]'
+ )
+}
+
+function parseOscPathLikeTarget(value: string): TappedFilePath | null {
+ if (
+ !/^(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))/.test(
+ value
+ )
+ ) {
+ return null
+ }
+ return parsePathWithOptionalLineColumn(value)
+}
+
+function parseFileUrlLineHash(hash: string): { line: number; column: number | null } | null {
+ const match = /^#?L(\d+)(?:C(\d+))?$/i.exec(hash)
+ if (!match) {
+ return null
+ }
+ const line = Number.parseInt(match[1]!, 10)
+ const column = match[2] ? Number.parseInt(match[2], 10) : null
+ if (line < 1 || (column !== null && column < 1)) {
+ return null
+ }
+ return { line, column }
+}
+
+function parseFilePathTrailingLineTarget(filePath: string): TappedFilePath | null {
+ const match = /^(.*?)(?::(\d+))(?::(\d+))?$/.exec(filePath)
+ if (!match || !match[1] || match[1].endsWith('/') || match[1].endsWith('\\')) {
+ return null
+ }
+ const line = Number.parseInt(match[2]!, 10)
+ const column = match[3] ? Number.parseInt(match[3], 10) : null
+ if (line < 1 || (column !== null && column < 1)) {
+ return null
+ }
+ return { pathText: match[1], line, column }
+}
diff --git a/mobile/src/terminal/terminal-live-accessory-input.ts b/mobile/src/terminal/terminal-live-accessory-input.ts
new file mode 100644
index 00000000000..5f483ebc0cf
--- /dev/null
+++ b/mobile/src/terminal/terminal-live-accessory-input.ts
@@ -0,0 +1,21 @@
+import type { TerminalLiveAccessoryLocalEdit } from './terminal-live-text-commit'
+
+export type TerminalLiveAccessoryInput = {
+ readonly bytes: string
+ readonly localEdit?: TerminalLiveAccessoryLocalEdit
+}
+
+type TerminalLiveAccessoryKey = {
+ readonly bytes: string
+ readonly id: string
+}
+
+export function createTerminalLiveAccessoryInput(
+ key: TerminalLiveAccessoryKey
+): TerminalLiveAccessoryInput {
+ if (key.id === 'backspace' || key.id === 'delete') {
+ return { bytes: key.bytes, localEdit: key.id }
+ }
+
+ return { bytes: key.bytes }
+}
diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts b/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts
new file mode 100644
index 00000000000..5e484c354bc
--- /dev/null
+++ b/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest'
+import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target'
+
+describe('terminal live accessory raw send target', () => {
+ it('Given the original terminal is still active When raw fallback resumes Then returns that handle', () => {
+ // Given
+ const targetHandle = 'terminal-a'
+
+ // When
+ const sendTarget = getTerminalLiveAccessoryRawSendTarget({
+ targetHandle,
+ activeHandle: targetHandle,
+ activeSessionTabType: 'terminal'
+ })
+
+ // Then
+ expect(sendTarget).toBe(targetHandle)
+ })
+
+ it('Given the active terminal changed while waiting When raw fallback resumes Then suppresses the send', () => {
+ // Given
+ const targetHandle = 'terminal-a'
+
+ // When
+ const sendTarget = getTerminalLiveAccessoryRawSendTarget({
+ targetHandle,
+ activeHandle: 'terminal-b',
+ activeSessionTabType: 'terminal'
+ })
+
+ // Then
+ expect(sendTarget).toBeNull()
+ })
+
+ it('Given the target is not an active terminal tab When raw fallback resumes Then suppresses the send', () => {
+ // Given
+ const targetHandle = 'terminal-a'
+
+ // When
+ const inactiveTabTarget = getTerminalLiveAccessoryRawSendTarget({
+ targetHandle,
+ activeHandle: targetHandle,
+ activeSessionTabType: 'browser'
+ })
+
+ // Then
+ expect(inactiveTabTarget).toBeNull()
+ })
+})
diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts b/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts
new file mode 100644
index 00000000000..f87138d210e
--- /dev/null
+++ b/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts
@@ -0,0 +1,17 @@
+type TerminalLiveAccessoryRawSendTargetInput = {
+ readonly targetHandle: string
+ readonly activeHandle: string | null
+ readonly activeSessionTabType: TTabType | null
+}
+
+export function getTerminalLiveAccessoryRawSendTarget({
+ targetHandle,
+ activeHandle,
+ activeSessionTabType
+}: TerminalLiveAccessoryRawSendTargetInput): string | null {
+ if (targetHandle !== activeHandle || activeSessionTabType !== 'terminal') {
+ return null
+ }
+
+ return targetHandle
+}
diff --git a/mobile/src/terminal/terminal-live-control-send-order.test.ts b/mobile/src/terminal/terminal-live-control-send-order.test.ts
new file mode 100644
index 00000000000..8f1797c7ca0
--- /dev/null
+++ b/mobile/src/terminal/terminal-live-control-send-order.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it, vi } from 'vitest'
+import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
+
+describe('terminal live control send order', () => {
+ it('Given a failed pending text flush When control bytes follow Then skips the control bytes', async () => {
+ // Given
+ const events: string[] = []
+ const flushPendingText = vi.fn(async () => {
+ events.push('flush')
+ return false
+ })
+ const sendControlBytes = vi.fn(async () => {
+ events.push('control')
+ return true
+ })
+
+ // When
+ const result = await sendTerminalLiveControlAfterPendingFlush(
+ flushPendingText,
+ sendControlBytes
+ )
+
+ // Then
+ expect(result).toBe(false)
+ expect(sendControlBytes).not.toHaveBeenCalled()
+ expect(events).toEqual(['flush'])
+ })
+
+ it('Given a successful pending text flush When control bytes follow Then sends them afterward', async () => {
+ // Given
+ const events: string[] = []
+ const flushPendingText = vi.fn(async () => {
+ events.push('flush')
+ return true
+ })
+ const sendControlBytes = vi.fn(async () => {
+ events.push('control')
+ return true
+ })
+
+ // When
+ const result = await sendTerminalLiveControlAfterPendingFlush(
+ flushPendingText,
+ sendControlBytes
+ )
+
+ // Then
+ expect(result).toBe(true)
+ expect(events).toEqual(['flush', 'control'])
+ })
+
+ it('Given a failed control byte send When pending text flushed Then reports failure', async () => {
+ // Given
+ const events: string[] = []
+ const flushPendingText = vi.fn(async () => {
+ events.push('flush')
+ return true
+ })
+ const sendControlBytes = vi.fn(async () => {
+ events.push('control')
+ return false
+ })
+
+ // When
+ const result = await sendTerminalLiveControlAfterPendingFlush(
+ flushPendingText,
+ sendControlBytes
+ )
+
+ // Then
+ expect(result).toBe(false)
+ expect(events).toEqual(['flush', 'control'])
+ })
+})
diff --git a/mobile/src/terminal/terminal-live-control-send-order.ts b/mobile/src/terminal/terminal-live-control-send-order.ts
new file mode 100644
index 00000000000..185b49ee48e
--- /dev/null
+++ b/mobile/src/terminal/terminal-live-control-send-order.ts
@@ -0,0 +1,12 @@
+export type TerminalLiveAsyncSendStep = () => Promise
+
+export async function sendTerminalLiveControlAfterPendingFlush(
+ flushPendingText: TerminalLiveAsyncSendStep,
+ sendControlBytes: TerminalLiveAsyncSendStep
+): Promise {
+ const flushed = await flushPendingText()
+ if (!flushed) {
+ return false
+ }
+ return sendControlBytes()
+}
diff --git a/mobile/src/terminal/terminal-live-input-sender.ts b/mobile/src/terminal/terminal-live-input-sender.ts
new file mode 100644
index 00000000000..c3538d1f723
--- /dev/null
+++ b/mobile/src/terminal/terminal-live-input-sender.ts
@@ -0,0 +1 @@
+export type TerminalLiveInputSender = (handle: string, bytes: string) => Promise
diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts
index db3e96236bc..95b4960f928 100644
--- a/mobile/src/terminal/terminal-live-input.test.ts
+++ b/mobile/src/terminal/terminal-live-input.test.ts
@@ -1,8 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
TERMINAL_LIVE_INPUT_MAX_BYTES,
+ applyDisabledTerminalLiveInputHandles,
clearTerminalLiveInputFocusTimer,
defaultTerminalLiveInputHandles,
+ filterTerminalLiveInputDefaultCandidates,
getTerminalLiveSpecialKeyBytes,
isTerminalLiveInputWithinByteLimit,
pruneTerminalLiveInputHandles,
@@ -101,6 +103,35 @@ describe('terminal live input', () => {
expect(result.defaultedHandles).toBe(defaulted)
})
+ it('does not default persisted buffered-mode handles back to live input on reentry', () => {
+ const defaultableHandles = filterTerminalLiveInputDefaultCandidates(
+ ['pty-1', 'pty-2'],
+ new Set(['pty-1'])
+ )
+
+ const result = defaultTerminalLiveInputHandles(
+ new Set(),
+ new Set(['pty-1']),
+ defaultableHandles
+ )
+
+ expect(defaultableHandles).toEqual(['pty-2'])
+ expect([...result.enabledHandles]).toEqual(['pty-2'])
+ expect([...result.defaultedHandles]).toEqual(['pty-1', 'pty-2'])
+ })
+
+ it('reconciles persisted buffered-mode handles with currently enabled live input', () => {
+ const result = applyDisabledTerminalLiveInputHandles(
+ new Set(['pty-1', 'pty-2']),
+ new Set(['pty-2']),
+ new Set(['pty-1'])
+ )
+
+ expect(result.changed).toBe(true)
+ expect([...result.enabledHandles]).toEqual(['pty-2'])
+ expect([...result.defaultedHandles]).toEqual(['pty-2', 'pty-1'])
+ })
+
it('prunes terminal handles that disappear from session snapshots', () => {
const result = pruneTerminalLiveInputHandles(
new Set(['pty-1', 'pty-stale']),
diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts
index dd5f8e6a709..96f279a4855 100644
--- a/mobile/src/terminal/terminal-live-input.ts
+++ b/mobile/src/terminal/terminal-live-input.ts
@@ -118,6 +118,48 @@ export function defaultTerminalLiveInputHandles(
}
}
+export function filterTerminalLiveInputDefaultCandidates(
+ terminalHandles: readonly string[],
+ disabledHandles: ReadonlySet
+): string[] {
+ return terminalHandles.filter((handle) => !disabledHandles.has(handle))
+}
+
+export function applyDisabledTerminalLiveInputHandles(
+ enabledHandles: ReadonlySet,
+ defaultedHandles: ReadonlySet,
+ disabledHandles: ReadonlySet
+): TerminalLiveInputDefaultResult {
+ let nextEnabledHandles: Set | null = null
+ let nextDefaultedHandles: Set | null = null
+
+ for (const handle of enabledHandles) {
+ if (!disabledHandles.has(handle)) {
+ continue
+ }
+ nextEnabledHandles ??= new Set(enabledHandles)
+ nextEnabledHandles.delete(handle)
+ }
+
+ for (const handle of disabledHandles) {
+ if (defaultedHandles.has(handle)) {
+ continue
+ }
+ nextDefaultedHandles ??= new Set(defaultedHandles)
+ nextDefaultedHandles.add(handle)
+ }
+
+ if (!nextEnabledHandles && !nextDefaultedHandles) {
+ return { enabledHandles, defaultedHandles, changed: false }
+ }
+
+ return {
+ enabledHandles: nextEnabledHandles ?? enabledHandles,
+ defaultedHandles: nextDefaultedHandles ?? defaultedHandles,
+ changed: true
+ }
+}
+
export function pruneTerminalLiveInputHandles(
enabledHandles: ReadonlySet,
defaultedHandles: ReadonlySet,
diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts
new file mode 100644
index 00000000000..27dd4147d09
--- /dev/null
+++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it } from 'vitest'
+import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
+import {
+ queueTerminalLivePendingFlush,
+ waitForTerminalLivePendingFlush,
+ type TerminalLivePendingFlushState
+} from './terminal-live-pending-flush-state'
+
+describe('terminal live pending flush state', () => {
+ it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => {
+ // Given
+ const state: TerminalLivePendingFlushState = { current: null }
+
+ // When / Then
+ await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true)
+ })
+
+ it('Given an in-flight flush When control input waits Then control is held until flush succeeds', async () => {
+ // Given
+ const events: string[] = []
+ let resolveFlush: (value: boolean) => void = () => {}
+ const flushPromise = new Promise((resolve) => {
+ resolveFlush = resolve
+ })
+ const state: TerminalLivePendingFlushState = { current: flushPromise }
+
+ // When
+ const controlSend = sendTerminalLiveControlAfterPendingFlush(
+ () => waitForTerminalLivePendingFlush(state),
+ async () => {
+ events.push('control')
+ return true
+ }
+ )
+ await Promise.resolve()
+
+ // Then
+ expect(events).toEqual([])
+ resolveFlush(true)
+ await expect(controlSend).resolves.toBe(true)
+ expect(events).toEqual(['control'])
+ })
+
+ it('Given an in-flight flush fails When control input waits Then control is skipped', async () => {
+ // Given
+ const events: string[] = []
+ let resolveFlush: (value: boolean) => void = () => {}
+ const flushPromise = new Promise((resolve) => {
+ resolveFlush = resolve
+ })
+ const state: TerminalLivePendingFlushState = { current: flushPromise }
+
+ // When
+ const controlSend = sendTerminalLiveControlAfterPendingFlush(
+ () => waitForTerminalLivePendingFlush(state),
+ async () => {
+ events.push('control')
+ return true
+ }
+ )
+ resolveFlush(false)
+
+ // Then
+ await expect(controlSend).resolves.toBe(false)
+ expect(events).toEqual([])
+ })
+
+ it('Given a queued pending flush When it resolves or rejects Then clears the barrier', async () => {
+ // Given
+ const resolvedState: TerminalLivePendingFlushState = { current: null }
+ const rejectedState: TerminalLivePendingFlushState = { current: null }
+
+ // When
+ await expect(queueTerminalLivePendingFlush(resolvedState, async () => true)).resolves.toBe(true)
+ await expect(
+ queueTerminalLivePendingFlush(rejectedState, async () => {
+ throw new Error('send failed')
+ })
+ ).resolves.toBe(false)
+ await Promise.resolve()
+
+ // Then
+ expect(resolvedState.current).toBeNull()
+ expect(rejectedState.current).toBeNull()
+ })
+
+ it('Given a current pending snapshot while another flush is in flight When queued Then sends current text after prior success', async () => {
+ // Given
+ const events: string[] = []
+ let resolveFirstFlush: (value: boolean) => void = () => {}
+ const firstFlush = new Promise((resolve) => {
+ resolveFirstFlush = resolve
+ })
+ const state: TerminalLivePendingFlushState = { current: firstFlush }
+
+ // When
+ const secondFlush = queueTerminalLivePendingFlush(state, async () => {
+ events.push('second-flush')
+ return true
+ })
+ const controlSend = sendTerminalLiveControlAfterPendingFlush(
+ () => waitForTerminalLivePendingFlush(state),
+ async () => {
+ events.push('control')
+ return true
+ }
+ )
+ await Promise.resolve()
+
+ // Then
+ expect(events).toEqual([])
+ resolveFirstFlush(true)
+ await expect(secondFlush).resolves.toBe(true)
+ await expect(controlSend).resolves.toBe(true)
+ expect(events).toEqual(['second-flush', 'control'])
+ })
+
+ it('Given a prior pending flush fails When another snapshot is queued Then skips the current text and control', async () => {
+ // Given
+ const events: string[] = []
+ let resolveFirstFlush: (value: boolean) => void = () => {}
+ const firstFlush = new Promise((resolve) => {
+ resolveFirstFlush = resolve
+ })
+ const state: TerminalLivePendingFlushState = { current: firstFlush }
+
+ // When
+ const secondFlush = queueTerminalLivePendingFlush(state, async () => {
+ events.push('second-flush')
+ return true
+ })
+ const controlSend = sendTerminalLiveControlAfterPendingFlush(
+ () => waitForTerminalLivePendingFlush(state),
+ async () => {
+ events.push('control')
+ return true
+ }
+ )
+ resolveFirstFlush(false)
+
+ // Then
+ await expect(secondFlush).resolves.toBe(false)
+ await expect(controlSend).resolves.toBe(false)
+ expect(events).toEqual([])
+ })
+})
diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts
new file mode 100644
index 00000000000..ea760fcee54
--- /dev/null
+++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts
@@ -0,0 +1,29 @@
+export type TerminalLivePendingFlushState = {
+ current: Promise | null
+}
+
+export function waitForTerminalLivePendingFlush(
+ state: TerminalLivePendingFlushState
+): Promise {
+ return state.current ?? Promise.resolve(true)
+}
+
+export function queueTerminalLivePendingFlush(
+ state: TerminalLivePendingFlushState,
+ sendPendingText: () => Promise
+): Promise {
+ const previousFlush = state.current
+ const flushPromise = (async () => {
+ if (previousFlush && !(await previousFlush)) {
+ return false
+ }
+ return sendPendingText()
+ })().catch(() => false)
+ state.current = flushPromise
+ void flushPromise.then(() => {
+ if (state.current === flushPromise) {
+ state.current = null
+ }
+ })
+ return flushPromise
+}
diff --git a/mobile/src/terminal/terminal-live-text-commit.test.ts b/mobile/src/terminal/terminal-live-text-commit.test.ts
new file mode 100644
index 00000000000..00435fb2230
--- /dev/null
+++ b/mobile/src/terminal/terminal-live-text-commit.test.ts
@@ -0,0 +1,234 @@
+import { describe, expect, it } from 'vitest'
+import {
+ TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS,
+ getTerminalLiveAccessoryBytesDecision,
+ getTerminalLiveAccessoryLocalEditText,
+ getTerminalLiveDeferredTextDelayMs,
+ getTerminalLiveSpecialKeyDecision,
+ getTerminalLiveSubmitSequence,
+ getTerminalLiveTextChangeDecision,
+ isTerminalLiveTextHangulCandidate,
+ isTerminalLiveTextImeCandidate
+} from './terminal-live-text-commit'
+
+describe('terminal live text commit', () => {
+ it('Given Korean IME changes When live text changes Then defers candidates and submits only final text before carriage return', () => {
+ // Given
+ const koreanCompositionSteps = ['ㅎ', '하', '한'] as const
+
+ // When
+ const decisions = koreanCompositionSteps.map(getTerminalLiveTextChangeDecision)
+ const sentImmediately = decisions.filter((decision) => decision.kind === 'send-now')
+ const submitSequence = getTerminalLiveSubmitSequence('한')
+ const composedWordDecision = getTerminalLiveTextChangeDecision('한글')
+ const composedWordSubmitSequence = getTerminalLiveSubmitSequence('한글')
+
+ // Then
+ expect(decisions).toEqual([
+ { kind: 'defer', text: 'ㅎ', delayMs: null },
+ { kind: 'defer', text: '하', delayMs: null },
+ { kind: 'defer', text: '한', delayMs: null }
+ ])
+ expect(sentImmediately).toEqual([])
+ expect(isTerminalLiveTextHangulCandidate('ㅎ')).toBe(true)
+ expect(getTerminalLiveDeferredTextDelayMs('ㅎ')).toBeNull()
+ expect(submitSequence).toEqual(['한', '\r'])
+ expect(composedWordDecision).toEqual({
+ kind: 'defer',
+ text: '한글',
+ delayMs: null
+ })
+ expect(composedWordSubmitSequence).toEqual(['한글', '\r'])
+ })
+
+ it('Given non-Hangul IME text When live text changes Then keeps the bounded settle timer', () => {
+ // Given
+ const text = 'あ'
+
+ // When
+ const decision = getTerminalLiveTextChangeDecision(text)
+
+ // Then
+ expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
+ expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
+ expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
+ expect(decision).toEqual({ kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS })
+ })
+
+ it('Given Chinese and Vietnamese IME text When live text changes Then does not use Hangul-only indefinite deferral', () => {
+ // Given
+ const nonHangulImeTexts = ['你好', 'tiếng Việt'] as const
+
+ for (const text of nonHangulImeTexts) {
+ // When
+ const decision = getTerminalLiveTextChangeDecision(text)
+
+ // Then
+ expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
+ expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
+ expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
+ expect(decision).toEqual({
+ kind: 'defer',
+ text,
+ delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
+ })
+ }
+ })
+
+ it('Given ASCII text When live text changes Then sends immediately', () => {
+ // Given
+ const text = 'abc123'
+
+ // When
+ const decision = getTerminalLiveTextChangeDecision(text)
+
+ // Then
+ expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
+ expect(decision).toEqual({ kind: 'send-now', text })
+ })
+
+ it('Given empty text When live text changes Then ignores the change', () => {
+ // Given
+ const text = ''
+
+ // When
+ const decision = getTerminalLiveTextChangeDecision(text)
+
+ // Then
+ expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
+ expect(decision).toEqual({ kind: 'ignore' })
+ })
+
+ it('Given pending text When Backspace or Delete is pressed Then keeps edits local', () => {
+ // Given
+ const pendingText = '한'
+
+ // When
+ const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
+ const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
+
+ // Then
+ expect(backspaceDecision).toEqual({ kind: 'local-edit' })
+ expect(deleteDecision).toEqual({ kind: 'local-edit' })
+ })
+
+ it('Given no pending text When Backspace or Delete is pressed Then sends terminal bytes', () => {
+ // Given
+ const pendingText = ''
+
+ // When
+ const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
+ const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
+
+ // Then
+ expect(backspaceDecision).toEqual({ kind: 'send-now', bytes: '\x7f' })
+ expect(deleteDecision).toEqual({ kind: 'send-now', bytes: '\x1b[3~' })
+ })
+
+ it('Given pending text When a terminal special key is pressed Then flushes pending text before bytes', () => {
+ // Given
+ const pendingText = '한'
+
+ // When
+ const decision = getTerminalLiveSpecialKeyDecision({ key: 'Tab', pendingText })
+
+ // Then
+ expect(decision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
+ })
+
+ it('Given pending text When accessory control bytes are requested Then flushes pending text before bytes', () => {
+ // Given
+ const pendingText = '한글'
+
+ // When
+ const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
+ const escapeDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\x1b', pendingText })
+ const enterDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\r', pendingText })
+
+ // Then
+ expect(tabDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
+ expect(escapeDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\x1b' })
+ expect(enterDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\r' })
+ })
+
+ it('Given pending text When accessory Backspace or Delete bytes are requested Then keeps edits local', () => {
+ // Given
+ const pendingText = '한글'
+
+ // When
+ const backspaceDecision = getTerminalLiveAccessoryBytesDecision({
+ bytes: '\x7f',
+ localEdit: 'backspace',
+ pendingText
+ })
+ const ctrlBackspaceDecision = getTerminalLiveAccessoryBytesDecision({
+ bytes: '\b',
+ localEdit: 'backspace',
+ pendingText
+ })
+ const deleteDecision = getTerminalLiveAccessoryBytesDecision({
+ bytes: '\x1b[3~',
+ localEdit: 'delete',
+ pendingText
+ })
+ const customDeleteByteDecision = getTerminalLiveAccessoryBytesDecision({
+ bytes: '\x7f',
+ pendingText
+ })
+ const backspaceText = getTerminalLiveAccessoryLocalEditText({
+ localEdit: 'backspace',
+ pendingText
+ })
+ const deleteText = getTerminalLiveAccessoryLocalEditText({
+ localEdit: 'delete',
+ pendingText
+ })
+
+ // Then
+ expect(backspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
+ expect(ctrlBackspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
+ expect(deleteDecision).toEqual({ kind: 'local-edit', localEdit: 'delete' })
+ expect(customDeleteByteDecision).toEqual({
+ kind: 'flush-then-send',
+ pendingText,
+ bytes: '\x7f'
+ })
+ expect(backspaceText).toBe('한')
+ expect(deleteText).toBe('한글')
+ expect(getTerminalLiveDeferredTextDelayMs(backspaceText)).toBeNull()
+ expect(getTerminalLiveDeferredTextDelayMs(deleteText)).toBeNull()
+ })
+
+ it('Given no pending text When accessory bytes are requested Then sends terminal bytes', () => {
+ // Given
+ const pendingText = ''
+
+ // When
+ const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
+
+ // Then
+ expect(tabDecision).toEqual({ kind: 'send-now', bytes: '\t' })
+ })
+
+ it('Given a non-special key When key decision is requested Then ignores it', () => {
+ // Given
+ const key = 'a'
+
+ // When
+ const decision = getTerminalLiveSpecialKeyDecision({ key, pendingText: '한' })
+
+ // Then
+ expect(decision).toEqual({ kind: 'ignore' })
+ })
+
+ it('Given no pending text When submit is requested Then sends only carriage return', () => {
+ // Given
+ const pendingText = ''
+
+ // When
+ const sequence = getTerminalLiveSubmitSequence(pendingText)
+
+ // Then
+ expect(sequence).toEqual(['\r'])
+ })
+})
diff --git a/mobile/src/terminal/terminal-live-text-commit.ts b/mobile/src/terminal/terminal-live-text-commit.ts
new file mode 100644
index 00000000000..aac74b8931b
--- /dev/null
+++ b/mobile/src/terminal/terminal-live-text-commit.ts
@@ -0,0 +1,145 @@
+import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input'
+
+// Why: React Native does not expose portable composition events here, so
+// non-Hangul IME text gets a short settle window before being sent to the PTY.
+export const TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS = 150
+
+export type TerminalLiveTextChangeDecision =
+ | { readonly kind: 'ignore' }
+ | { readonly kind: 'send-now'; readonly text: string }
+ | { readonly kind: 'defer'; readonly text: string; readonly delayMs: number | null }
+
+export type TerminalLiveSpecialKeyDecision =
+ | { readonly kind: 'ignore' }
+ | { readonly kind: 'local-edit' }
+ | { readonly kind: 'send-now'; readonly bytes: string }
+ | { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
+
+export type TerminalLiveSpecialKeyDecisionInput = {
+ readonly key: string
+ readonly pendingText: string
+}
+
+export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
+
+export type TerminalLiveAccessoryBytesDecision =
+ | { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit }
+ | { readonly kind: 'send-now'; readonly bytes: string }
+ | { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
+
+export type TerminalLiveAccessoryBytesDecisionInput = {
+ readonly bytes: string
+ readonly localEdit?: TerminalLiveAccessoryLocalEdit
+ readonly pendingText: string
+}
+
+export function isTerminalLiveTextImeCandidate(text: string): boolean {
+ for (const character of text) {
+ const codePoint = character.codePointAt(0)
+ if (codePoint !== undefined && codePoint > 0x7f) {
+ return true
+ }
+ }
+ return false
+}
+
+function isHangulCodePoint(codePoint: number): boolean {
+ return (
+ (codePoint >= 0x1100 && codePoint <= 0x11ff) ||
+ (codePoint >= 0x3130 && codePoint <= 0x318f) ||
+ (codePoint >= 0xa960 && codePoint <= 0xa97f) ||
+ (codePoint >= 0xac00 && codePoint <= 0xd7af)
+ )
+}
+
+export function isTerminalLiveTextHangulCandidate(text: string): boolean {
+ for (const character of text) {
+ const codePoint = character.codePointAt(0)
+ if (codePoint !== undefined && isHangulCodePoint(codePoint)) {
+ return true
+ }
+ }
+ return false
+}
+
+export function getTerminalLiveTextChangeDecision(text: string): TerminalLiveTextChangeDecision {
+ if (text.length === 0) {
+ return { kind: 'ignore' }
+ }
+
+ if (isTerminalLiveTextHangulCandidate(text)) {
+ return { kind: 'defer', text, delayMs: null }
+ }
+
+ if (isTerminalLiveTextImeCandidate(text)) {
+ return { kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS }
+ }
+
+ return { kind: 'send-now', text }
+}
+
+export function getTerminalLiveDeferredTextDelayMs(text: string): number | null {
+ return isTerminalLiveTextHangulCandidate(text) ? null : TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
+}
+
+export function getTerminalLiveSpecialKeyDecision({
+ key,
+ pendingText
+}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision {
+ const bytes = getTerminalLiveSpecialKeyBytes(key)
+ if (bytes === null) {
+ return { kind: 'ignore' }
+ }
+
+ if (pendingText.length > 0 && (key === 'Backspace' || key === 'Delete')) {
+ return { kind: 'local-edit' }
+ }
+
+ if (pendingText.length > 0) {
+ return { kind: 'flush-then-send', pendingText, bytes }
+ }
+
+ return { kind: 'send-now', bytes }
+}
+
+export function getTerminalLiveAccessoryBytesDecision({
+ bytes,
+ localEdit,
+ pendingText
+}: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision {
+ if (pendingText.length > 0 && localEdit) {
+ return { kind: 'local-edit', localEdit }
+ }
+
+ if (pendingText.length > 0) {
+ return { kind: 'flush-then-send', pendingText, bytes }
+ }
+
+ return { kind: 'send-now', bytes }
+}
+
+export function getTerminalLiveAccessoryLocalEditText({
+ localEdit,
+ pendingText
+}: {
+ readonly localEdit: TerminalLiveAccessoryLocalEdit
+ readonly pendingText: string
+}): string {
+ if (localEdit === 'delete') {
+ // Why: accessory Delete mirrors forward-delete at the hidden input's end;
+ // it stays local but does not remove the pending IME text.
+ return pendingText
+ }
+
+ return Array.from(pendingText).slice(0, -1).join('')
+}
+
+export type TerminalLiveSubmitSequence = readonly ['\r'] | readonly [string, '\r']
+
+export function getTerminalLiveSubmitSequence(pendingText: string): TerminalLiveSubmitSequence {
+ if (pendingText.length === 0) {
+ return ['\r']
+ }
+
+ return [pendingText, '\r']
+}
diff --git a/mobile/src/terminal/terminal-path-tap-injected.ts b/mobile/src/terminal/terminal-path-tap-injected.ts
index 4ce3deb36f1..766c381ea22 100644
--- a/mobile/src/terminal/terminal-path-tap-injected.ts
+++ b/mobile/src/terminal/terminal-path-tap-injected.ts
@@ -14,11 +14,12 @@
// often print a bare filename (the markdown link target is consumed, leaving
// only the label text), so requiring a slash would miss the common case.
export const TERMINAL_PATH_TAP_JS = String.raw`
- var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g;
- var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 };
- var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 };
+ var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g;
+ var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g;
+ var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 };
+ var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 };
- function parsePathLineCol(value) {
+ function parsePathLineCol(value) {
var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value);
if (!m) return null;
var pathText = m[1];
@@ -27,24 +28,91 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
var line = m[2] ? parseInt(m[2], 10) : null;
var column = m[3] ? parseInt(m[3], 10) : null;
if ((line !== null && line < 1) || (column !== null && column < 1)) return null;
- return { pathText: pathText, line: line, column: column };
- }
+ return { pathText: pathText, line: line, column: column };
+ }
- function matchFilePathAtColumn(lineText, col) {
- FILE_PATH_RE.lastIndex = 0;
+ function trimPathBoundaryPunctuation(raw, rawStart) {
+ var start = 0, end = raw.length;
+ while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1;
+ while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1;
+ if (start >= end) return null;
+ return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end };
+ }
+
+ function hasSeparatorAfterWhitespace(text) {
+ var sawWhitespace = false;
+ for (var i = 0; i < text.length; i++) {
+ var ch = text.charAt(i);
+ if (/\s/.test(ch)) { sawWhitespace = true; continue; }
+ if (sawWhitespace && (ch === '/' || ch === '\\')) return true;
+ }
+ return false;
+ }
+
+ function trimSpacedPathTrailingProse(range, col) {
+ // A line-end extension token only extends the span when the added segment
+ // is path-like (contains a separator) — prose must not be swallowed.
+ var selected = null;
+ var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g;
+ var match;
+ while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
+ var end = match.index + match[0].length;
+ var text = range.text.slice(0, end);
+ if (countPathStarts(text) > 1) continue;
+ if (end < range.text.length || selected === null || /[\\/]/.test(range.text.slice(selected.length, end))) {
+ selected = text;
+ }
+ }
+ if (selected) {
+ if (col !== undefined && col >= range.startIndex + selected.length) return null;
+ return { text: selected, startIndex: range.startIndex, endIndex: range.startIndex + selected.length };
+ }
+ var text = range.text.replace(/\s+$/, '');
+ return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length };
+ }
+
+ function countPathStarts(text) {
+ var count = 0;
+ var pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g;
+ while (pathStartPattern.exec(text) !== null) count += 1;
+ return count;
+ }
+
+ function hasSpacedPathExtension(text) {
+ var range = trimSpacedPathTrailingProse({ text: text, startIndex: 0, endIndex: text.length });
+ if (!range) return false;
+ var trimmed = range.text.replace(/\s+$/, '');
+ return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed);
+ }
+
+ function matchSpacedFilePathAtColumn(lineText, col) {
+ SPACED_PATH_RE.lastIndex = 0;
+ var match;
+ while ((match = SPACED_PATH_RE.exec(lineText)) !== null) {
+ var trimmed = trimPathBoundaryPunctuation(match[0], match.index);
+ if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue;
+ var candidate = trimSpacedPathTrailingProse(trimmed, col);
+ if (!candidate) continue;
+ if (col < candidate.startIndex || col >= candidate.endIndex) continue;
+ var parsed = parsePathLineCol(candidate.text);
+ if (parsed) return parsed;
+ }
+ return null;
+ }
+
+ function matchFilePathAtColumn(lineText, col) {
+ var spaced = matchSpacedFilePathAtColumn(lineText, col);
+ if (spaced) return spaced;
+ FILE_PATH_RE.lastIndex = 0;
var match;
while ((match = FILE_PATH_RE.exec(lineText)) !== null) {
var raw = match[0];
if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; }
- var start = 0, end = raw.length;
- while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1;
- while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1;
- if (start >= end) continue;
- var spanStart = match.index + start;
- var spanEnd = match.index + end;
- if (col < spanStart || col > spanEnd) continue;
- var parsed = parsePathLineCol(raw.slice(start, end));
- if (parsed) return parsed;
+ var trimmed = trimPathBoundaryPunctuation(raw, match.index);
+ if (!trimmed) continue;
+ if (col < trimmed.startIndex || col >= trimmed.endIndex) continue;
+ var parsed = parsePathLineCol(trimmed.text);
+ if (parsed) return parsed;
}
return null;
}
diff --git a/mobile/src/terminal/terminal-path-tap.test.ts b/mobile/src/terminal/terminal-path-tap.test.ts
index e31971b6ec6..ebb8e0b9ec3 100644
--- a/mobile/src/terminal/terminal-path-tap.test.ts
+++ b/mobile/src/terminal/terminal-path-tap.test.ts
@@ -1,6 +1,14 @@
+import { Script, createContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
+import {
+ TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES,
+ columnForTerminalFileLinkTap
+} from '../../../src/shared/terminal-file-link-conformance'
+import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import { matchFilePathAtColumn, parsePathWithOptionalLineColumn } from './terminal-path-tap'
+type InjectedPathMatcher = typeof matchFilePathAtColumn
+
// Returns the column of the first occurrence of `needle` in `line` (+offset).
function colOf(line: string, needle: string, offset = 0): number {
return line.indexOf(needle) + offset
@@ -32,6 +40,15 @@ describe('parsePathWithOptionalLineColumn', () => {
})
describe('matchFilePathAtColumn', () => {
+ it.each(TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES)(
+ 'matches shared terminal file-link tap case: $name',
+ (testCase) => {
+ expect(
+ matchFilePathAtColumn(testCase.lineText, columnForTerminalFileLinkTap(testCase))
+ ).toEqual(testCase.expected)
+ }
+ )
+
it('matches an absolute path under the tap', () => {
const line = 'created /tmp/out/report.html for you'
const result = matchFilePathAtColumn(line, colOf(line, 'report'))
@@ -50,12 +67,68 @@ describe('matchFilePathAtColumn', () => {
expect(result?.pathText).toBe('~/Documents/notes.md')
})
- it('yields the tight whitespace-bounded segment under the tap', () => {
- // On a path whose dir name has a space, tapping the file segment yields the
- // openable sub-path after the space (still resolves against the worktree).
+ it('matches a path whose directory name contains a space', () => {
const line = '/Users/me/My Project/readme.md done'
const result = matchFilePathAtColumn(line, colOf(line, 'readme'))
- expect(result?.pathText).toBe('Project/readme.md')
+ expect(result?.pathText).toBe('/Users/me/My Project/readme.md')
+ })
+
+ it('matches a spaced path with line and column suffixes', () => {
+ const line = 'wrote /tmp/orca report/result.json:12:3 for you'
+ const result = matchFilePathAtColumn(line, colOf(line, 'result'))
+ expect(result).toEqual({
+ pathText: '/tmp/orca report/result.json',
+ line: 12,
+ column: 3
+ })
+ })
+
+ it('matches a spaced path when an earlier directory segment contains a dot', () => {
+ const line = 'wrote /tmp/v1.2 reports/result.json for you'
+ expect(matchFilePathAtColumn(line, colOf(line, 'v1.2'))).toEqual({
+ pathText: '/tmp/v1.2 reports/result.json',
+ line: null,
+ column: null
+ })
+ expect(matchFilePathAtColumn(line, colOf(line, 'reports'))).toEqual({
+ pathText: '/tmp/v1.2 reports/result.json',
+ line: null,
+ column: null
+ })
+ expect(matchFilePathAtColumn(line, colOf(line, 'result'))).toEqual({
+ pathText: '/tmp/v1.2 reports/result.json',
+ line: null,
+ column: null
+ })
+ })
+
+ it('matches a path whose final filename contains a space', () => {
+ const line = 'wrote /tmp/final report.json for you'
+ expect(matchFilePathAtColumn(line, colOf(line, 'final'))).toEqual({
+ pathText: '/tmp/final report.json',
+ line: null,
+ column: null
+ })
+
+ expect(matchFilePathAtColumn(line, colOf(line, 'report'))).toEqual({
+ pathText: '/tmp/final report.json',
+ line: null,
+ column: null
+ })
+ })
+
+ it('does not merge two spaced path candidates through prose', () => {
+ const line = 'see /tmp/a.txt and /tmp/b.txt done'
+ expect(matchFilePathAtColumn(line, colOf(line, 'a.txt'))).toEqual({
+ pathText: '/tmp/a.txt',
+ line: null,
+ column: null
+ })
+ expect(matchFilePathAtColumn(line, colOf(line, 'b.txt'))).toEqual({
+ pathText: '/tmp/b.txt',
+ line: null,
+ column: null
+ })
})
it('trims surrounding punctuation', () => {
@@ -74,6 +147,11 @@ describe('matchFilePathAtColumn', () => {
expect(matchFilePathAtColumn(line, 0)).toBeNull()
})
+ it('returns null when the tap is immediately after the path span', () => {
+ const line = 'prefix /tmp/x.ts next'
+ expect(matchFilePathAtColumn(line, line.indexOf(' next'))).toBeNull()
+ })
+
it('matches a bare filename with an extension (no slash)', () => {
// Why: agents commonly print a bare filename (e.g. a markdown link whose
// target was consumed). The host existence-check rejects non-files.
@@ -87,3 +165,24 @@ describe('matchFilePathAtColumn', () => {
expect(matchFilePathAtColumn(line, colOf(line, 'Here'))).toBeNull()
})
})
+
+describe('injected matchFilePathAtColumn', () => {
+ const matcher = createInjectedPathMatcher()
+
+ it.each(TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES)(
+ 'matches shared terminal file-link tap case: $name',
+ (testCase) => {
+ expect(matcher(testCase.lineText, columnForTerminalFileLinkTap(testCase))).toEqual(
+ testCase.expected
+ )
+ }
+ )
+})
+
+function createInjectedPathMatcher(): InjectedPathMatcher {
+ const context = createContext({})
+ new Script(
+ `${TERMINAL_PATH_TAP_JS}\nthis.__matchFilePathAtColumn = matchFilePathAtColumn;`
+ ).runInContext(context)
+ return (context as { __matchFilePathAtColumn: InjectedPathMatcher }).__matchFilePathAtColumn
+}
diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts
index 7c9d295158b..ac2a8ea0ff3 100644
--- a/mobile/src/terminal/terminal-path-tap.ts
+++ b/mobile/src/terminal/terminal-path-tap.ts
@@ -13,11 +13,11 @@ export type TappedFilePath = {
// a bare filename with an extension (README.md, index.ts), optionally suffixed
// with :line or :line:col. Like desktop, we propose candidates and let the host
// existence-check reject non-files — agents often print a bare filename, so
-// requiring a slash would miss the common case. The desktop's spaced-path
-// variants are intentionally not ported: a tap always lands inside one
-// whitespace-bounded segment, so this already covers the real cases.
+// requiring a slash would miss the common case.
const LOCAL_PATH_REGEX =
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g
+const SPACED_PATH_REGEX =
+ /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|`\r\n]+(?::\d+)?(?::\d+)?/g
const LEADING_TRIM_CHARS = new Set(['(', '[', '{', '"', "'"])
const TRAILING_TRIM_CHARS = new Set([')', ']', '}', '"', "'", ',', ';', '.'])
@@ -64,9 +64,116 @@ export function parsePathWithOptionalLineColumn(value: string): TappedFilePath |
return { pathText, line, column }
}
+function hasSeparatorAfterWhitespace(text: string): boolean {
+ let sawWhitespace = false
+ for (const char of text) {
+ if (/\s/.test(char)) {
+ sawWhitespace = true
+ continue
+ }
+ if (sawWhitespace && (char === '/' || char === '\\')) {
+ return true
+ }
+ }
+ return false
+}
+
+function trimSpacedPathTrailingProse(
+ range: Span & { text: string },
+ col?: number
+): (Span & { text: string }) | null {
+ // Why: a line-end extension token only extends the span when the added
+ // segment is path-like (contains a separator) — "v1.2 reports/result.json"
+ // extends, prose like "failed to start app.py" must not be swallowed.
+ let selected: string | null = null
+ const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g
+ let match: RegExpExecArray | null
+ while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
+ const end = match.index + match[0].length
+ const text = range.text.slice(0, end)
+ if (countPathStarts(text) > 1) {
+ continue
+ }
+ if (
+ end < range.text.length ||
+ selected === null ||
+ /[\\/]/.test(range.text.slice(selected.length, end))
+ ) {
+ selected = text
+ }
+ }
+ if (!selected) {
+ const text = range.text.trimEnd()
+ return {
+ ...range,
+ text,
+ endIndex: range.startIndex + text.length
+ }
+ }
+ if (col !== undefined && col >= range.startIndex + selected.length) {
+ return null
+ }
+ return {
+ text: selected,
+ startIndex: range.startIndex,
+ endIndex: range.startIndex + selected.length
+ }
+}
+
+function countPathStarts(text: string): number {
+ let count = 0
+ for (const match of text.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) {
+ void match
+ count += 1
+ }
+ return count
+}
+
+function hasSpacedPathExtension(text: string): boolean {
+ const trimmed = trimSpacedPathTrailingProse({
+ text,
+ startIndex: 0,
+ endIndex: text.length
+ })?.text.trimEnd()
+ if (!trimmed) {
+ return false
+ }
+ return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed)
+}
+
+function matchSpacedFilePathAtColumn(lineText: string, col: number): TappedFilePath | null {
+ SPACED_PATH_REGEX.lastIndex = 0
+ let match: RegExpExecArray | null
+ while ((match = SPACED_PATH_REGEX.exec(lineText)) !== null) {
+ const trimmed = trimBoundaryPunctuation(match[0], match.index)
+ if (
+ !trimmed ||
+ (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))
+ ) {
+ continue
+ }
+ const candidate = trimSpacedPathTrailingProse(trimmed, col)
+ if (!candidate) {
+ continue
+ }
+ if (col < candidate.startIndex || col >= candidate.endIndex) {
+ continue
+ }
+ const parsed = parsePathWithOptionalLineColumn(candidate.text)
+ if (parsed) {
+ return parsed
+ }
+ }
+ return null
+}
+
// Returns the file-path span (after punctuation trim) that contains `col`, or
// null when the tap isn't on a path.
export function matchFilePathAtColumn(lineText: string, col: number): TappedFilePath | null {
+ const spaced = matchSpacedFilePathAtColumn(lineText, col)
+ if (spaced) {
+ return spaced
+ }
LOCAL_PATH_REGEX.lastIndex = 0
let match: RegExpExecArray | null
while ((match = LOCAL_PATH_REGEX.exec(lineText)) !== null) {
@@ -78,8 +185,7 @@ export function matchFilePathAtColumn(lineText: string, col: number): TappedFile
if (!trimmed) {
continue
}
- // Inclusive of the trailing edge so a tap on the last glyph still counts.
- if (col < trimmed.startIndex || col > trimmed.endIndex) {
+ if (col < trimmed.startIndex || col >= trimmed.endIndex) {
continue
}
const parsed = parsePathWithOptionalLineColumn(trimmed.text)
diff --git a/mobile/src/terminal/terminal-send-rpc-response.test.ts b/mobile/src/terminal/terminal-send-rpc-response.test.ts
new file mode 100644
index 00000000000..5ef459a6fde
--- /dev/null
+++ b/mobile/src/terminal/terminal-send-rpc-response.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest'
+import type { RpcResponse } from '../transport/types'
+import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
+
+const runtimeMeta = { runtimeId: 'test-runtime' } as const
+
+describe('terminal send RPC response', () => {
+ it('Given accepted terminal send response When checked Then reports success', () => {
+ // Given
+ const response: RpcResponse = {
+ id: '1',
+ ok: true,
+ result: { send: { handle: 'terminal-1', accepted: true, bytesWritten: 1 } },
+ _meta: runtimeMeta
+ }
+
+ // When / Then
+ expect(isTerminalSendRpcAccepted(response)).toBe(true)
+ })
+
+ it('Given rejected terminal send response When checked Then reports failure', () => {
+ // Given
+ const response: RpcResponse = {
+ id: '1',
+ ok: true,
+ result: { send: { handle: 'terminal-1', accepted: false, bytesWritten: 0 } },
+ _meta: runtimeMeta
+ }
+
+ // When / Then
+ expect(isTerminalSendRpcAccepted(response)).toBe(false)
+ })
+
+ it('Given RPC failure or malformed terminal send response When checked Then reports failure', () => {
+ // Given
+ const rpcFailure: RpcResponse = {
+ id: '1',
+ ok: false,
+ error: { code: 'terminal_error', message: 'failed' },
+ _meta: runtimeMeta
+ }
+ const malformedSuccess: RpcResponse = {
+ id: '2',
+ ok: true,
+ result: {},
+ _meta: runtimeMeta
+ }
+
+ // When / Then
+ expect(isTerminalSendRpcAccepted(rpcFailure)).toBe(false)
+ expect(isTerminalSendRpcAccepted(malformedSuccess)).toBe(false)
+ })
+})
diff --git a/mobile/src/terminal/terminal-send-rpc-response.ts b/mobile/src/terminal/terminal-send-rpc-response.ts
new file mode 100644
index 00000000000..454c5e32972
--- /dev/null
+++ b/mobile/src/terminal/terminal-send-rpc-response.ts
@@ -0,0 +1,15 @@
+import type { RpcResponse } from '../transport/types'
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+export function isTerminalSendRpcAccepted(response: RpcResponse): boolean {
+ if (!response.ok) {
+ return false
+ }
+ if (!isRecord(response.result) || !isRecord(response.result.send)) {
+ return false
+ }
+ return response.result.send.accepted === true
+}
diff --git a/mobile/src/terminal/terminal-webview-tap-routing.test.ts b/mobile/src/terminal/terminal-webview-tap-routing.test.ts
index a4d18c6e56e..9e71a3bf85f 100644
--- a/mobile/src/terminal/terminal-webview-tap-routing.test.ts
+++ b/mobile/src/terminal/terminal-webview-tap-routing.test.ts
@@ -1,4 +1,4 @@
-// @vitest-environment jsdom
+// @vitest-environment happy-dom
// Exercises the in-WebView touch dispatcher end-to-end: a surface tap on a
// printed http(s) URL must post an `open-url` message (which RN routes to the
// in-app/phone browser). Regression guard for taps that jitter a few pixels —
@@ -174,6 +174,39 @@ describe('terminal WebView tap routing', () => {
expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/issue/1234')
})
+ it('opens first-load file OSC links through terminal file taps', async () => {
+ const oscLinks = [{ row: 0, startCol: 5, endCol: 15, uri: 'file:///tmp/result.json#L12C3' }]
+ const { posted } = boot('open artifact here', oscLinks)
+ await settle()
+
+ fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }])
+ fireTouch('touchend', [])
+
+ expect(posted.find((m) => m.type === 'open-url')).toBeUndefined()
+ expect(posted.find((m) => m.type === 'terminal-file-tap')).toMatchObject({
+ type: 'terminal-file-tap',
+ pathText: '/tmp/result.json',
+ line: 12,
+ column: 3
+ })
+ })
+
+ it('opens plain file URLs through terminal file taps', async () => {
+ const line = 'open file:///tmp/result.json#L12C3 now'
+ const { posted } = boot(line)
+ await settle()
+
+ fireTouch('touchstart', [{ x: screenXForCol(line.indexOf('result')), y: tapY }])
+ fireTouch('touchend', [])
+
+ expect(posted.find((m) => m.type === 'terminal-file-tap')).toMatchObject({
+ type: 'terminal-file-tap',
+ pathText: '/tmp/result.json',
+ line: 12,
+ column: 3
+ })
+ })
+
it('does not open snapshot OSC links from adjacent terminal cells', async () => {
const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }]
const { posted } = boot('issue #1234 done', oscLinks)
@@ -185,6 +218,36 @@ describe('terminal WebView tap routing', () => {
expect(posted.find((m) => m.type === 'open-url')).toBeUndefined()
})
+ it('does not parse off-cell snapshot file OSC links while routing a tap', async () => {
+ const oscLinks = Array.from({ length: 50 }, (_, index) => ({
+ row: index + 1,
+ startCol: 0,
+ endCol: 10,
+ uri: `file:///tmp/result-${index}.json#L1`
+ }))
+ const { posted } = boot('plain text', oscLinks)
+ await settle()
+
+ const OriginalURL = window.URL
+ let parseCount = 0
+ const CountingURL = class extends OriginalURL {
+ constructor(url: string | URL, base?: string | URL) {
+ parseCount += 1
+ super(url, base)
+ }
+ }
+ Object.defineProperty(window, 'URL', { value: CountingURL, configurable: true })
+ try {
+ fireTouch('touchstart', [{ x: screenXForCol(5), y: tapY }])
+ fireTouch('touchend', [])
+ } finally {
+ Object.defineProperty(window, 'URL', { value: OriginalURL, configurable: true })
+ }
+
+ expect(parseCount).toBe(0)
+ expect(posted.find((m) => m.type === 'terminal-file-tap')).toBeUndefined()
+ })
+
it('does not open stale snapshot OSC links after the row text changes', async () => {
const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }]
const { posted, setLine } = boot('issue #1234 done', oscLinks)
diff --git a/mobile/src/terminal/terminal-webview-url-tap.test.ts b/mobile/src/terminal/terminal-webview-url-tap.test.ts
index ba992874f47..bd7ff4bf06b 100644
--- a/mobile/src/terminal/terminal-webview-url-tap.test.ts
+++ b/mobile/src/terminal/terminal-webview-url-tap.test.ts
@@ -1,11 +1,140 @@
+import { createContext, Script } from 'node:vm'
import { describe, expect, it } from 'vitest'
+import type { TappedFilePath } from './terminal-path-tap'
+import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import {
TERMINAL_HTTP_URL_MAX_LENGTH,
TERMINAL_HTTP_URL_REGEX_SOURCE,
- findUrlAtColumn
+ URL_TAP_WEBVIEW_JS,
+ findFileUrlAtColumn,
+ findUrlAtColumn,
+ resolveTerminalOscFileTap,
+ resolveTerminalFileUrlTap
} from './terminal-webview-url-tap'
import { XTERM_HTML } from './terminal-webview-html'
+type FileTapResolverCase = {
+ name: string
+ uri: string
+ expected: TappedFilePath | null
+}
+
+const FILE_URL_TAP_CASES: FileTapResolverCase[] = [
+ {
+ name: 'plain file URL',
+ uri: 'file:///tmp/result.json',
+ expected: { pathText: '/tmp/result.json', line: null, column: null }
+ },
+ {
+ name: 'hash line and column',
+ uri: 'file:///tmp/result.json#L12C3',
+ expected: { pathText: '/tmp/result.json', line: 12, column: 3 }
+ },
+ {
+ name: 'trailing line and column suffix',
+ uri: 'file:///tmp/result.json:8:2',
+ expected: { pathText: '/tmp/result.json', line: 8, column: 2 }
+ },
+ {
+ name: 'percent-encoded colon suffix stays in the path',
+ uri: 'file:///tmp/report%3A8%3A2',
+ expected: { pathText: '/tmp/report:8:2', line: null, column: null }
+ },
+ {
+ name: 'Windows drive path with hash line',
+ uri: 'file:///C:/repo/src/app.ts#L4',
+ expected: { pathText: 'C:/repo/src/app.ts', line: 4, column: null }
+ },
+ {
+ name: 'host-qualified POSIX authority is preserved',
+ uri: 'file://remote-host/tmp/result.json#L12',
+ expected: { pathText: '//remote-host/tmp/result.json', line: 12, column: null }
+ },
+ {
+ name: 'bracketed IPv6 loopback is local',
+ uri: 'file://[::1]/tmp/result.json#L12',
+ expected: { pathText: '/tmp/result.json', line: 12, column: null }
+ },
+ {
+ name: 'IPv4 loopback is local',
+ uri: 'file://127.0.0.1/tmp/result.json#L12',
+ expected: { pathText: '/tmp/result.json', line: 12, column: null }
+ },
+ {
+ name: 'UNC authority is preserved',
+ uri: 'file://server/share/repo/app.ts#L12',
+ expected: { pathText: '//server/share/repo/app.ts', line: 12, column: null }
+ },
+ {
+ name: 'non-file scheme is rejected',
+ uri: 'https://example.com/result.json',
+ expected: null
+ }
+]
+
+const OSC_FILE_TAP_CASES: FileTapResolverCase[] = [
+ {
+ name: 'relative path',
+ uri: 'docs/README.md',
+ expected: { pathText: 'docs/README.md', line: null, column: null }
+ },
+ {
+ name: 'tilde path with line and column',
+ uri: '~/notes.md:4:2',
+ expected: { pathText: '~/notes.md', line: 4, column: 2 }
+ },
+ {
+ name: 'mailto target is rejected',
+ uri: 'mailto:team@example.com',
+ expected: null
+ }
+]
+
+type InjectedFileTapResolver = (uri: string) => TappedFilePath | null
+
+// Why: the WebView blob hand-translates terminal-file-url-tap.ts into plain JS
+// with re-escaped regexes; executing it against the same cases as the TS module
+// keeps the two copies from drifting (mirrors createInjectedPathMatcher).
+function createInjectedFileTapResolvers(): {
+ resolveTerminalFileUrlTap: InjectedFileTapResolver
+ resolveTerminalOscFileTap: InjectedFileTapResolver
+} {
+ const context = createContext({ URL })
+ new Script(
+ `${TERMINAL_PATH_TAP_JS}\n${URL_TAP_WEBVIEW_JS}\n` +
+ 'this.__resolveTerminalFileUrlTap = resolveTerminalFileUrlTap;\n' +
+ 'this.__resolveTerminalOscFileTap = resolveTerminalOscFileTap;'
+ ).runInContext(context)
+ const injected = context as {
+ __resolveTerminalFileUrlTap: InjectedFileTapResolver
+ __resolveTerminalOscFileTap: InjectedFileTapResolver
+ }
+ return {
+ resolveTerminalFileUrlTap: injected.__resolveTerminalFileUrlTap,
+ resolveTerminalOscFileTap: injected.__resolveTerminalOscFileTap
+ }
+}
+
+describe.each([
+ ['module', { resolveTerminalFileUrlTap, resolveTerminalOscFileTap }],
+ ['injected', createInjectedFileTapResolvers()]
+] as const)('terminal file tap resolvers (%s)', (_variant, resolvers) => {
+ it.each(FILE_URL_TAP_CASES)('resolves file URL: $name', ({ uri, expected }) => {
+ expect(resolvers.resolveTerminalFileUrlTap(uri)).toEqual(expected)
+ })
+
+ it.each(OSC_FILE_TAP_CASES)('resolves OSC target: $name', ({ uri, expected }) => {
+ expect(resolvers.resolveTerminalOscFileTap(uri)).toEqual(expected)
+ })
+
+ it.each(FILE_URL_TAP_CASES)('OSC resolver accepts file URL: $name', ({ uri, expected }) => {
+ if (expected === null) {
+ return
+ }
+ expect(resolvers.resolveTerminalOscFileTap(uri)).toEqual(expected)
+ })
+})
+
describe('findUrlAtColumn', () => {
it('returns the URL when the tapped column falls inside it', () => {
const line = 'see https://example.com/path for details'
@@ -46,6 +175,13 @@ describe('findUrlAtColumn', () => {
expect(findUrlAtColumn(line, line.indexOf('etc'))).toBeNull()
})
+ it('finds file URLs separately so taps route to file opens', () => {
+ const line = 'open file:///tmp/result.json#L12C3 please'
+
+ expect(findFileUrlAtColumn(line, line.indexOf('result'))).toBe('file:///tmp/result.json#L12C3')
+ expect(findFileUrlAtColumn(line, 0)).toBeNull()
+ })
+
it('matches desktop URL boundary and length guards', () => {
expect(findUrlAtColumn('prefixhttps://example.com/path', 'prefix'.length)).toBeNull()
expect(findUrlAtColumn('prefix https://example.com/path', 'prefix '.length)).toBe(
@@ -58,9 +194,15 @@ describe('findUrlAtColumn', () => {
it('injects URL and OSC tap handling into the WebView document', () => {
expect(XTERM_HTML).toContain('function findUrlAtColumn(')
+ expect(XTERM_HTML).toContain('function findFileUrlAtColumn(')
+ expect(XTERM_HTML).toContain('function fileUrlAtViewportPoint(')
expect(XTERM_HTML).toContain('function urlAtViewportPoint(')
expect(XTERM_HTML).toContain(JSON.stringify(TERMINAL_HTTP_URL_REGEX_SOURCE))
expect(XTERM_HTML).toContain('function oscLinkAtViewportPoint(')
+ expect(XTERM_HTML).toContain('function resolveTerminalOscFileTap(')
+ expect(XTERM_HTML).toContain('function resolveTerminalFileUrlTap(')
+ expect(XTERM_HTML).toContain('function isLocalFileUriHostname(')
+ expect(XTERM_HTML).toContain('return parsePathLineCol(value);')
expect(XTERM_HTML).toContain('function notifyTerminalSurfaceTap(')
expect(XTERM_HTML).toContain("notify({ type: 'open-url', url: tappedUrl });")
})
diff --git a/mobile/src/terminal/terminal-webview-url-tap.ts b/mobile/src/terminal/terminal-webview-url-tap.ts
index d5fb0516a61..86f87a44198 100644
--- a/mobile/src/terminal/terminal-webview-url-tap.ts
+++ b/mobile/src/terminal/terminal-webview-url-tap.ts
@@ -1,16 +1,32 @@
+export { resolveTerminalFileUrlTap, resolveTerminalOscFileTap } from './terminal-file-url-tap'
+
export const TERMINAL_HTTP_URL_REGEX_SOURCE =
String.raw`\bhttps?:\/\/[^\s"'!*(){}|\\^<>` +
'`' +
String.raw`]*[^\s"':,.!?{}|\\^~[\]` +
'`' +
String.raw`()<>]`
+export const TERMINAL_FILE_URL_REGEX_SOURCE =
+ String.raw`\bfile:\/\/[^\s"'!*(){}|\\^<>` +
+ '`' +
+ String.raw`]*[^\s"',!?{}|\\^~[\]` +
+ '`' +
+ String.raw`()<>]`
export const TERMINAL_HTTP_URL_MAX_LENGTH = 2048
export function findUrlAtColumn(lineText: string, col: number): string | null {
+ return findTerminalUrlAtColumn(lineText, col, TERMINAL_HTTP_URL_REGEX_SOURCE)
+}
+
+export function findFileUrlAtColumn(lineText: string, col: number): string | null {
+ return findTerminalUrlAtColumn(lineText, col, TERMINAL_FILE_URL_REGEX_SOURCE)
+}
+
+function findTerminalUrlAtColumn(lineText: string, col: number, source: string): string | null {
if (typeof lineText !== 'string' || lineText.length === 0) {
return null
}
- const re = new RegExp(TERMINAL_HTTP_URL_REGEX_SOURCE, 'gi')
+ const re = new RegExp(source, 'gi')
let match: RegExpExecArray | null
while ((match = re.exec(lineText)) !== null) {
const start = match.index
@@ -30,10 +46,17 @@ export function findUrlAtColumn(lineText: string, col: number): string | null {
export const URL_TAP_WEBVIEW_JS = `
var URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_HTTP_URL_REGEX_SOURCE)};
+ var FILE_URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_FILE_URL_REGEX_SOURCE)};
var URL_TAP_MAX_LENGTH = ${TERMINAL_HTTP_URL_MAX_LENGTH};
function findUrlAtColumn(lineText, col) {
+ return findTerminalUrlAtColumn(lineText, col, URL_TAP_RE_SOURCE);
+ }
+ function findFileUrlAtColumn(lineText, col) {
+ return findTerminalUrlAtColumn(lineText, col, FILE_URL_TAP_RE_SOURCE);
+ }
+ function findTerminalUrlAtColumn(lineText, col, source) {
if (typeof lineText !== 'string' || lineText.length === 0) return null;
- var re = new RegExp(URL_TAP_RE_SOURCE, 'gi');
+ var re = new RegExp(source, 'gi');
var match;
while ((match = re.exec(lineText)) !== null) {
var end = match.index + match[0].length;
@@ -42,6 +65,11 @@ export const URL_TAP_WEBVIEW_JS = `
}
return null;
}
+ function fileUrlAtViewportPoint(clientX, clientY) {
+ var cell = viewportToCell(clientX, clientY);
+ if (!cell) return null;
+ return findFileUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col));
+ }
function urlAtViewportPoint(clientX, clientY) {
var cell = viewportToCell(clientX, clientY);
if (!cell) return null;
@@ -73,19 +101,81 @@ export const URL_TAP_WEBVIEW_JS = `
if (!svc || !svc.getLinkData) return initialOscLinkAtCell(cell.row, cell.col);
var data = svc.getLinkData(urlId);
var uri = data && data.uri;
- return uri && /^https?:/i.test(uri) ? uri : null;
+ return terminalOscLinkTarget(uri);
} catch (e) { return null; }
}
function initialOscLinkAtCell(row, col) {
for (var i = 0; i < initialOscLinks.length; i++) {
var link = initialOscLinks[i];
- if (!link || typeof link.uri !== 'string' || !/^https?:/i.test(link.uri)) continue;
+ if (!link || typeof link.uri !== 'string') continue;
if (link.row < initialOscLinkRowOffset) continue;
var shiftedRow = link.row - initialOscLinkRowOffset;
- if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return link.uri;
+ if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return terminalOscLinkTarget(link.uri);
}
return null;
}
+ function terminalOscLinkTarget(uri) {
+ if (typeof uri !== 'string') return null;
+ if (/^https?:/i.test(uri)) return { kind: 'url', url: uri };
+ var fileTap = resolveTerminalOscFileTap(uri);
+ return fileTap ? { kind: 'file', fileTap: fileTap } : null;
+ }
+ function resolveTerminalOscFileTap(uri) {
+ return resolveTerminalFileUrlTap(uri) || parseOscPathLikeTarget(uri);
+ }
+ function resolveTerminalFileUrlTap(uri) {
+ var parsed;
+ try {
+ parsed = new URL(uri);
+ } catch (e) {
+ return null;
+ }
+ if (parsed.protocol !== 'file:') return null;
+ var filePath;
+ try {
+ filePath = decodeURIComponent(parsed.pathname || '');
+ } catch (e) {
+ return null;
+ }
+ if (parsed.hostname && !isLocalFileUriHostname(parsed.hostname)) {
+ filePath = '//' + parsed.hostname + filePath;
+ } else if (/^\\/[A-Za-z]:\\//.test(filePath)) {
+ filePath = filePath.slice(1);
+ }
+ if (!filePath) return null;
+ var hashTarget = parseFileUrlLineHash(parsed.hash || '');
+ if (hashTarget) {
+ return { pathText: filePath, line: hashTarget.line, column: hashTarget.column };
+ }
+ if (/%3a/i.test(parsed.pathname || '')) {
+ return { pathText: filePath, line: null, column: null };
+ }
+ return parseFilePathTrailingLineTarget(filePath) || { pathText: filePath, line: null, column: null };
+ }
+ function isLocalFileUriHostname(hostname) {
+ var normalized = String(hostname).toLowerCase();
+ return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]';
+ }
+ function parseOscPathLikeTarget(value) {
+ if (!/^(?:~[\\\\/]|[\\\\/]|\\.{1,2}[\\\\/]|[A-Za-z]:[\\\\/]|[A-Za-z0-9._-]+[\\\\/]|(?=[A-Za-z0-9._-]*\\.[A-Za-z0-9]))/.test(value)) return null;
+ return parsePathLineCol(value);
+ }
+ function parseFileUrlLineHash(hash) {
+ var match = /^#?L(\\d+)(?:C(\\d+))?$/i.exec(hash);
+ if (!match) return null;
+ var line = parseInt(match[1], 10);
+ var column = match[2] ? parseInt(match[2], 10) : null;
+ if (line < 1 || (column !== null && column < 1)) return null;
+ return { line: line, column: column };
+ }
+ function parseFilePathTrailingLineTarget(filePath) {
+ var match = /^(.*?)(?::(\\d+))(?::(\\d+))?$/.exec(filePath);
+ if (!match || !match[1] || match[1].charAt(match[1].length - 1) === '/' || match[1].charAt(match[1].length - 1) === '\\\\') return null;
+ var line = parseInt(match[2], 10);
+ var column = match[3] ? parseInt(match[3], 10) : null;
+ if (line < 1 || (column !== null && column < 1)) return null;
+ return { pathText: match[1], line: line, column: column };
+ }
function captureInitialOscLinkTexts() {
if (!Array.isArray(initialOscLinks)) return;
for (var i = 0; i < initialOscLinks.length; i++) {
@@ -116,7 +206,28 @@ export const URL_TAP_WEBVIEW_JS = `
}
function notifyTerminalSurfaceTap(originX, originY) {
- var tappedUrl = oscLinkAtViewportPoint(originX, originY) || urlAtViewportPoint(originX, originY);
+ var tappedOscLink = oscLinkAtViewportPoint(originX, originY);
+ if (tappedOscLink && tappedOscLink.kind === 'file') {
+ notify({
+ type: 'terminal-file-tap',
+ pathText: tappedOscLink.fileTap.pathText,
+ line: tappedOscLink.fileTap.line,
+ column: tappedOscLink.fileTap.column
+ });
+ return;
+ }
+ var tappedFileUrl = fileUrlAtViewportPoint(originX, originY);
+ var tappedFileUrlPath = tappedFileUrl ? resolveTerminalFileUrlTap(tappedFileUrl) : null;
+ if (tappedFileUrlPath) {
+ notify({
+ type: 'terminal-file-tap',
+ pathText: tappedFileUrlPath.pathText,
+ line: tappedFileUrlPath.line,
+ column: tappedFileUrlPath.column
+ });
+ return;
+ }
+ var tappedUrl = tappedOscLink && tappedOscLink.kind === 'url' ? tappedOscLink.url : urlAtViewportPoint(originX, originY);
if (tappedUrl) {
notify({ type: 'open-url', url: tappedUrl });
return;
diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts
new file mode 100644
index 00000000000..b6bcb380607
--- /dev/null
+++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest'
+import { getTerminalLiveAccessoryInactiveInputCommitResult } from './use-terminal-live-accessory-input-commit'
+
+type DeferredBoolean = {
+ readonly promise: Promise
+ readonly resolve: (value: boolean) => void
+}
+
+function createDeferredBoolean(): DeferredBoolean {
+ let resolvePromise: (value: boolean) => void = () => {
+ throw new Error('deferred promise was resolved before initialization')
+ }
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve
+ })
+ return { promise, resolve: resolvePromise }
+}
+
+describe('terminal live accessory input commit', () => {
+ it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => {
+ // Given
+ const deferredFlush = createDeferredBoolean()
+ let settled = false
+
+ // When
+ const resultPromise = getTerminalLiveAccessoryInactiveInputCommitResult(
+ () => deferredFlush.promise
+ )
+ void resultPromise.then(() => {
+ settled = true
+ })
+ await Promise.resolve()
+
+ // Then
+ expect(settled).toBe(false)
+ deferredFlush.resolve(true)
+ await expect(resultPromise).resolves.toEqual({ kind: 'allow-raw' })
+ })
+
+ it('Given live input is disabled with a failed active flush When accessory raw fallback is requested Then suppresses raw send', async () => {
+ // Given
+ const waitForPendingLiveInputFlush = async (): Promise => false
+
+ // When
+ const result = await getTerminalLiveAccessoryInactiveInputCommitResult(
+ waitForPendingLiveInputFlush
+ )
+
+ // Then
+ expect(result).toEqual({ kind: 'suppress-raw' })
+ })
+})
diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts
new file mode 100644
index 00000000000..b7e8cc8b214
--- /dev/null
+++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts
@@ -0,0 +1,124 @@
+import { useCallback, type RefObject } from 'react'
+import type { TextInput } from 'react-native'
+import {
+ getTerminalLiveAccessoryBytesDecision,
+ getTerminalLiveAccessoryLocalEditText,
+ getTerminalLiveDeferredTextDelayMs
+} from './terminal-live-text-commit'
+import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
+import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
+import type { TerminalLiveInputSender } from './terminal-live-input-sender'
+
+export type TerminalLiveAccessoryInputCommitResult =
+ | { readonly kind: 'allow-raw' }
+ | { readonly kind: 'handled' }
+ | { readonly kind: 'suppress-raw' }
+
+type TerminalLiveInputCommitScheduler = (
+ handle: string,
+ text: string,
+ delayMs: number | null
+) => void
+
+export async function getTerminalLiveAccessoryInactiveInputCommitResult(
+ waitForPendingLiveInputFlush: () => Promise
+): Promise {
+ return (await waitForPendingLiveInputFlush()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' }
+}
+
+type TerminalLiveAccessoryInputCommitOptions = {
+ readonly activeHandle: string | null
+ readonly clearPendingLiveInputCommit: () => void
+ readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise
+ readonly liveInputRef: RefObject
+ readonly liveInputTerminalHandles: ReadonlySet
+ readonly pendingLiveInputHandleRef: RefObject
+ readonly pendingLiveInputTextRef: RefObject
+ readonly schedulePendingLiveInputCommit: TerminalLiveInputCommitScheduler
+ readonly sendLiveTerminalInputRef: RefObject
+ readonly setLiveInputCapture: (text: string) => void
+ readonly waitForPendingLiveInputFlush: () => Promise
+}
+
+export function useTerminalLiveAccessoryInputCommit({
+ activeHandle,
+ clearPendingLiveInputCommit,
+ flushPendingLiveInputText,
+ liveInputRef,
+ liveInputTerminalHandles,
+ pendingLiveInputHandleRef,
+ pendingLiveInputTextRef,
+ schedulePendingLiveInputCommit,
+ sendLiveTerminalInputRef,
+ setLiveInputCapture,
+ waitForPendingLiveInputFlush
+}: TerminalLiveAccessoryInputCommitOptions): (
+ input: TerminalLiveAccessoryInput
+) => Promise {
+ return useCallback(
+ async (input: TerminalLiveAccessoryInput): Promise => {
+ if (!activeHandle) {
+ return { kind: 'allow-raw' }
+ }
+ if (!liveInputTerminalHandles.has(activeHandle)) {
+ return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
+ }
+ const pendingText =
+ pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
+ if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
+ clearPendingLiveInputCommit()
+ }
+ const decision = getTerminalLiveAccessoryBytesDecision({ ...input, pendingText })
+ switch (decision.kind) {
+ case 'send-now':
+ // Why: raw accessory bytes must wait behind any in-flight IME text
+ // flush so composed Hangul reaches the PTY before follow-up controls.
+ return (await waitForPendingLiveInputFlush())
+ ? { kind: 'allow-raw' }
+ : { kind: 'suppress-raw' }
+ case 'local-edit': {
+ const editedText = getTerminalLiveAccessoryLocalEditText({
+ localEdit: decision.localEdit,
+ pendingText
+ })
+ if (editedText.length === 0) {
+ clearPendingLiveInputCommit()
+ return { kind: 'handled' }
+ }
+ // Why: accessory buttons do not emit native TextInput edits, so the
+ // pending IME buffer must be edited and rescheduled here.
+ setLiveInputCapture(editedText)
+ liveInputRef.current?.setNativeProps({ text: editedText })
+ schedulePendingLiveInputCommit(
+ activeHandle,
+ editedText,
+ getTerminalLiveDeferredTextDelayMs(editedText)
+ )
+ return { kind: 'handled' }
+ }
+ case 'flush-then-send':
+ await sendTerminalLiveControlAfterPendingFlush(
+ () => flushPendingLiveInputText(activeHandle),
+ () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
+ )
+ return { kind: 'handled' }
+ default:
+ decision satisfies never
+ return { kind: 'handled' }
+ }
+ },
+ [
+ activeHandle,
+ clearPendingLiveInputCommit,
+ flushPendingLiveInputText,
+ liveInputRef,
+ liveInputTerminalHandles,
+ pendingLiveInputHandleRef,
+ pendingLiveInputTextRef,
+ schedulePendingLiveInputCommit,
+ sendLiveTerminalInputRef,
+ setLiveInputCapture,
+ waitForPendingLiveInputFlush
+ ]
+ )
+}
diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts
new file mode 100644
index 00000000000..005d4b77f15
--- /dev/null
+++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts
@@ -0,0 +1,184 @@
+import { createElement, type RefObject } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import type { TextInput } from 'react-native'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { TerminalLiveInputSender } from './terminal-live-input-sender'
+import { TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } from './terminal-live-text-commit'
+import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
+
+type TerminalLiveInputCommitHarness = {
+ readonly captures: readonly string[]
+ readonly handlers: ReturnType>
+ readonly sent: readonly string[]
+ readonly unmount: () => void
+}
+
+type TerminalLiveInputCommitHarnessOptions = {
+ readonly sendResult?: boolean
+}
+
+function suppressReactTestRendererDeprecationWarning(): () => void {
+ const originalConsoleError = console.error
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
+ const firstArg = args[0]
+ if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
+ return
+ }
+ originalConsoleError(...args)
+ })
+ return () => consoleErrorSpy.mockRestore()
+}
+
+function createTerminalLiveInputCommitHarness({
+ sendResult = true
+}: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness {
+ const activeHandle = 'terminal-a'
+ const activeHandleRef: RefObject = { current: activeHandle }
+ const activeSessionTabTypeRef: RefObject = { current: 'terminal' }
+ const captures: string[] = []
+ const liveInputRef: RefObject = { current: null }
+ const liveInputTerminalHandles = new Set([activeHandle])
+ const liveInputTerminalHandlesRef: RefObject> = {
+ current: new Set([activeHandle])
+ }
+ const sent: string[] = []
+ const sendLiveTerminalInputRef: RefObject = {
+ current: async (_handle, bytes) => {
+ sent.push(bytes)
+ return sendResult
+ }
+ }
+ let handlers: ReturnType> | null = null
+ let renderer: ReactTestRenderer | null = null
+
+ function Harness(): null {
+ handlers = useTerminalLiveInputCommit({
+ activeHandle,
+ activeHandleRef,
+ activeSessionTabType: 'terminal',
+ activeSessionTabTypeRef,
+ liveInputRef,
+ liveInputTerminalHandles,
+ liveInputTerminalHandlesRef,
+ sendLiveTerminalInputRef,
+ setLiveInputCapture: (text) => captures.push(text)
+ })
+ return null
+ }
+
+ const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
+ try {
+ act(() => {
+ renderer = create(createElement(Harness))
+ })
+ } finally {
+ restoreConsoleError()
+ }
+ if (!handlers || !renderer) {
+ throw new Error('terminal live input hook did not render')
+ }
+
+ return {
+ captures,
+ handlers,
+ sent,
+ unmount: () => {
+ act(() => renderer?.unmount())
+ }
+ }
+}
+
+describe('terminal live input commit hook', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('Given Hangul pending text When the old idle window elapses Then does not send jamo to the terminal', async () => {
+ // Given
+ vi.useFakeTimers()
+ const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
+
+ // When
+ handlers.handleLiveInputChange('ㅎ')
+ await vi.advanceTimersByTimeAsync(1_000)
+
+ // Then
+ expect(captures).toEqual(['ㅎ'])
+ expect(sent).toEqual([])
+ })
+
+ it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => {
+ // Given
+ const { handlers, sent } = createTerminalLiveInputCommitHarness()
+ handlers.handleLiveInputChange('한')
+
+ // When
+ handlers.handleLiveInputSubmit()
+
+ // Then
+ await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
+ })
+
+ it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => {
+ // Given
+ const { handlers, sent } = createTerminalLiveInputCommitHarness()
+ handlers.handleLiveInputChange('한')
+
+ // When
+ const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
+
+ // Then
+ expect(flushed).toBe(true)
+ expect(sent).toEqual(['한'])
+ })
+
+ it('Given pending text cannot be sent When an external terminal send is requested Then reports failure', async () => {
+ // Given
+ const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
+ handlers.handleLiveInputChange('한')
+
+ // When
+ const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
+
+ // Then
+ expect(flushed).toBe(false)
+ expect(sent).toEqual(['한'])
+ })
+
+ it('Given Chinese and Vietnamese IME text When the settle window elapses Then sends the committed text', async () => {
+ // Given
+ vi.useFakeTimers()
+ const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
+
+ // When
+ handlers.handleLiveInputChange('你好')
+ await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS - 1)
+
+ // Then
+ expect(captures).toEqual(['你好'])
+ expect(sent).toEqual([])
+
+ // When
+ await vi.advanceTimersByTimeAsync(1)
+ await vi.waitFor(() => expect(sent).toEqual(['你好']))
+ handlers.handleLiveInputChange('tiếng Việt')
+ await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
+
+ // Then
+ await vi.waitFor(() => expect(sent).toEqual(['你好', 'tiếng Việt']))
+ })
+
+ it('Given deferred IME text When the hook unmounts Then cancels the pending commit timer', async () => {
+ // Given
+ vi.useFakeTimers()
+ const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness()
+ handlers.handleLiveInputChange('é')
+
+ // When
+ unmount()
+ await vi.advanceTimersByTimeAsync(1_000)
+
+ // Then
+ expect(sent).toEqual([])
+ })
+})
diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts
new file mode 100644
index 00000000000..2e2dd9e1589
--- /dev/null
+++ b/mobile/src/terminal/use-terminal-live-input-commit.ts
@@ -0,0 +1,236 @@
+import { useCallback, useEffect, type RefObject } from 'react'
+import type { TextInput } from 'react-native'
+import {
+ getTerminalLiveSpecialKeyDecision,
+ getTerminalLiveSubmitSequence,
+ getTerminalLiveTextChangeDecision
+} from './terminal-live-text-commit'
+import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
+import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
+import type { TerminalLiveInputSender } from './terminal-live-input-sender'
+import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
+import { useTerminalLivePendingInputFlush } from './use-terminal-live-pending-input-flush'
+import {
+ useTerminalLiveAccessoryInputCommit,
+ type TerminalLiveAccessoryInputCommitResult
+} from './use-terminal-live-accessory-input-commit'
+
+type TerminalLiveInputKeyPressEvent = {
+ readonly nativeEvent: {
+ readonly key: string
+ }
+}
+
+type TerminalLiveInputCommitOptions = {
+ readonly activeHandle: string | null
+ readonly activeHandleRef: RefObject
+ readonly activeSessionTabType: TTabType | null | undefined
+ readonly activeSessionTabTypeRef: RefObject
+ readonly liveInputRef: RefObject
+ readonly liveInputTerminalHandles: ReadonlySet
+ readonly liveInputTerminalHandlesRef: RefObject>
+ readonly sendLiveTerminalInputRef: RefObject
+ readonly setLiveInputCapture: (text: string) => void
+}
+
+type TerminalLiveInputCommitHandlers = {
+ readonly clearPendingLiveInputCommit: () => void
+ readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise
+ readonly handleLiveInputAccessoryBytes: (
+ input: TerminalLiveAccessoryInput
+ ) => Promise
+ readonly handleLiveInputChange: (text: string) => void
+ readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void
+ readonly handleLiveInputSubmit: () => void
+}
+
+export function useTerminalLiveInputCommit({
+ activeHandle,
+ activeHandleRef,
+ activeSessionTabType,
+ activeSessionTabTypeRef,
+ liveInputRef,
+ liveInputTerminalHandles,
+ liveInputTerminalHandlesRef,
+ sendLiveTerminalInputRef,
+ setLiveInputCapture
+}: TerminalLiveInputCommitOptions): TerminalLiveInputCommitHandlers {
+ const {
+ clearPendingLiveInputCommit,
+ flushPendingLiveInputText,
+ pendingLiveInputHandleRef,
+ pendingLiveInputTextRef,
+ schedulePendingLiveInputCommit,
+ waitForPendingLiveInputFlush
+ } = useTerminalLivePendingInputFlush({
+ activeHandleRef,
+ activeSessionTabTypeRef,
+ liveInputRef,
+ liveInputTerminalHandlesRef,
+ sendLiveTerminalInputRef,
+ setLiveInputCapture
+ })
+
+ useEffect(() => {
+ const pendingHandle = pendingLiveInputHandleRef.current
+ if (!pendingHandle) {
+ return
+ }
+ if (
+ !activeHandle ||
+ pendingHandle !== activeHandle ||
+ activeSessionTabType !== 'terminal' ||
+ !liveInputTerminalHandles.has(activeHandle)
+ ) {
+ clearPendingLiveInputCommit()
+ }
+ }, [activeHandle, activeSessionTabType, clearPendingLiveInputCommit, liveInputTerminalHandles])
+
+ const flushPendingLiveInputBeforeExternalSend = useCallback(
+ async (handle: string): Promise => {
+ const pendingHandle = pendingLiveInputHandleRef.current
+ if (pendingHandle && pendingHandle !== handle) {
+ clearPendingLiveInputCommit()
+ return waitForPendingLiveInputFlush()
+ }
+ if (pendingHandle === handle && pendingLiveInputTextRef.current.length > 0) {
+ return flushPendingLiveInputText(handle)
+ }
+ return waitForPendingLiveInputFlush()
+ },
+ [clearPendingLiveInputCommit, flushPendingLiveInputText, waitForPendingLiveInputFlush]
+ )
+
+ const handleLiveInputChange = useCallback(
+ (text: string) => {
+ if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
+ clearPendingLiveInputCommit()
+ return
+ }
+ const normalizedText = normalizeTerminalTextInput(text)
+ const decision = getTerminalLiveTextChangeDecision(normalizedText)
+ switch (decision.kind) {
+ case 'ignore':
+ clearPendingLiveInputCommit()
+ return
+ case 'send-now':
+ clearPendingLiveInputCommit()
+ void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
+ sendLiveTerminalInputRef.current(activeHandle, decision.text)
+ )
+ return
+ case 'defer':
+ // Why: React Native does not expose composition events here, so keep
+ // probable IME text in the native field until the commit timer settles.
+ setLiveInputCapture(decision.text)
+ schedulePendingLiveInputCommit(activeHandle, decision.text, decision.delayMs)
+ return
+ default:
+ decision satisfies never
+ }
+ },
+ [
+ activeHandle,
+ clearPendingLiveInputCommit,
+ liveInputTerminalHandles,
+ schedulePendingLiveInputCommit,
+ sendLiveTerminalInputRef,
+ setLiveInputCapture,
+ waitForPendingLiveInputFlush
+ ]
+ )
+
+ const handleLiveInputKeyPress = useCallback(
+ (event: TerminalLiveInputKeyPressEvent) => {
+ if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
+ return
+ }
+ const pendingText =
+ pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
+ if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
+ clearPendingLiveInputCommit()
+ }
+ const decision = getTerminalLiveSpecialKeyDecision({
+ key: event.nativeEvent.key,
+ pendingText
+ })
+ switch (decision.kind) {
+ case 'ignore':
+ case 'local-edit':
+ return
+ case 'send-now':
+ clearPendingLiveInputCommit()
+ void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
+ sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
+ )
+ return
+ case 'flush-then-send':
+ void sendTerminalLiveControlAfterPendingFlush(
+ () => flushPendingLiveInputText(activeHandle),
+ () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
+ )
+ return
+ default:
+ decision satisfies never
+ }
+ },
+ [
+ activeHandle,
+ clearPendingLiveInputCommit,
+ flushPendingLiveInputText,
+ liveInputTerminalHandles,
+ sendLiveTerminalInputRef,
+ waitForPendingLiveInputFlush
+ ]
+ )
+
+ const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({
+ activeHandle,
+ clearPendingLiveInputCommit,
+ flushPendingLiveInputText,
+ liveInputRef,
+ liveInputTerminalHandles,
+ pendingLiveInputHandleRef,
+ pendingLiveInputTextRef,
+ schedulePendingLiveInputCommit,
+ sendLiveTerminalInputRef,
+ setLiveInputCapture,
+ waitForPendingLiveInputFlush
+ })
+
+ const handleLiveInputSubmit = useCallback(() => {
+ if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
+ return
+ }
+ const pendingText =
+ pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
+ const sequence = getTerminalLiveSubmitSequence(pendingText)
+ if (sequence.length === 2) {
+ void sendTerminalLiveControlAfterPendingFlush(
+ () => flushPendingLiveInputText(activeHandle),
+ () => sendLiveTerminalInputRef.current(activeHandle, sequence[1])
+ )
+ return
+ }
+ clearPendingLiveInputCommit()
+ void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
+ sendLiveTerminalInputRef.current(activeHandle, sequence[0])
+ )
+ }, [
+ activeHandle,
+ clearPendingLiveInputCommit,
+ flushPendingLiveInputText,
+ liveInputTerminalHandles,
+ sendLiveTerminalInputRef,
+ waitForPendingLiveInputFlush
+ ])
+
+ return {
+ clearPendingLiveInputCommit,
+ flushPendingLiveInputBeforeExternalSend,
+ handleLiveInputAccessoryBytes,
+ handleLiveInputChange,
+ handleLiveInputKeyPress,
+ handleLiveInputSubmit
+ }
+}
diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts
new file mode 100644
index 00000000000..2c6998c99a0
--- /dev/null
+++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts
@@ -0,0 +1,139 @@
+import { useCallback, useEffect, useRef, type RefObject } from 'react'
+import type { TextInput } from 'react-native'
+import type { TerminalLiveInputSender } from './terminal-live-input-sender'
+import {
+ queueTerminalLivePendingFlush,
+ waitForTerminalLivePendingFlush
+} from './terminal-live-pending-flush-state'
+
+type TerminalLivePendingInputFlushOptions = {
+ readonly activeHandleRef: RefObject
+ readonly activeSessionTabTypeRef: RefObject
+ readonly liveInputRef: RefObject
+ readonly liveInputTerminalHandlesRef: RefObject>
+ readonly sendLiveTerminalInputRef: RefObject
+ readonly setLiveInputCapture: (text: string) => void
+}
+
+type TerminalLivePendingInputFlush = {
+ readonly clearPendingLiveInputCommit: () => void
+ readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise
+ readonly pendingLiveInputHandleRef: RefObject
+ readonly pendingLiveInputTextRef: RefObject
+ readonly schedulePendingLiveInputCommit: (
+ handle: string,
+ text: string,
+ delayMs: number | null
+ ) => void
+ readonly waitForPendingLiveInputFlush: () => Promise
+}
+
+export function useTerminalLivePendingInputFlush({
+ activeHandleRef,
+ activeSessionTabTypeRef,
+ liveInputRef,
+ liveInputTerminalHandlesRef,
+ sendLiveTerminalInputRef,
+ setLiveInputCapture
+}: TerminalLivePendingInputFlushOptions): TerminalLivePendingInputFlush {
+ const liveInputCommitTimerRef = useRef | null>(null)
+ const pendingLiveInputFlushRef = useRef | null>(null)
+ const pendingLiveInputTextRef = useRef('')
+ const pendingLiveInputHandleRef = useRef(null)
+
+ const clearPendingLiveInputCommit = useCallback(() => {
+ if (liveInputCommitTimerRef.current) {
+ clearTimeout(liveInputCommitTimerRef.current)
+ liveInputCommitTimerRef.current = null
+ }
+ pendingLiveInputTextRef.current = ''
+ pendingLiveInputHandleRef.current = null
+ setLiveInputCapture('')
+ liveInputRef.current?.setNativeProps({ text: '' })
+ }, [liveInputRef, setLiveInputCapture])
+
+ const waitForPendingLiveInputFlush = useCallback(async (): Promise => {
+ return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
+ }, [])
+
+ const flushPendingLiveInputText = useCallback(
+ async (expectedHandle: string | null): Promise => {
+ const existingFlush = pendingLiveInputFlushRef.current
+ if (liveInputCommitTimerRef.current) {
+ clearTimeout(liveInputCommitTimerRef.current)
+ liveInputCommitTimerRef.current = null
+ }
+
+ const handle = pendingLiveInputHandleRef.current
+ const text = pendingLiveInputTextRef.current
+ pendingLiveInputHandleRef.current = null
+ pendingLiveInputTextRef.current = ''
+ setLiveInputCapture('')
+ liveInputRef.current?.setNativeProps({ text: '' })
+
+ if (!handle || text.length === 0) {
+ return existingFlush ?? false
+ }
+ if (
+ (expectedHandle !== null && handle !== expectedHandle) ||
+ handle !== activeHandleRef.current ||
+ activeSessionTabTypeRef.current !== 'terminal' ||
+ !liveInputTerminalHandlesRef.current.has(handle)
+ ) {
+ return false
+ }
+
+ return queueTerminalLivePendingFlush(pendingLiveInputFlushRef, () =>
+ sendLiveTerminalInputRef.current(handle, text)
+ )
+ },
+ [
+ activeHandleRef,
+ activeSessionTabTypeRef,
+ liveInputRef,
+ liveInputTerminalHandlesRef,
+ sendLiveTerminalInputRef,
+ setLiveInputCapture
+ ]
+ )
+
+ const schedulePendingLiveInputCommit = useCallback(
+ (handle: string, text: string, delayMs: number | null) => {
+ if (liveInputCommitTimerRef.current) {
+ clearTimeout(liveInputCommitTimerRef.current)
+ }
+ pendingLiveInputHandleRef.current = handle
+ pendingLiveInputTextRef.current = text
+ if (delayMs === null) {
+ liveInputCommitTimerRef.current = null
+ return
+ }
+ liveInputCommitTimerRef.current = setTimeout(() => {
+ liveInputCommitTimerRef.current = null
+ void flushPendingLiveInputText(handle)
+ }, delayMs)
+ },
+ [flushPendingLiveInputText]
+ )
+
+ useEffect(() => {
+ return () => {
+ if (liveInputCommitTimerRef.current) {
+ clearTimeout(liveInputCommitTimerRef.current)
+ liveInputCommitTimerRef.current = null
+ }
+ pendingLiveInputHandleRef.current = null
+ pendingLiveInputTextRef.current = ''
+ pendingLiveInputFlushRef.current = null
+ }
+ }, [])
+
+ return {
+ clearPendingLiveInputCommit,
+ flushPendingLiveInputText,
+ pendingLiveInputHandleRef,
+ pendingLiveInputTextRef,
+ schedulePendingLiveInputCommit,
+ waitForPendingLiveInputFlush
+ }
+}
diff --git a/mobile/src/transport/rpc-client-terminal-binary-frame.test.ts b/mobile/src/transport/rpc-client-terminal-binary-frame.test.ts
new file mode 100644
index 00000000000..216fd2aec56
--- /dev/null
+++ b/mobile/src/transport/rpc-client-terminal-binary-frame.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it, vi } from 'vitest'
+import { handleTerminalBinaryFrame } from './rpc-client-terminal-binary-frame'
+import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
+
+function encodeFrame(opcode: TerminalStreamOpcode, streamId: number, payload: unknown): Uint8Array {
+ const body =
+ typeof payload === 'string'
+ ? new TextEncoder().encode(payload)
+ : new TextEncoder().encode(JSON.stringify(payload))
+ return encodeTerminalStreamFrame({
+ opcode,
+ streamId,
+ seq: 1,
+ payload: body
+ })
+}
+
+describe('handleTerminalBinaryFrame', () => {
+ it('routes terminal metadata frames to the stream listener', () => {
+ const listener = vi.fn()
+ const recordValidatedInboundTraffic = vi.fn()
+
+ handleTerminalBinaryFrame(
+ encodeFrame(TerminalStreamOpcode.Metadata, 42, { cwd: '/repo/src' }),
+ {
+ terminalSnapshots: new Map(),
+ getListener: (streamId) => (streamId === 42 ? listener : undefined),
+ recordValidatedInboundTraffic
+ }
+ )
+
+ expect(recordValidatedInboundTraffic).toHaveBeenCalledTimes(1)
+ expect(listener).toHaveBeenCalledWith({ type: 'metadata', streamId: 42, cwd: '/repo/src' })
+ })
+})
diff --git a/mobile/src/transport/rpc-client-terminal-binary-frame.ts b/mobile/src/transport/rpc-client-terminal-binary-frame.ts
new file mode 100644
index 00000000000..ee7da35383e
--- /dev/null
+++ b/mobile/src/transport/rpc-client-terminal-binary-frame.ts
@@ -0,0 +1,116 @@
+import {
+ TerminalStreamOpcode,
+ decodeTerminalStreamFrame,
+ decodeTerminalStreamJson,
+ decodeTerminalStreamText
+} from './terminal-stream-protocol'
+
+export type TerminalSnapshotState = {
+ streamId: number
+ meta: Record
+ chunks: string[]
+}
+
+type StreamingListener = (result: unknown) => void
+
+type TerminalBinaryFrameOptions = {
+ terminalSnapshots: Map
+ getListener: (streamId: number) => StreamingListener | undefined
+ recordValidatedInboundTraffic: () => void
+}
+
+export function handleTerminalBinaryFrame(
+ bytes: Uint8Array,
+ options: TerminalBinaryFrameOptions
+): void {
+ const frame = decodeTerminalStreamFrame(bytes)
+ if (!frame) {
+ return
+ }
+ const listener = options.getListener(frame.streamId)
+ if (!listener) {
+ options.recordValidatedInboundTraffic()
+ return
+ }
+ if (frame.opcode === TerminalStreamOpcode.Output) {
+ options.recordValidatedInboundTraffic()
+ listener({
+ type: 'data',
+ streamId: frame.streamId,
+ chunk: decodeTerminalStreamText(frame.payload)
+ })
+ return
+ }
+ if (frame.opcode === TerminalStreamOpcode.SnapshotStart) {
+ const meta = decodeTerminalStreamJson>(frame.payload)
+ if (!meta) {
+ return
+ }
+ options.recordValidatedInboundTraffic()
+ options.terminalSnapshots.set(frame.streamId, {
+ streamId: frame.streamId,
+ meta,
+ chunks: []
+ })
+ return
+ }
+ if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) {
+ options.recordValidatedInboundTraffic()
+ const snapshot = options.terminalSnapshots.get(frame.streamId)
+ if (!snapshot) {
+ return
+ }
+ snapshot.chunks.push(decodeTerminalStreamText(frame.payload))
+ return
+ }
+ if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) {
+ options.recordValidatedInboundTraffic()
+ const snapshot = options.terminalSnapshots.get(frame.streamId)
+ if (!snapshot) {
+ return
+ }
+ options.terminalSnapshots.delete(frame.streamId)
+ const kind = snapshot.meta.kind === 'resized' ? 'resized' : 'scrollback'
+ listener({
+ ...snapshot.meta,
+ type: kind,
+ streamId: frame.streamId,
+ serialized: snapshot.chunks.join('')
+ })
+ return
+ }
+ if (frame.opcode === TerminalStreamOpcode.Resized) {
+ const meta = decodeTerminalStreamJson>(frame.payload)
+ if (!meta) {
+ return
+ }
+ options.recordValidatedInboundTraffic()
+ listener({
+ ...meta,
+ type: 'resized',
+ streamId: frame.streamId
+ })
+ return
+ }
+ if (frame.opcode === TerminalStreamOpcode.Metadata) {
+ const meta = decodeTerminalStreamJson>(frame.payload)
+ if (!meta) {
+ return
+ }
+ options.recordValidatedInboundTraffic()
+ listener({
+ ...meta,
+ type: 'metadata',
+ streamId: frame.streamId
+ })
+ return
+ }
+ if (frame.opcode === TerminalStreamOpcode.Error) {
+ options.recordValidatedInboundTraffic()
+ listener({
+ type: 'error',
+ streamId: frame.streamId,
+ message: decodeTerminalStreamText(frame.payload)
+ })
+ }
+}
diff --git a/mobile/src/transport/rpc-client-terminal-reconnect.test.ts b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts
new file mode 100644
index 00000000000..f382068b735
--- /dev/null
+++ b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts
@@ -0,0 +1,163 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { connect } from './rpc-client'
+import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
+
+vi.mock('./e2ee', () => ({
+ generateKeyPair: () => ({
+ publicKey: new Uint8Array(32),
+ secretKey: new Uint8Array(32)
+ }),
+ deriveSharedKey: () => new Uint8Array(32),
+ publicKeyFromBase64: () => new Uint8Array(32),
+ publicKeyToBase64: () => 'client-public-key',
+ encrypt: (plaintext: string) => `encrypted:${plaintext}`,
+ decrypt: (raw: string) => (raw === 'undecryptable' ? null : raw.replace(/^encrypted:/, '')),
+ decryptBytes: (bytes: Uint8Array) => bytes
+}))
+
+class MockWebSocket {
+ static CONNECTING = 0
+ static OPEN = 1
+ static CLOSED = 3
+
+ readonly CONNECTING = MockWebSocket.CONNECTING
+ readonly OPEN = MockWebSocket.OPEN
+ readonly CLOSED = MockWebSocket.CLOSED
+
+ readyState = MockWebSocket.CONNECTING
+ onopen: (() => void) | null = null
+ onclose: (() => void) | null = null
+ onmessage: ((event: { data: unknown }) => void) | null = null
+ sent: string[] = []
+ close = vi.fn(() => {
+ this.readyState = MockWebSocket.CLOSED
+ this.onclose?.()
+ })
+
+ constructor(readonly endpoint: string) {
+ mockSockets.push(this)
+ }
+
+ send(payload: string): void {
+ this.sent.push(payload)
+ }
+
+ open(): void {
+ this.readyState = MockWebSocket.OPEN
+ this.onopen?.()
+ }
+
+ receive(payload: unknown): void {
+ this.onmessage?.({ data: payload })
+ }
+}
+
+const mockSockets: MockWebSocket[] = []
+const originalWebSocket = globalThis.WebSocket
+
+function authenticate(socket: MockWebSocket): void {
+ socket.open()
+ socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
+ socket.receive('encrypted:{"type":"e2ee_authenticated"}')
+}
+
+function sentRequest(socket: MockWebSocket, method: string): { id: string } {
+ for (const payload of socket.sent) {
+ const decoded = JSON.parse(payload.replace(/^encrypted:/, '')) as {
+ id: string
+ method: string
+ }
+ if (decoded.method === method) {
+ return { id: decoded.id }
+ }
+ }
+ throw new Error(`Request not sent: ${method}`)
+}
+
+function sentRequests(socket: MockWebSocket, method: string): Array<{ id: string }> {
+ return socket.sent
+ .map(
+ (payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as { id: string; method: string }
+ )
+ .filter((request) => request.method === method)
+}
+
+function encryptedStreamingReady(id: string, streamId: number): string {
+ return `encrypted:${JSON.stringify({
+ id,
+ ok: true,
+ streaming: true,
+ result: { type: 'subscribed', streamId }
+ })}`
+}
+
+function unauthorizedResponsePayload(id: string): string {
+ return `encrypted:${JSON.stringify({
+ id,
+ ok: false,
+ error: { code: 'unauthorized', message: 'Unauthorized' }
+ })}`
+}
+
+function encodeTerminalOutput(streamId: number, chunk: string): Uint8Array {
+ return encodeTerminalStreamFrame({
+ opcode: TerminalStreamOpcode.Output,
+ streamId,
+ seq: 1,
+ payload: new TextEncoder().encode(chunk)
+ })
+}
+
+describe('rpc-client terminal reconnect streams', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ mockSockets.length = 0
+ globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
+ })
+
+ afterEach(() => {
+ globalThis.WebSocket = originalWebSocket
+ vi.useRealTimers()
+ })
+
+ it('re-subscribes active streams after auth retry without routing stale stream ids', async () => {
+ const client = connect('ws://desktop.invalid', 'token', 'server-key')
+ const first = mockSockets[0]!
+ const terminalEvents: unknown[] = []
+ authenticate(first)
+
+ client.subscribe('terminal.subscribe', { terminal: 'term-1' }, (event) => {
+ terminalEvents.push(event)
+ })
+ const initialSubscribe = sentRequest(first, 'terminal.subscribe')
+ first.receive(encryptedStreamingReady(initialSubscribe.id, 76))
+
+ const request = client.sendRequest('status.get').catch(() => undefined)
+ await Promise.resolve()
+ first.receive(unauthorizedResponsePayload(sentRequest(first, 'status.get').id))
+ await request
+
+ await vi.advanceTimersByTimeAsync(500)
+ const second = mockSockets.at(-1)!
+ authenticate(second)
+ expect(sentRequests(second, 'terminal.subscribe')).toHaveLength(1)
+ const resumedSubscribe = sentRequest(second, 'terminal.subscribe')
+ second.receive(encryptedStreamingReady(resumedSubscribe.id, 77))
+ second.receive(encodeTerminalOutput(77, 'after-reconnect'))
+ second.receive(encodeTerminalOutput(76, 'stale-before-reconnect'))
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(terminalEvents).toContainEqual({
+ type: 'data',
+ streamId: 77,
+ chunk: 'after-reconnect'
+ })
+ expect(terminalEvents).not.toContainEqual({
+ type: 'data',
+ streamId: 76,
+ chunk: 'stale-before-reconnect'
+ })
+ client.close()
+ })
+})
diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts
index 03d7515b69d..e096ee659c9 100644
--- a/mobile/src/transport/rpc-client.test.ts
+++ b/mobile/src/transport/rpc-client.test.ts
@@ -906,57 +906,6 @@ describe('mobile rpc-client connection timeout', () => {
client.close()
})
-
- it('re-subscribes active streams after an auth-retry reconnect', async () => {
- const client = connect('ws://desktop.invalid', 'token', 'server-key')
- const first = mockSockets[0]!
- const terminalEvents: unknown[] = []
- authenticate(first)
- expect(client.getState()).toBe('connected')
-
- // An active terminal subscription on the live connection.
- client.subscribe('terminal.subscribe', { terminal: 'term-1' }, (event) => {
- terminalEvents.push(event)
- })
- expect(sentRequests(first, 'terminal.subscribe')).toHaveLength(1)
-
- // Mid-session transient unauthorized — handleAuthRejection retries
- // (budget not exhausted) and reconnects rather than latching.
- const request = client.sendRequest('status.get').catch(() => undefined)
- await Promise.resolve()
- const id = sentRequest(first, 'status.get').id
- first.receive(unauthorizedResponsePayload(id))
- await request
- expect(client.getState()).toBe('reconnecting')
-
- // Fresh socket authenticates; the replay loop must re-send the still
- // active terminal subscription (issue #5200 frozen-terminal regression).
- await vi.advanceTimersByTimeAsync(500)
- const second = mockSockets[mockSockets.length - 1]!
- expect(second).not.toBe(first)
- authenticate(second)
- expect(client.getState()).toBe('connected')
- expect(sentRequests(second, 'terminal.subscribe')).toHaveLength(1)
- const resumedSubscribe = sentRequest(second, 'terminal.subscribe')
- second.receive(
- `encrypted:${JSON.stringify({
- id: resumedSubscribe.id,
- ok: true,
- streaming: true,
- result: { type: 'subscribed', streamId: 77 }
- })}`
- )
- second.receive(encodeTerminalOutput(77, 'after-reconnect'))
- await Promise.resolve()
- await Promise.resolve()
- expect(terminalEvents).toContainEqual({
- type: 'data',
- streamId: 77,
- chunk: 'after-reconnect'
- })
-
- client.close()
- })
})
it('rejects requests waiting for reconnect after the retry cap', async () => {
diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts
index 03777588698..2cd484de4b8 100644
--- a/mobile/src/transport/rpc-client.ts
+++ b/mobile/src/transport/rpc-client.ts
@@ -15,11 +15,9 @@ import {
decryptBytes
} from './e2ee'
import {
- TerminalStreamOpcode,
- decodeTerminalStreamFrame,
- decodeTerminalStreamJson,
- decodeTerminalStreamText
-} from './terminal-stream-protocol'
+ handleTerminalBinaryFrame,
+ type TerminalSnapshotState
+} from './rpc-client-terminal-binary-frame'
import {
decodeBrowserScreencastFrame,
type BrowserScreencastFrame
@@ -63,12 +61,6 @@ type StreamRequest = {
sent?: boolean
}
-type TerminalSnapshotState = {
- streamId: number
- meta: Record
- chunks: string[]
-}
-
export type RpcClient = {
sendRequest: (
method: string,
@@ -490,6 +482,7 @@ export function connect(
pendingBrowserScreencastRequestId = id
activeBrowserScreencastRequestId = null
}
+ resetTerminalStreamRoutingForRequest(id)
if (
sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params })
) {
@@ -708,7 +701,7 @@ export function connect(
sharedKey = null
activeBrowserScreencastRequestId = null
pendingBrowserScreencastRequestId = null
- streamListeners.forEach((stream) => (stream.sent = false))
+ markStreamsForReplay()
if (handshakeTimer) {
clearTimeout(handshakeTimer)
handshakeTimer = null
@@ -759,7 +752,7 @@ export function connect(
ws = null
sharedKey = null
// Why: close cleanup stale-bails here, so mark active streams for replay.
- streamListeners.forEach((stream) => (stream.sent = false))
+ markStreamsForReplay()
rejectAllPending(reason)
if (closing) {
closing.close()
@@ -900,6 +893,25 @@ export function connect(
}
}
+ function markStreamsForReplay(): void {
+ for (const [id, stream] of streamListeners) {
+ stream.sent = false
+ resetTerminalStreamRoutingForRequest(id)
+ }
+ }
+
+ function resetTerminalStreamRoutingForRequest(id: string): void {
+ const terminalStreamIds = terminalStreamIdsByRequest.get(id)
+ if (!terminalStreamIds) {
+ return
+ }
+ for (const streamId of terminalStreamIds) {
+ terminalStreamListeners.delete(streamId)
+ terminalSnapshots.delete(streamId)
+ }
+ terminalStreamIdsByRequest.delete(id)
+ }
+
function emitStreamError(stream: StreamRequest, message: string, error?: unknown): void {
if (stream.cancelled) {
return
@@ -942,7 +954,11 @@ export function connect(
handleBrowserBinaryFrame(browserFrame)
return
}
- handleTerminalBinaryFrame(bytes)
+ handleTerminalBinaryFrame(bytes, {
+ terminalSnapshots,
+ getListener: (streamId) => terminalStreamListeners.get(streamId),
+ recordValidatedInboundTraffic
+ })
}
function handleBrowserBinaryFrame(frame: BrowserScreencastFrame) {
@@ -956,82 +972,6 @@ export function connect(
stream.onBinaryFrame?.(frame)
}
- function handleTerminalBinaryFrame(bytes: Uint8Array): void {
- const frame = decodeTerminalStreamFrame(bytes)
- if (!frame) {
- return
- }
- const listener = terminalStreamListeners.get(frame.streamId)
- if (!listener) {
- recordValidatedInboundTraffic()
- return
- }
- if (frame.opcode === TerminalStreamOpcode.Output) {
- recordValidatedInboundTraffic()
- listener({
- type: 'data',
- streamId: frame.streamId,
- chunk: decodeTerminalStreamText(frame.payload)
- })
- return
- }
- if (frame.opcode === TerminalStreamOpcode.SnapshotStart) {
- const meta = decodeTerminalStreamJson>(frame.payload)
- if (!meta) {
- return
- }
- recordValidatedInboundTraffic()
- terminalSnapshots.set(frame.streamId, { streamId: frame.streamId, meta, chunks: [] })
- return
- }
- if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) {
- recordValidatedInboundTraffic()
- const snapshot = terminalSnapshots.get(frame.streamId)
- if (!snapshot) {
- return
- }
- snapshot.chunks.push(decodeTerminalStreamText(frame.payload))
- return
- }
- if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) {
- recordValidatedInboundTraffic()
- const snapshot = terminalSnapshots.get(frame.streamId)
- if (!snapshot) {
- return
- }
- terminalSnapshots.delete(frame.streamId)
- const kind = snapshot.meta.kind === 'resized' ? 'resized' : 'scrollback'
- listener({
- ...snapshot.meta,
- type: kind,
- streamId: frame.streamId,
- serialized: snapshot.chunks.join('')
- })
- return
- }
- if (frame.opcode === TerminalStreamOpcode.Resized) {
- const meta = decodeTerminalStreamJson>(frame.payload)
- if (!meta) {
- return
- }
- recordValidatedInboundTraffic()
- listener({
- ...meta,
- type: 'resized',
- streamId: frame.streamId
- })
- return
- }
- if (frame.opcode === TerminalStreamOpcode.Error) {
- recordValidatedInboundTraffic()
- listener({
- type: 'error',
- streamId: frame.streamId,
- message: decodeTerminalStreamText(frame.payload)
- })
- }
- }
-
function sendEncrypted(request: unknown): boolean {
if (ws && ws.readyState === WebSocket.OPEN && sharedKey) {
ws.send(encrypt(JSON.stringify(request), sharedKey))
diff --git a/mobile/src/transport/terminal-stream-protocol.ts b/mobile/src/transport/terminal-stream-protocol.ts
index 0d5a04340c5..384d66996f1 100644
--- a/mobile/src/transport/terminal-stream-protocol.ts
+++ b/mobile/src/transport/terminal-stream-protocol.ts
@@ -8,7 +8,8 @@ export enum TerminalStreamOpcode {
SnapshotChunk = 3,
SnapshotEnd = 4,
Resized = 5,
- Error = 6
+ Error = 6,
+ Metadata = 12
}
export type TerminalStreamFrame = {
@@ -74,6 +75,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
value === TerminalStreamOpcode.SnapshotChunk ||
value === TerminalStreamOpcode.SnapshotEnd ||
value === TerminalStreamOpcode.Resized ||
- value === TerminalStreamOpcode.Error
+ value === TerminalStreamOpcode.Error ||
+ value === TerminalStreamOpcode.Metadata
)
}
diff --git a/notes/windows-perf-progress.md b/notes/windows-perf-progress.md
index 5cd3b89ff20..51bee65e4f6 100644
--- a/notes/windows-perf-progress.md
+++ b/notes/windows-perf-progress.md
@@ -4,6 +4,64 @@ Goal: (1) significantly improve Windows startup time (~1 min cold start reported
(2) fix OpenCode-driven UI freezes, (3) improve overall Windows performance.
All changes must be proven with before/after benchmark numbers.
+## Phase 2 (2026-07-02, branch Jinwoo-H/windows-performance-improvement) — terminal interaction latency
+
+Complaints: slow workspace switching, slow tab create/switch (terminal-related), occasional crashes.
+Harness: `tools/benchmarks/terminal-perf-bench.mjs` (CDP-driven dev app, renderer-clock phase
+timings; scenarios tab-create / tab-switch / workspace-switch; local git fixture).
+Main-process spawn attribution: `ORCA_PTY_SPAWN_TIMING=1` → `[pty-spawn-timing]` lines
+(pty.ts handler phases: preflight/auth/host_env/options/provider_spawn).
+
+Findings (baseline, this machine):
+- Workspace switch: every hide disposed each pane's WebGL context; resume recreated it —
+ ~5ms macOS, 100-500ms/pane Windows ANGLE (the comment in terminal-visibility-resume.ts
+ admitted this). Premise (16-context budget) stale since #7064 raised budget to 128.
+- Tab create: ~550ms steady state; main handler only ~115ms (host_env≈50ms, daemon
+ provider_spawn≈68ms). Remainder is renderer-side (xterm open + WebGL context for the new
+ pane + React mount). First-ever spawn paid +2.7s inside provider_spawn = daemon's first
+ ConPTY (native module + conpty.dll + OpenConsole + Defender), lazily on the user's first terminal.
+- Tab switch: paint settle median 80-99ms; longtasks 64-151ms — every light tab resume runs
+ scheduleTerminalWebglAtlasRecovery: 3× (frame/120ms/500ms) global shared-atlas clear +
+ refresh of EVERY pane in EVERY manager. Parse-time recovery (pty-connection.ts
+ recoverWebglAtlasAfterParse / hiddenOutputNeedsAtlasRecoveryAfterParse) already covers
+ risky output including hidden. CAUTION: #7058 changed this area and was reverted (#7073) —
+ left as follow-up.
+- LocalPtyProvider spawned without useConptyDll while the daemon path used it (legacy system
+ ConPTY corruption + perf differences on degraded-mode/fresh-local spawns).
+
+Fixes on this branch (PR #7080 merged in — WebGL release on dispose + stale pty:exit synthesis):
+- A/D: WebGL context retention across hide/show and the suspended-pane atlas recovery scoping
+ were reverted/parked for more terminal lifecycle testing. Hidden workspaces return to the
+ previous dispose-on-hide behavior.
+- B: useConptyDll for LocalPtyProvider spawns (local-pty-utils.ts) — parity with daemon.
+- F: daemon boots a throwaway `cmd.exe /c exit` ConPTY (windows-conpty-warmup.ts) so the
+ first user terminal doesn't pay the ~2.7s first-ConPTY cost.
+
+Follow-ups (documented, not in this branch):
+- Gate/scope the light-tab-switch atlas burst (see #7058/#7073 history first). Residual
+ tab-switch cost besides the burst: debounced ResizeObserver re-fit can reflow scrollback
+ when column count changed while hidden.
+- Renderer-side tab-create cost (~400ms): mount chain runs new Terminal() + 5 eager addons
+ + synchronous attachWebgl (pane-lifecycle.ts:108) before the spawn IPC (deferred one rAF,
+ pty-connection.ts:5158). Candidate: defer WebGL attach for brand-new panes.
+- Cold-restore respawn fan-out: reconnectPersistedTerminals does NOT spawn; the fan-out is
+ Terminal.tsx mounting a TerminalPane per restored tab at once — each fires connectPanePty
+ → rAF-deferred spawn IPC (pty-connection.ts:5158) with no concurrency cap. Cap belongs at
+ that renderer connect layer, not in reconnectPersistedTerminals.
+- First terminal opened immediately after launch also waits on the one-time daemon-init
+ barrier (pty:spawn awaits getLocalPtyStartupPromise, ipc/pty.ts:2518; measured
+ preflight=0 in the bench because hydration had finished first, but an early Ctrl+T pays
+ it). In-daemon Windows shell resolution (pwsh -Version probe, PowerShell exe-chain
+ existsSync/statSync scan) is uncached per spawn; the conpty warm-up spawns cmd.exe so it
+ does not warm PowerShell resolution. Note the warm-up and an early first spawn serialize
+ on the daemon's single thread — the 1255ms post-fix first-spawn number is mostly queueing
+ behind the in-flight warm-up, not unwarmed cost.
+- node-pty ≥1.2.0-beta defers conpty connect (spawn returns pid=0 fast) — would stop spawn
+ storms serializing the daemon loop.
+
+Pre-existing Windows-only test failures (also on main, CI is ubuntu-only): 5 attribution-shim
+PATH assertions in src/main/ipc/pty.test.ts (path-separator artifacts).
+
## Status
- [x] Benchmark harness for startup time (`tools/benchmarks/startup-time-bench.mjs`)
diff --git a/package.json b/package.json
index 88f598a7d08..858ae4e230b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.117-rc.3",
+ "version": "1.4.121-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
diff --git a/src/cli/handlers/emulator.test.ts b/src/cli/handlers/emulator.test.ts
index ee1175863ab..3a9711595b5 100644
--- a/src/cli/handlers/emulator.test.ts
+++ b/src/cli/handlers/emulator.test.ts
@@ -73,6 +73,24 @@ describe('orca emulator CLI handlers', () => {
})
})
+ it('uses a wider client timeout for emulator attach recovery', async () => {
+ queueFixtures(
+ callMock,
+ okFixture('req_attach', {
+ attached: true,
+ info: { deviceUdid: 'device-1', streamUrl: 'http://127.0.0.1:3102/stream.mjpeg' }
+ })
+ )
+
+ await main(['emulator', 'attach', 'device-1', '--worktree', 'all'], '/repo/project')
+
+ expect(callMock).toHaveBeenCalledWith(
+ 'emulator.attach',
+ { device: 'device-1', worktree: undefined, focus: false },
+ { timeoutMs: 180_000 }
+ )
+ })
+
it('rejects relative APK paths for remote runtimes', async () => {
remoteMock.mockReturnValue(true)
diff --git a/src/cli/handlers/emulator.ts b/src/cli/handlers/emulator.ts
index 20fa44f149b..e41700ff4b5 100644
--- a/src/cli/handlers/emulator.ts
+++ b/src/cli/handlers/emulator.ts
@@ -128,7 +128,13 @@ export const EMULATOR_HANDLERS: Record = {
const target = await getEmulatorCommandTarget(flags, cwd, client)
const device = getOptionalStringFlag(flags, 'device')
const focus = flags.get('focus') === true
- const res = await client.call('emulator.attach', { device, worktree: target.worktree, focus })
+ // Why: attach may cold-boot or recycle a wedged simulator (shutdown + boot +
+ // helper restart), which can legitimately exceed the 60s default budget.
+ const res = await client.call(
+ 'emulator.attach',
+ { device, worktree: target.worktree, focus },
+ { timeoutMs: 180_000 }
+ )
printResult(res, json, (r: unknown) => {
const result = r as EmulatorAttachResult
const info = result.info ?? result
diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts
index 35701d07332..ba0dad4a0ab 100644
--- a/src/main/agent-hooks/installer-utils.test.ts
+++ b/src/main/agent-hooks/installer-utils.test.ts
@@ -21,6 +21,8 @@ import {
hookDefinitionHasManagedCommand,
removeManagedCommands,
wrapPosixHookCommand,
+ wrapWindowsCmdHookCommand,
+ wrapWindowsGitBashHookCommand,
wrapWindowsHookCommand,
writeManagedScript,
writeHooksJson,
@@ -339,19 +341,19 @@ describe('wrapPosixHookCommand', () => {
)
})
+const qualifiedWindowsPowerShellCommand =
+ /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
+
+function decodeWindowsHookCommand(command: string): string {
+ const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1]
+ expect(encodedCommand).toBeTruthy()
+ return Buffer.from(encodedCommand!, 'base64').toString('utf16le')
+}
+
describe('wrapWindowsHookCommand', () => {
- const qualifiedPowerShellCommand =
- /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
-
- function decodeWindowsHookCommand(command: string): string {
- const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1]
- expect(encodedCommand).toBeTruthy()
- return Buffer.from(encodedCommand!, 'base64').toString('utf16le')
- }
-
it('invokes the .cmd through an encoded PowerShell command', () => {
const command = wrapWindowsHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd')
- expect(command).toMatch(qualifiedPowerShellCommand)
+ expect(command).toMatch(qualifiedWindowsPowerShellCommand)
expect(command).not.toMatch(/^powershell\b/i)
expect(decodeWindowsHookCommand(command)).toBe(
"& 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE"
@@ -363,7 +365,7 @@ describe('wrapWindowsHookCommand', () => {
// the whole path inside the encoded command so shells do not split it.
it('preserves spaces in the script path (user profile with space case)', () => {
const cmd = wrapWindowsHookCommand('C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd')
- expect(cmd).toMatch(qualifiedPowerShellCommand)
+ expect(cmd).toMatch(qualifiedWindowsPowerShellCommand)
expect(decodeWindowsHookCommand(cmd)).toBe(
"& 'C:\\Users\\Jorge Silva\\.orca\\agent-hooks\\codex-hook.cmd'; exit $LASTEXITCODE"
)
@@ -395,6 +397,43 @@ describe('wrapWindowsHookCommand', () => {
)
})
+describe('wrapWindowsCmdHookCommand', () => {
+ it('returns a bare .cmd path when cmd.exe can invoke it safely', () => {
+ const scriptPath = 'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd'
+ expect(wrapWindowsCmdHookCommand(scriptPath)).toBe(scriptPath)
+ })
+
+ it('falls back to the encoded launcher when cmd.exe would split or expand the path', () => {
+ const scriptPath = 'C:\\Users\\Jane Doe\\%ORCA_TEST%\\codex-hook.cmd'
+ const command = wrapWindowsCmdHookCommand(scriptPath)
+ expect(command).toMatch(qualifiedWindowsPowerShellCommand)
+ expect(decodeWindowsHookCommand(command)).toBe(`& '${scriptPath}'; exit $LASTEXITCODE`)
+ })
+})
+
+describe('wrapWindowsGitBashHookCommand', () => {
+ it('returns a forward-slash .cmd path when Git Bash can execute it safely', () => {
+ expect(
+ wrapWindowsGitBashHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd')
+ ).toBe('C:/Users/alice/.orca/agent-hooks/claude-hook.cmd')
+ })
+
+ it('falls back to the encoded launcher when bash would split the path', () => {
+ const scriptPath = 'C:\\Users\\Jane Doe\\.orca\\agent-hooks\\claude-hook.cmd'
+ const command = wrapWindowsGitBashHookCommand(scriptPath)
+ expect(command).toMatch(qualifiedWindowsPowerShellCommand)
+ expect(decodeWindowsHookCommand(command)).toBe(`& '${scriptPath}'; exit $LASTEXITCODE`)
+ })
+
+ it('falls back to the encoded launcher when bash metacharacters are present', () => {
+ const scriptPath = 'C:\\Users\\alice & bob\\.orca\\agent-hooks\\claude-hook.cmd'
+ const command = wrapWindowsGitBashHookCommand(scriptPath)
+ expect(command).toMatch(qualifiedWindowsPowerShellCommand)
+ expect(command).not.toContain('& bob')
+ expect(decodeWindowsHookCommand(command)).toBe(`& '${scriptPath}'; exit $LASTEXITCODE`)
+ })
+})
+
describe('buildWindowsAgentHookPostCommand', () => {
it('posts hook stdin through bounded curl without spawning PowerShell', () => {
const command = buildWindowsAgentHookPostCommand('codex')
diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts
index 4d233c0fc33..86bb58607ca 100644
--- a/src/main/agent-hooks/installer-utils.ts
+++ b/src/main/agent-hooks/installer-utils.ts
@@ -163,6 +163,24 @@ export function wrapWindowsHookCommand(scriptPath: string): string {
return `${getWindowsPowerShellExecutablePath()} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`
}
+export const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/
+
+export function wrapWindowsCmdHookCommand(scriptPath: string): string {
+ // Why: skip the ~360ms PowerShell interpreter startup when the path is safe
+ // for cmd.exe to invoke directly. The fallback keeps the encoded PowerShell
+ // launcher for paths with spaces or metacharacters.
+ return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath)
+}
+
+export const WINDOWS_GIT_BASH_SAFE_PATH = /^[A-Za-z0-9_.:/~-]+$/
+
+export function wrapWindowsGitBashHookCommand(scriptPath: string): string {
+ const bashPath = scriptPath.replaceAll('\\', '/')
+ // Why: Claude Code's Windows hook runner is Git Bash/MSYS. It can execute a
+ // bare .cmd via forward slashes, but shell metacharacters must stay encoded.
+ return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath) ? bashPath : wrapWindowsHookCommand(scriptPath)
+}
+
export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string {
// Why: Codex runs these hooks inline on every turn. PowerShell startup alone
// makes trusted Windows hooks visibly slow, so mirror the POSIX curl path.
diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts
index 9f345a2e6ee..11fbcd772cf 100644
--- a/src/main/browser/cdp-ws-proxy.test.ts
+++ b/src/main/browser/cdp-ws-proxy.test.ts
@@ -21,7 +21,9 @@ function createMockWebContents() {
detach: vi.fn(() => {
debuggerAttached = false
}),
- sendCommand: vi.fn(async () => ({})),
+ sendCommand: vi.fn(
+ async (_method?: string, _params?: Record, _sessionId?: string) => ({})
+ ),
on: vi.fn((event: string, handler: DebuggerListener) => {
const arr = listeners.get(event) ?? []
arr.push(handler)
@@ -41,6 +43,8 @@ function createMockWebContents() {
debugger: debuggerObj,
isDestroyed: () => destroyed,
focus: vi.fn(),
+ reload: vi.fn(),
+ reloadIgnoringCache: vi.fn(),
getTitle: vi.fn(() => 'Example'),
getURL: vi.fn(() => 'https://example.com')
},
@@ -89,6 +93,22 @@ describe('CdpWsProxy', () => {
})
}
+ type SendCommandCall = [string, Record?, string?]
+
+ function getSendCommandCalls(): SendCommandCall[] {
+ const calls = mock.webContents.debugger.sendCommand.mock.calls as unknown as [
+ string,
+ Record?,
+ string?
+ ][]
+ return calls
+ }
+
+ function getSendCommandMethods(): string[] {
+ const calls = getSendCommandCalls()
+ return calls.map((call) => call[0])
+ }
+
it('starts on a random port and returns ws:// URL', () => {
expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
expect(proxy.getPort()).toBeGreaterThan(0)
@@ -311,6 +331,168 @@ describe('CdpWsProxy', () => {
client.close()
})
+ it('primes lifecycle events for Page.navigate', async () => {
+ const client = await connect()
+
+ const response = await sendAndReceive(client, {
+ id: 11,
+ method: 'Page.navigate',
+ params: { url: 'https://example.com/next' }
+ })
+
+ expect(response.id).toBe(11)
+ expect(response.result).toEqual({})
+ expect(getSendCommandMethods()).toEqual([
+ 'Page.enable',
+ 'Page.addScriptToEvaluateOnNewDocument',
+ 'Network.enable',
+ 'Page.enable',
+ 'Page.setLifecycleEventsEnabled',
+ 'Page.navigate'
+ ])
+ client.close()
+ })
+
+ it('primes lifecycle events for Page.reload and preserves response id', async () => {
+ const client = await connect()
+
+ const response = await sendAndReceive(client, {
+ id: 12,
+ method: 'Page.reload'
+ })
+
+ expect(response.id).toBe(12)
+ expect(response.result).toEqual({})
+ expect(getSendCommandMethods()).toEqual([
+ 'Page.enable',
+ 'Page.addScriptToEvaluateOnNewDocument',
+ 'Network.enable',
+ 'Page.enable',
+ 'Page.setLifecycleEventsEnabled'
+ ])
+ expect(mock.webContents.reload).toHaveBeenCalledTimes(1)
+ expect(getSendCommandMethods()).not.toContain('Page.reload')
+ client.close()
+ })
+
+ it('preserves explicit Page.navigate session during lifecycle priming', async () => {
+ const client = await connect()
+
+ await sendAndReceive(client, {
+ id: 14,
+ method: 'Page.navigate',
+ params: { url: 'https://example.com/frame' },
+ sessionId: 'iframe-session-123'
+ })
+
+ expect(getSendCommandCalls().slice(2)).toEqual([
+ ['Network.enable', {}, 'iframe-session-123'],
+ ['Page.enable', {}, 'iframe-session-123'],
+ ['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
+ ['Page.navigate', { url: 'https://example.com/frame' }, 'iframe-session-123']
+ ])
+ client.close()
+ })
+
+ it('forwards explicit Page.reload session after lifecycle priming', async () => {
+ const client = await connect()
+
+ await sendAndReceive(client, {
+ id: 15,
+ method: 'Page.reload',
+ params: { ignoreCache: true },
+ sessionId: 'iframe-session-123'
+ })
+
+ expect(getSendCommandCalls().slice(2)).toEqual([
+ ['Network.enable', {}, 'iframe-session-123'],
+ ['Page.enable', {}, 'iframe-session-123'],
+ ['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
+ ['Page.reload', { ignoreCache: true }, 'iframe-session-123']
+ ])
+ expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
+ expect(mock.webContents.reload).not.toHaveBeenCalled()
+ client.close()
+ })
+
+ it('rejects root Page.reload params that direct webContents reload cannot honor', async () => {
+ const client = await connect()
+
+ const response = await sendAndReceive(client, {
+ id: 16,
+ method: 'Page.reload',
+ params: { loaderId: 'stale-loader' }
+ })
+
+ expect(response).toEqual({
+ id: 16,
+ error: {
+ code: -32000,
+ message: 'Page.reload parameter "loaderId" is not supported for Orca tab reloads'
+ }
+ })
+ expect(mock.webContents.reload).not.toHaveBeenCalled()
+ expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
+ expect(getSendCommandMethods()).not.toContain('Network.enable')
+ client.close()
+ })
+
+ it('still reloads when lifecycle priming stalls', async () => {
+ const client = await connect()
+ mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
+ if (method === 'Network.enable') {
+ return new Promise(() => {})
+ }
+ return Promise.resolve({})
+ })
+
+ const responsePromise = sendAndReceive(client, {
+ id: 17,
+ method: 'Page.reload'
+ })
+
+ await expect(responsePromise).resolves.toEqual({ id: 17, result: {} })
+ expect(mock.webContents.reload).toHaveBeenCalledTimes(1)
+ client.close()
+ })
+
+ it('does not reload after the requesting client disconnects during priming', async () => {
+ const client = await connect()
+ mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
+ if (method === 'Network.enable') {
+ return new Promise(() => {})
+ }
+ return Promise.resolve({})
+ })
+
+ client.send(JSON.stringify({ id: 18, method: 'Page.reload' }))
+ client.close()
+
+ await new Promise((resolve) => setTimeout(resolve, 1_100))
+
+ expect(mock.webContents.reload).not.toHaveBeenCalled()
+ expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
+ })
+
+ it('forwards Runtime.evaluate without lifecycle priming', async () => {
+ const client = await connect()
+
+ const response = await sendAndReceive(client, {
+ id: 13,
+ method: 'Runtime.evaluate',
+ params: { expression: 'document.readyState' }
+ })
+
+ expect(response.id).toBe(13)
+ expect(response.result).toEqual({})
+ expect(getSendCommandMethods()).toEqual([
+ 'Page.enable',
+ 'Page.addScriptToEvaluateOnNewDocument',
+ 'Runtime.evaluate'
+ ])
+ client.close()
+ })
+
// ── Page.frameNavigated interception ──
// ── Cleanup ──
diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts
index 2531822b982..ec8de5be176 100644
--- a/src/main/browser/cdp-ws-proxy.ts
+++ b/src/main/browser/cdp-ws-proxy.ts
@@ -6,6 +6,8 @@ import { captureScreenshot } from './cdp-screenshot'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease'
+const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000
+
export class CdpWsProxy {
private httpServer: Server | null = null
private wss: WebSocketServer | null = null
@@ -298,13 +300,39 @@ export class CdpWsProxy {
}
// Why: agent-browser waits for network idle to detect navigation completion.
// Electron webview CDP subscriptions silently lapse after cross-process swaps.
+ // Page.reload needs the same priming: forwarding it unprimed closed the tab (#7031).
if (msg.method === 'Page.navigate' && !this.webContents.isDestroyed()) {
- void this.navigateWithLifecycleEnsured(client, clientId, msg.params ?? {})
+ void this.navigateWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId)
+ return
+ }
+ // Why: CDP Page.reload can destroy Electron webview targets during process swaps.
+ // Use the same direct webContents reload path as Orca's own browser.reload.
+ if (msg.method === 'Page.reload' && !this.webContents.isDestroyed()) {
+ void this.reloadWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId)
return
}
this.forwardCommand(client, clientId, msg.method, msg.params ?? {}, msg.sessionId)
}
+ private resolveDebuggerSessionId(msgSessionId?: string): string | undefined {
+ return msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined
+ }
+
+ private isActiveClient(client: WebSocket): boolean {
+ return this.client === client && client.readyState === WebSocket.OPEN
+ }
+
+ private sendDebuggerCommand(
+ method: string,
+ params: Record,
+ sessionId?: string
+ ): Promise {
+ const command = sessionId
+ ? this.webContents.debugger.sendCommand(method, params, sessionId)
+ : this.webContents.debugger.sendCommand(method, params)
+ return Promise.resolve(command)
+ }
+
private forwardCommand(
client: WebSocket,
clientId: number,
@@ -316,10 +344,9 @@ export class CdpWsProxy {
this.sendError(clientId, 'Browser tab is no longer available', client)
return
}
- const sessionId =
- msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined
+ const sessionId = this.resolveDebuggerSessionId(msgSessionId)
try {
- Promise.resolve(this.webContents.debugger.sendCommand(method, params, sessionId))
+ this.sendDebuggerCommand(method, params, sessionId)
.then((result) => {
this.sendResult(clientId, result, client)
})
@@ -331,21 +358,85 @@ export class CdpWsProxy {
}
}
- private async navigateWithLifecycleEnsured(
+ private async navigateWithLifecycle(
client: WebSocket,
clientId: number,
- params: Record
+ params: Record,
+ msgSessionId?: string
): Promise {
+ await this.primePageLifecycle(this.resolveDebuggerSessionId(msgSessionId))
+ if (!this.isActiveClient(client)) {
+ return
+ }
+ this.forwardCommand(client, clientId, 'Page.navigate', params, msgSessionId)
+ }
+
+ private async reloadWithLifecycle(
+ client: WebSocket,
+ clientId: number,
+ params: Record,
+ msgSessionId?: string
+ ): Promise {
+ const sessionId = this.resolveDebuggerSessionId(msgSessionId)
+ const unsupportedParam = sessionId ? null : this.getUnsupportedRootReloadParam(params)
+ if (unsupportedParam) {
+ this.sendError(
+ clientId,
+ `Page.reload parameter "${unsupportedParam}" is not supported for Orca tab reloads`,
+ client
+ )
+ return
+ }
+ await this.primePageLifecycle(sessionId)
+ if (!this.isActiveClient(client)) {
+ return
+ }
+ if (sessionId) {
+ this.forwardCommand(client, clientId, 'Page.reload', params, msgSessionId)
+ return
+ }
+ if (this.webContents.isDestroyed()) {
+ this.sendError(clientId, 'Browser tab is no longer available', client)
+ return
+ }
try {
- const dbg = this.webContents.debugger
+ if (params.ignoreCache === true) {
+ this.webContents.reloadIgnoringCache()
+ } else {
+ this.webContents.reload()
+ }
+ this.sendResult(clientId, {}, client)
+ } catch (err) {
+ this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
+ }
+ }
+
+ private getUnsupportedRootReloadParam(params: Record): string | null {
+ return Object.keys(params).find((key) => key !== 'ignoreCache') ?? null
+ }
+
+ private async primePageLifecycle(sessionId?: string): Promise {
+ let timeout: ReturnType | null = null
+ const priming = (async (): Promise => {
// Why: without Network.enable, agent-browser never sees network idle → goto times out.
- await dbg.sendCommand('Network.enable', {})
- await dbg.sendCommand('Page.enable', {})
- await dbg.sendCommand('Page.setLifecycleEventsEnabled', { enabled: true })
- } catch {
- /* best-effort */
+ await this.sendDebuggerCommand('Network.enable', {}, sessionId)
+ await this.sendDebuggerCommand('Page.enable', {}, sessionId)
+ await this.sendDebuggerCommand('Page.setLifecycleEventsEnabled', { enabled: true }, sessionId)
+ })().catch(() => {})
+
+ try {
+ await Promise.race([
+ priming,
+ new Promise((resolve) => {
+ timeout = setTimeout(resolve, LIFECYCLE_PRIMING_TIMEOUT_MS)
+ timeout.unref?.()
+ })
+ ])
+ } finally {
+ if (timeout) {
+ clearTimeout(timeout)
+ }
}
- this.forwardCommand(client, clientId, 'Page.navigate', params)
}
private handleScreenshot(
diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts
index 97e43e24287..22c20b63e81 100644
--- a/src/main/claude/hook-service.test.ts
+++ b/src/main/claude/hook-service.test.ts
@@ -15,6 +15,7 @@ vi.mock('electron', () => ({
}))
import type { SFTPWrapper } from 'ssh2'
+import { createManagedCommandMatcher } from '../agent-hooks/installer-utils'
import { ClaudeHookService } from './hook-service'
import { OPENCLAUDE_HOOK_SETTINGS } from './hook-settings'
@@ -23,6 +24,8 @@ const OPENCLAUDE_SCRIPT_FILE_NAME =
process.platform === 'win32' ? 'openclaude-hook.cmd' : 'openclaude-hook.sh'
const WINDOWS_POWERSHELL_LAUNCHER =
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
+const isClaudeManagedCommand = createManagedCommandMatcher(CLAUDE_SCRIPT_FILE_NAME)
+const isOpenClaudeManagedCommand = createManagedCommandMatcher(OPENCLAUDE_SCRIPT_FILE_NAME)
type FakeFs = {
files: Map
@@ -162,23 +165,13 @@ describe('ClaudeHookService.install', () => {
definition.hooks.map((hook) => hook.command)
)
expect(legacyCommands).toContain('/usr/local/bin/user-hook')
- expect(
- legacyCommands.some((command: string) =>
- process.platform === 'win32'
- ? WINDOWS_POWERSHELL_LAUNCHER.test(command)
- : command.includes(CLAUDE_SCRIPT_FILE_NAME)
- )
- ).toBe(true)
+ expect(legacyCommands.some((command: string) => isClaudeManagedCommand(command))).toBe(true)
expect(
legacyCommands.some((command: string) =>
command.includes('/Users/old/.orca/agent-hooks/claude-hook.sh')
)
).toBe(false)
- expect(legacy.hooks.StopFailure[0].hooks[0].command).toMatch(
- process.platform === 'win32'
- ? WINDOWS_POWERSHELL_LAUNCHER
- : new RegExp(CLAUDE_SCRIPT_FILE_NAME)
- )
+ expect(isClaudeManagedCommand(legacy.hooks.StopFailure[0].hooks[0].command)).toBe(true)
expect(
readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME), 'utf-8')
).toContain('DEVIN_PROJECT_DIR')
@@ -351,10 +344,8 @@ describe('OpenClaudeHookService-compatible install', () => {
const parsed = JSON.parse(readFileSync(openClaudeSettings, 'utf-8'))
for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure']) {
const command = parsed.hooks[event][0].hooks[0].command as string
- if (process.platform === 'win32') {
- expect(command).toMatch(WINDOWS_POWERSHELL_LAUNCHER)
- } else {
- expect(command).toContain(OPENCLAUDE_SCRIPT_FILE_NAME)
+ expect(isOpenClaudeManagedCommand(command)).toBe(true)
+ if (process.platform !== 'win32') {
expect(command).toMatch(/^if \[ -x /)
}
}
diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts
index 51ce5a9f16b..cb754fce5d0 100644
--- a/src/main/claude/hook-settings.ts
+++ b/src/main/claude/hook-settings.ts
@@ -6,7 +6,7 @@ import {
getSharedManagedScriptPath,
removeManagedCommands,
wrapPosixHookCommand,
- wrapWindowsHookCommand,
+ wrapWindowsGitBashHookCommand,
type HookDefinition,
type HooksConfig
} from '../agent-hooks/installer-utils'
@@ -75,14 +75,9 @@ export function getRemoteConfigPath(remoteHome: string, settings = CLAUDE_HOOK_S
}
export function getManagedCommand(scriptPath: string): string {
- if (process.platform === 'win32') {
- // Why: Claude Code runs hooks through Git Bash on Windows. Forward slashes
- // alone don't survive a path with spaces — bash splits at the space and
- // tries to execute `C:/Users/Jorge` as a command. Wrapping in
- // `cmd.exe /d /c call "..."` keeps the path as one argument. #6078.
- return wrapWindowsHookCommand(scriptPath)
- }
- return wrapPosixHookCommand(scriptPath)
+ return process.platform === 'win32'
+ ? wrapWindowsGitBashHookCommand(scriptPath)
+ : wrapPosixHookCommand(scriptPath)
}
export function getRemoteManagedCommand(scriptPath: string): string {
diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts
index 807d502f934..13b39b6b0d7 100644
--- a/src/main/codex-accounts/service.test.ts
+++ b/src/main/codex-accounts/service.test.ts
@@ -277,6 +277,49 @@ describe('CodexAccountService config sync', () => {
)
})
+ it('rewrites relative path config values when syncing into managed homes', async () => {
+ const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
+ writeFileSync(
+ canonicalConfigPath,
+ 'model_instructions_file = "instructions.md"\nsandbox_mode = "danger-full-access"\n',
+ 'utf-8'
+ )
+ const managedHomePath = createManagedHome(
+ testState.userDataDir,
+ 'account-1',
+ 'approval_policy = "on-request"\n',
+ '{"account":"managed"}\n'
+ )
+ const settings = createSettings({
+ codexManagedAccounts: [
+ {
+ id: 'account-1',
+ email: 'user@example.com',
+ managedHomePath,
+ providerAccountId: null,
+ workspaceLabel: null,
+ workspaceAccountId: null,
+ createdAt: 1,
+ updatedAt: 1,
+ lastAuthenticatedAt: 1
+ }
+ ],
+ activeCodexManagedAccountId: 'account-1'
+ })
+ const store = createStore(settings)
+ const rateLimits = createRateLimits()
+ const runtimeHome = createRuntimeHome()
+
+ const { CodexAccountService } = await import('./service')
+ new CodexAccountService(store as never, rateLimits as never, runtimeHome as never)
+
+ const managedConfig = readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')
+ expect(managedConfig).toContain(
+ `model_instructions_file = '${join(testState.fakeHomeDir, '.codex', 'instructions.md')}'`
+ )
+ expect(managedConfig).toContain('sandbox_mode = "danger-full-access"')
+ })
+
it('does not rewrite managed configs that already match canonical config', async () => {
const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
const canonicalConfig = 'approval_policy = "never"\nsandbox_mode = "danger-full-access"\n'
@@ -706,7 +749,11 @@ describe('CodexAccountService config sync', () => {
const wslManagedHomePath = join(testState.userDataDir, 'wsl-managed-home')
const wslConfigPath = join(testState.userDataDir, 'wsl-config.toml')
const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-id-for-test/home'
- writeFileSync(wslConfigPath, 'sandbox_mode = "danger-full-access"\n', 'utf-8')
+ writeFileSync(
+ wslConfigPath,
+ 'sandbox_mode = "danger-full-access"\nmodel_instructions_file = "instructions.md"\n',
+ 'utf-8'
+ )
const execFileSyncMock = vi.fn((_command: string, args: string[]) => {
const script = decodeEncodedWslBashCommand(String(args.at(-1)))
@@ -731,8 +778,11 @@ describe('CodexAccountService config sync', () => {
'-ic',
`export CODEX_HOME='${wslLinuxHomePath}'; exec codex login`
])
+ // Why: codex login runs inside WSL, so the rewritten path must be the
+ // Linux-side ~/.codex, not a Windows UNC path.
expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe(
- 'sandbox_mode = "danger-full-access"\n'
+ 'sandbox_mode = "danger-full-access"\n' +
+ "model_instructions_file = '/home/alice/.codex/instructions.md'\n"
)
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough
diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts
index ef28b584ae3..c979725633b 100644
--- a/src/main/codex-accounts/service.ts
+++ b/src/main/codex-accounts/service.ts
@@ -15,6 +15,7 @@ import type {
} from '../../shared/types'
import type { CodexRuntimeHomeService } from './runtime-home-service'
import { writeFileAtomically } from './fs-utils'
+import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite'
import { resolveCodexCommand } from '../codex-cli/command'
import type { Store } from '../persistence'
import type { RateLimitService } from '../rate-limits/service'
@@ -47,6 +48,13 @@ type ResolvedCodexIdentity = {
workspaceAccountId: string | null
}
+type CanonicalCodexConfig = {
+ contents: string
+ /** Home the config was read from, in the path style Codex sees at runtime
+ * (Linux-side for WSL); relative path-valued settings resolve against it. */
+ sourceHomePath: string
+}
+
export type CodexAccountAddTarget = {
runtime?: 'host' | 'wsl'
wslDistro?: string | null
@@ -452,25 +460,31 @@ export class CodexAccountService {
// Why: Orca account switching is meant to swap Codex credentials and quota
// identity, not silently fork the user's sandbox/config defaults. Syncing
// one canonical config into every managed home keeps auth isolated per
- // account while preserving consistent Codex behavior.
- this.writeManagedConfig(trustedManagedHomePath, canonicalConfig)
+ // account while preserving consistent Codex behavior. Managed homes are
+ // real CODEX_HOMEs for `codex login`, so relative path-valued settings
+ // must keep resolving against the home the config was read from.
+ this.writeManagedConfig(
+ trustedManagedHomePath,
+ rewriteRelativePathConfigValues(canonicalConfig.contents, canonicalConfig.sourceHomePath)
+ )
}
- private readCanonicalConfig(): string | null {
- const primaryConfigPath = join(homedir(), '.codex', 'config.toml')
+ private readCanonicalConfig(): CanonicalCodexConfig | null {
+ const sourceHomePath = join(homedir(), '.codex')
+ const primaryConfigPath = join(sourceHomePath, 'config.toml')
if (!existsSync(primaryConfigPath)) {
return null
}
try {
- return readFileSync(primaryConfigPath, 'utf-8')
+ return { contents: readFileSync(primaryConfigPath, 'utf-8'), sourceHomePath }
} catch (error) {
console.warn('[codex-accounts] Failed to read canonical config:', error)
return null
}
}
- private readCanonicalConfigForManagedHome(managedHomePath: string): string | null {
+ private readCanonicalConfigForManagedHome(managedHomePath: string): CanonicalCodexConfig | null {
const wslInfo = parseWslUncPath(managedHomePath)
if (!wslInfo) {
return this.readCanonicalConfig()
@@ -488,7 +502,9 @@ export class CodexAccountService {
}
try {
- return readFileSync(configPath, 'utf-8')
+ // Why: the config is read over UNC but consumed by Codex inside WSL, so
+ // path rewrites must anchor to the Linux-side ~/.codex, not the UNC path.
+ return { contents: readFileSync(configPath, 'utf-8'), sourceHomePath: `${wslHome}/.codex` }
} catch (error) {
console.warn('[codex-accounts] Failed to read WSL canonical config:', error)
return null
diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts
index 17865439a5d..ff3ad9ba3bd 100644
--- a/src/main/codex/codex-config-mirror.test.ts
+++ b/src/main/codex/codex-config-mirror.test.ts
@@ -108,6 +108,141 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
expect(readFileSync(getSystemConfigPath(), 'utf-8')).toContain('codex_hooks = true')
})
+ it('preserves system-home relative path references in the runtime config copy', () => {
+ writeFileSync(
+ getSystemConfigPath(),
+ [
+ 'model_instructions_file = "instructions.md"',
+ "model_catalog_json = 'catalogs/models.json'",
+ 'experimental_compact_prompt_file = "prompts/compact.md"',
+ 'experimental_instructions_file = "legacy-instructions.md"',
+ 'log_dir = "logs"',
+ 'sqlite_home = "state"',
+ '',
+ '[agents.reviewer]',
+ 'config_file = "agents/reviewer.toml"',
+ '',
+ '[model_providers.qwen.auth]',
+ 'cwd = "auth"',
+ '',
+ '[[skills.config]]',
+ 'path = "skills/local"',
+ ''
+ ].join('\r\n'),
+ 'utf-8'
+ )
+
+ syncSystemConfigIntoManagedCodexHome()
+
+ const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
+ expect(runtimeConfig).toContain(
+ `model_instructions_file = '${join(getSystemCodexHomePath(), 'instructions.md')}'`
+ )
+ expect(runtimeConfig).toContain(
+ `model_catalog_json = '${join(getSystemCodexHomePath(), 'catalogs', 'models.json')}'`
+ )
+ expect(runtimeConfig).toContain(
+ `experimental_compact_prompt_file = '${join(
+ getSystemCodexHomePath(),
+ 'prompts',
+ 'compact.md'
+ )}'`
+ )
+ expect(runtimeConfig).toContain(
+ `experimental_instructions_file = '${join(
+ getSystemCodexHomePath(),
+ 'legacy-instructions.md'
+ )}'`
+ )
+ expect(runtimeConfig).toContain(`log_dir = '${join(getSystemCodexHomePath(), 'logs')}'`)
+ expect(runtimeConfig).toContain(`sqlite_home = '${join(getSystemCodexHomePath(), 'state')}'`)
+ expect(runtimeConfig).toContain(
+ `config_file = '${join(getSystemCodexHomePath(), 'agents', 'reviewer.toml')}'`
+ )
+ expect(runtimeConfig).toContain(`cwd = '${join(getSystemCodexHomePath(), 'auth')}'`)
+ expect(runtimeConfig).toContain(`path = '${join(getSystemCodexHomePath(), 'skills', 'local')}'`)
+ })
+
+ it('rewrites profile and debug lockfile path references', () => {
+ writeFileSync(
+ getSystemConfigPath(),
+ [
+ '[profiles.fast]',
+ 'model_catalog_json = "catalogs/fast.json"',
+ '',
+ '[debug.config_lockfile]',
+ 'load_path = "locks/config.lock.toml"',
+ 'export_dir = "locks"',
+ ''
+ ].join('\n'),
+ 'utf-8'
+ )
+
+ syncSystemConfigIntoManagedCodexHome()
+
+ const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
+ expect(runtimeConfig).toContain(
+ `model_catalog_json = '${join(getSystemCodexHomePath(), 'catalogs', 'fast.json')}'`
+ )
+ expect(runtimeConfig).toContain(
+ `load_path = '${join(getSystemCodexHomePath(), 'locks', 'config.lock.toml')}'`
+ )
+ expect(runtimeConfig).toContain(`export_dir = '${join(getSystemCodexHomePath(), 'locks')}'`)
+ })
+
+ it('does not treat lines inside multiline arrays as headers or path keys', () => {
+ writeFileSync(
+ getSystemConfigPath(),
+ ['notify = [', ' ["custom", 1]', ']', 'log_dir = "logs"', ''].join('\n'),
+ 'utf-8'
+ )
+
+ syncSystemConfigIntoManagedCodexHome()
+
+ const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
+ expect(runtimeConfig).toContain(' ["custom", 1]')
+ expect(runtimeConfig).toContain(`log_dir = '${join(getSystemCodexHomePath(), 'logs')}'`)
+ })
+
+ it('escapes control characters instead of emitting them raw in rewritten paths', () => {
+ writeFileSync(getSystemConfigPath(), 'log_dir = "logs\\bdir"\n', 'utf-8')
+
+ syncSystemConfigIntoManagedCodexHome()
+
+ const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
+ expect(runtimeConfig).toContain('log_dir = "')
+ expect(runtimeConfig).toContain('\\u0008')
+ expect(runtimeConfig).not.toContain('\b')
+ })
+
+ it('leaves values with lone-surrogate unicode escapes untouched', () => {
+ writeFileSync(getSystemConfigPath(), 'log_dir = "logs\\uD800dir"\n', 'utf-8')
+
+ syncSystemConfigIntoManagedCodexHome()
+
+ const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
+ expect(runtimeConfig).toContain('log_dir = "logs\\uD800dir"')
+ })
+
+ it('leaves absolute, home-prefixed, env-shaped, and URL path references untouched', () => {
+ const passthroughLines = [
+ 'model_instructions_file = "~/notes/instructions.md"',
+ 'model_catalog_json = "$CODEX_ASSETS/models.json"',
+ 'experimental_instructions_file = "%USERPROFILE%\\\\instructions.md"',
+ 'log_dir = "/var/log/codex"',
+ "sqlite_home = 'C:\\Users\\example\\state'",
+ 'experimental_compact_prompt_file = "file://server/prompts/compact.md"'
+ ]
+ writeFileSync(getSystemConfigPath(), `${passthroughLines.join('\n')}\n`, 'utf-8')
+
+ syncSystemConfigIntoManagedCodexHome()
+
+ const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
+ for (const line of passthroughLines) {
+ expect(runtimeConfig).toContain(line)
+ }
+ })
+
it('drops deprecated codex_hooks when the new hooks flag already exists', () => {
writeFileSync(
getSystemConfigPath(),
diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts
index a78b876c155..ff9f72c55fe 100644
--- a/src/main/codex/codex-config-mirror.ts
+++ b/src/main/codex/codex-config-mirror.ts
@@ -1,7 +1,14 @@
import { existsSync, readFileSync } from 'node:fs'
-import { join } from 'node:path'
+import { dirname, join } from 'node:path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
+import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite'
+import {
+ createTomlLineScanState,
+ getTomlTableHeader,
+ isTomlStructuralLine,
+ updateTomlLineScanState
+} from './config-toml-line-scan'
function getRuntimeCodexConfigTomlPath(): string {
return join(getOrcaManagedCodexHomePath(), 'config.toml')
@@ -28,8 +35,9 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
return
}
- const systemConfig = normalizeDeprecatedCodexHookFeatureFlag(
- systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : ''
+ const systemConfig = prepareSystemConfigForRuntimeMirror(
+ systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '',
+ dirname(systemConfigPath)
)
if (!runtimeConfigExists) {
// Why: trust blocks reference a hooks.json path, so system-home hook trust
@@ -45,6 +53,13 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
}
}
+function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: string): string {
+ return rewriteRelativePathConfigValues(
+ normalizeDeprecatedCodexHookFeatureFlag(config),
+ systemConfigDir
+ )
+}
+
function normalizeDeprecatedCodexHookFeatureFlag(config: string): string {
if (!config.includes('codex_hooks')) {
return config
@@ -145,13 +160,6 @@ type TomlSection = {
start: number
}
-type TomlMultilineState = {
- basic: boolean
- literal: boolean
-}
-
-type TomlMultilineMode = 'basic' | 'literal' | null
-
function stripRuntimeOwnedTomlSections(
config: string,
runtimeProjectHeaders = new Set()
@@ -179,14 +187,12 @@ function getTomlSections(config: string): TomlSection[] {
const sections: TomlSection[] = []
let sectionStart = -1
let sectionHeader: string | null = null
- let multilineState: TomlMultilineState = { basic: false, literal: false }
+ let scanState = createTomlLineScanState()
for (let index = 0; index < lines.length; index += 1) {
- const header = isInsideTomlMultilineString(multilineState)
- ? null
- : getTomlTableHeader(lines[index] ?? '')
+ const header = isTomlStructuralLine(scanState) ? getTomlTableHeader(lines[index] ?? '') : null
if (!header) {
- multilineState = updateTomlMultilineState(multilineState, lines[index] ?? '')
+ scanState = updateTomlLineScanState(scanState, lines[index] ?? '')
continue
}
@@ -199,7 +205,7 @@ function getTomlSections(config: string): TomlSection[] {
}
sectionStart = index
sectionHeader = header
- multilineState = updateTomlMultilineState(multilineState, lines[index] ?? '')
+ scanState = updateTomlLineScanState(scanState, lines[index] ?? '')
}
if (sectionStart !== -1) {
@@ -241,87 +247,3 @@ function joinTomlBlocks(blocks: string[]): string {
const normalizedBlocks = blocks.map((block) => block.trim()).filter((block) => block.length > 0)
return normalizedBlocks.length === 0 ? '' : `${normalizedBlocks.join('\n\n')}\n`
}
-
-function getTomlTableHeader(line: string): string | null {
- const match = /^(\s*\[\[?.+\]\]?\s*)(?:#.*)?$/.exec(line)
- return match?.[1] ?? null
-}
-
-function isInsideTomlMultilineString(state: TomlMultilineState): boolean {
- return state.basic || state.literal
-}
-
-function updateTomlMultilineState(state: TomlMultilineState, line: string): TomlMultilineState {
- let mode: TomlMultilineMode = state.basic ? 'basic' : state.literal ? 'literal' : null
- let index = 0
- while (index < line.length) {
- if (mode === 'basic') {
- if (line[index] === '\\') {
- index += 2
- continue
- }
- if (line.startsWith('"""', index)) {
- mode = null
- index += 3
- continue
- }
- index += 1
- continue
- }
- if (mode === 'literal') {
- if (line.startsWith("'''", index)) {
- mode = null
- index += 3
- continue
- }
- index += 1
- continue
- }
-
- const char = line[index]
- if (char === '#') {
- break
- }
- if (line.startsWith('"""', index)) {
- mode = 'basic'
- index += 3
- continue
- }
- if (line.startsWith("'''", index)) {
- mode = 'literal'
- index += 3
- continue
- }
- if (char === '"') {
- index = skipTomlBasicString(line, index + 1)
- continue
- }
- if (char === "'") {
- index = skipTomlLiteralString(line, index + 1)
- continue
- }
- index += 1
- }
- return { basic: mode === 'basic', literal: mode === 'literal' }
-}
-
-function skipTomlBasicString(line: string, startIndex: number): number {
- let index = startIndex
- while (index < line.length) {
- const char = line[index]
- if (char === '\\') {
- index += 2
- continue
- }
- if (char === '"') {
- return index + 1
- }
- index += 1
- }
- return index
-}
-
-function skipTomlLiteralString(line: string, startIndex: number): number {
- const endIndex = line.indexOf("'", startIndex)
- return endIndex === -1 ? line.length : endIndex + 1
-}
diff --git a/src/main/codex/codex-config-path-reference-rewrite.ts b/src/main/codex/codex-config-path-reference-rewrite.ts
new file mode 100644
index 00000000000..94e29db3d8f
--- /dev/null
+++ b/src/main/codex/codex-config-path-reference-rewrite.ts
@@ -0,0 +1,251 @@
+import { posix as pathPosix, win32 as pathWin32 } from 'node:path'
+import {
+ createTomlLineScanState,
+ getTomlTableHeader,
+ isTomlStructuralLine,
+ updateTomlLineScanState
+} from './config-toml-line-scan'
+
+// Why: codex-rs types these settings AbsolutePathBuf and resolves relative
+// values against the defining config.toml's directory (= CODEX_HOME for the
+// user config). experimental_instructions_file only exists in older Codex
+// releases; keeping it is harmless since Codex ignores unknown keys.
+const EXACT_PATH_CONFIG_KEYS = new Set([
+ 'debug.config_lockfile.export_dir',
+ 'debug.config_lockfile.load_path',
+ 'experimental_compact_prompt_file',
+ 'experimental_instructions_file',
+ 'log_dir',
+ 'model_catalog_json',
+ 'model_instructions_file',
+ 'skills.config.path',
+ 'sqlite_home'
+])
+
+type ParsedTomlString = {
+ value: string
+ start: number
+ end: number
+}
+
+// Why: Orca mirrors config.toml into a managed CODEX_HOME, but Codex resolves
+// path-valued config settings from the file it read. Keep user-owned assets in
+// ~/.codex reachable after the mirror moves the TOML. Best-effort by design:
+// values spelled as inline tables, quoted keys, or triple-quoted strings pass
+// through unchanged rather than risk corrupting them.
+export function rewriteRelativePathConfigValues(config: string, sourceConfigDir: string): string {
+ const lines = config.split('\n')
+ let tablePath = ''
+ let scanState = createTomlLineScanState()
+
+ for (let index = 0; index < lines.length; index += 1) {
+ const line = lines[index] ?? ''
+ if (isTomlStructuralLine(scanState)) {
+ const header = getTomlTableHeader(line)
+ if (header) {
+ tablePath = getTomlHeaderPath(header)
+ } else {
+ lines[index] = rewriteRelativePathConfigLine(line, tablePath, sourceConfigDir)
+ }
+ }
+ scanState = updateTomlLineScanState(scanState, line)
+ }
+
+ return lines.join('\n')
+}
+
+function rewriteRelativePathConfigLine(
+ line: string,
+ tablePath: string,
+ sourceConfigDir: string
+): string {
+ const equalsIndex = line.indexOf('=')
+ if (equalsIndex === -1) {
+ return line
+ }
+
+ const key = line.slice(0, equalsIndex).trim()
+ if (!isPathConfigKey(tablePath, key)) {
+ return line
+ }
+
+ const parsed = parseTomlSingleLineStringValue(line, equalsIndex + 1)
+ if (!parsed || !shouldRewriteRelativePath(parsed.value)) {
+ return line
+ }
+
+ // Why: WSL configs are read over UNC paths but consumed inside Linux, so
+ // join semantics must follow the source home's path style, not the host's.
+ const path = sourceConfigDir.startsWith('/') ? pathPosix : pathWin32
+ const absolutePath = path.join(sourceConfigDir, parsed.value)
+ return `${line.slice(0, parsed.start)}${quoteTomlPath(absolutePath)}${line.slice(parsed.end)}`
+}
+
+function isPathConfigKey(tablePath: string, key: string): boolean {
+ const normalizedKey = normalizeTomlPathExpression(key)
+ const fullPath = tablePath
+ ? `${normalizeTomlPathExpression(tablePath)}.${normalizedKey}`
+ : normalizedKey
+ if (EXACT_PATH_CONFIG_KEYS.has(fullPath)) {
+ return true
+ }
+ return (
+ /^agents\..+\.config_file$/.test(fullPath) ||
+ /^model_providers\..+\.auth\.cwd$/.test(fullPath) ||
+ // Why: profiles mirror the top-level file settings that Codex reads (and
+ // can abort on) during config load.
+ /^profiles\..+\.(?:experimental_compact_prompt_file|model_catalog_json|model_instructions_file)$/.test(
+ fullPath
+ )
+ )
+}
+
+function normalizeTomlPathExpression(value: string): string {
+ return value.replace(/\s+/g, '')
+}
+
+function shouldRewriteRelativePath(value: string): boolean {
+ const trimmed = value.trim()
+ if (!trimmed || trimmed.startsWith('~') || trimmed.startsWith('$') || trimmed.startsWith('%')) {
+ return false
+ }
+ if (pathWin32.isAbsolute(trimmed) || pathPosix.isAbsolute(trimmed)) {
+ return false
+ }
+ return !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(trimmed)
+}
+
+function quoteTomlPath(value: string): string {
+ return canUseTomlLiteralString(value) ? `'${value}'` : quoteTomlBasicString(value)
+}
+
+// Why: TOML literal strings allow tab but no other control chars and no
+// single quote; anything else must go through an escaped basic string.
+function canUseTomlLiteralString(value: string): boolean {
+ for (const char of value) {
+ if (char === "'") {
+ return false
+ }
+ const codePoint = char.codePointAt(0) ?? 0
+ if ((codePoint < 0x20 && codePoint !== 0x09) || codePoint === 0x7f) {
+ return false
+ }
+ }
+ return true
+}
+
+function quoteTomlBasicString(value: string): string {
+ let quoted = '"'
+ for (const char of value) {
+ if (char === '"' || char === '\\') {
+ quoted += `\\${char}`
+ continue
+ }
+ const codePoint = char.codePointAt(0) ?? 0
+ if (codePoint < 0x20 || codePoint === 0x7f) {
+ quoted += `\\u${codePoint.toString(16).toUpperCase().padStart(4, '0')}`
+ continue
+ }
+ quoted += char
+ }
+ return `${quoted}"`
+}
+
+function parseTomlSingleLineStringValue(line: string, offset: number): ParsedTomlString | null {
+ let index = offset
+ while (line[index] === ' ' || line[index] === '\t') {
+ index += 1
+ }
+
+ if (line.startsWith('"""', index) || line.startsWith("'''", index)) {
+ return null
+ }
+
+ const quote = line[index]
+ if (quote !== '"' && quote !== "'") {
+ return null
+ }
+
+ const start = index
+ index += 1
+ let value = ''
+ while (index < line.length) {
+ const char = line[index]
+ if (char === quote) {
+ return { value, start, end: index + 1 }
+ }
+ if (quote === '"' && char === '\\') {
+ const escaped = parseTomlBasicStringEscape(line, index)
+ if (!escaped) {
+ return null
+ }
+ value += escaped.value
+ index = escaped.nextIndex
+ continue
+ }
+ value += char
+ index += 1
+ }
+ return null
+}
+
+function parseTomlBasicStringEscape(
+ line: string,
+ slashIndex: number
+): { value: string; nextIndex: number } | null {
+ const escaped = line[slashIndex + 1]
+ switch (escaped) {
+ case 'b':
+ return { value: '\b', nextIndex: slashIndex + 2 }
+ case 't':
+ return { value: '\t', nextIndex: slashIndex + 2 }
+ case 'n':
+ return { value: '\n', nextIndex: slashIndex + 2 }
+ case 'f':
+ return { value: '\f', nextIndex: slashIndex + 2 }
+ case 'r':
+ return { value: '\r', nextIndex: slashIndex + 2 }
+ case '"':
+ case '\\':
+ return { value: escaped, nextIndex: slashIndex + 2 }
+ case 'u':
+ return parseTomlUnicodeEscape(line, slashIndex + 2, 4)
+ case 'U':
+ return parseTomlUnicodeEscape(line, slashIndex + 2, 8)
+ default:
+ return null
+ }
+}
+
+function parseTomlUnicodeEscape(
+ line: string,
+ start: number,
+ length: number
+): { value: string; nextIndex: number } | null {
+ const raw = line.slice(start, start + length)
+ if (!new RegExp(`^[0-9a-fA-F]{${length}}$`).test(raw)) {
+ return null
+ }
+ const codePoint = Number.parseInt(raw, 16)
+ // Why: TOML escapes must be Unicode scalar values; String.fromCodePoint
+ // accepts lone surrogates, which would round-trip into invalid TOML.
+ if (codePoint >= 0xd800 && codePoint <= 0xdfff) {
+ return null
+ }
+ try {
+ return { value: String.fromCodePoint(codePoint), nextIndex: start + length }
+ } catch {
+ return null
+ }
+}
+
+function getTomlHeaderPath(header: string): string {
+ const trimmed = header.trim()
+ if (trimmed.startsWith('[[') && trimmed.endsWith(']]')) {
+ return trimmed.slice(2, -2).trim()
+ }
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
+ return trimmed.slice(1, -1).trim()
+ }
+ return ''
+}
diff --git a/src/main/codex/config-toml-line-scan.ts b/src/main/codex/config-toml-line-scan.ts
new file mode 100644
index 00000000000..c0136be9e32
--- /dev/null
+++ b/src/main/codex/config-toml-line-scan.ts
@@ -0,0 +1,115 @@
+// Why: Orca edits Codex config.toml byte-preservingly (no TOML dependency), so
+// every editor must agree on which lines sit inside multiline strings or
+// arrays vs real TOML structure. Keep the line-scanner in one place to avoid
+// drift.
+
+export type TomlLineScanState = {
+ basic: boolean
+ literal: boolean
+ arrayDepth: number
+}
+
+type TomlMultilineMode = 'basic' | 'literal' | null
+
+export function createTomlLineScanState(): TomlLineScanState {
+ return { basic: false, literal: false, arrayDepth: 0 }
+}
+
+// Why: lines inside multiline strings or unclosed arrays can look exactly like
+// `[section]` headers or `key = value` pairs but are data, not structure.
+export function isTomlStructuralLine(state: TomlLineScanState): boolean {
+ return !state.basic && !state.literal && state.arrayDepth === 0
+}
+
+export function updateTomlLineScanState(state: TomlLineScanState, line: string): TomlLineScanState {
+ let mode: TomlMultilineMode = state.basic ? 'basic' : state.literal ? 'literal' : null
+ let arrayDepth = state.arrayDepth
+ let index = 0
+ while (index < line.length) {
+ if (mode === 'basic') {
+ if (line[index] === '\\') {
+ index += 2
+ continue
+ }
+ if (line.startsWith('"""', index)) {
+ mode = null
+ index += 3
+ continue
+ }
+ index += 1
+ continue
+ }
+ if (mode === 'literal') {
+ if (line.startsWith("'''", index)) {
+ mode = null
+ index += 3
+ continue
+ }
+ index += 1
+ continue
+ }
+
+ const char = line[index]
+ if (char === '#') {
+ break
+ }
+ if (line.startsWith('"""', index)) {
+ mode = 'basic'
+ index += 3
+ continue
+ }
+ if (line.startsWith("'''", index)) {
+ mode = 'literal'
+ index += 3
+ continue
+ }
+ if (char === '"') {
+ index = skipTomlBasicString(line, index + 1)
+ continue
+ }
+ if (char === "'") {
+ index = skipTomlLiteralString(line, index + 1)
+ continue
+ }
+ // Why: table-header brackets balance within their line, so a depth that
+ // stays positive across lines means a multiline array is still open.
+ if (char === '[') {
+ arrayDepth += 1
+ index += 1
+ continue
+ }
+ if (char === ']') {
+ arrayDepth = Math.max(0, arrayDepth - 1)
+ index += 1
+ continue
+ }
+ index += 1
+ }
+ return { basic: mode === 'basic', literal: mode === 'literal', arrayDepth }
+}
+
+export function getTomlTableHeader(line: string): string | null {
+ const match = /^(\s*\[\[?.+\]\]?\s*)(?:#.*)?$/.exec(line)
+ return match?.[1] ?? null
+}
+
+function skipTomlBasicString(line: string, startIndex: number): number {
+ let index = startIndex
+ while (index < line.length) {
+ const char = line[index]
+ if (char === '\\') {
+ index += 2
+ continue
+ }
+ if (char === '"') {
+ return index + 1
+ }
+ index += 1
+ }
+ return index
+}
+
+function skipTomlLiteralString(line: string, startIndex: number): number {
+ const endIndex = line.indexOf("'", startIndex)
+ return endIndex === -1 ? line.length : endIndex + 1
+}
diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts
index afc9c0d3685..d587f044d5e 100644
--- a/src/main/codex/config-toml-trust.ts
+++ b/src/main/codex/config-toml-trust.ts
@@ -11,6 +11,11 @@ import { dirname, join } from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import { escapeRegex } from '../../shared/string-utils'
import { copyFileWithWindowsRetry, renameFileWithWindowsRetry } from '../codex-accounts/fs-utils'
+import {
+ createTomlLineScanState,
+ isTomlStructuralLine,
+ updateTomlLineScanState
+} from './config-toml-line-scan'
// Why: Codex 0.129+ gates each hook on a `trusted_hash` entry in
// ~/.codex/config.toml under [hooks.state.""]. Without it the hook is in
@@ -476,16 +481,14 @@ export function normalizeHookTrustKeyForLookup(key: string): string {
function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
const ranges: TrustBlockRange[] = []
let cursor = 0
- let multilineState: TomlMultilineState = { basic: false, literal: false }
+ let scanState = createTomlLineScanState()
while (cursor < content.length) {
const newlineIdx = content.indexOf('\n', cursor)
const lineEnd = newlineIdx === -1 ? content.length : newlineIdx
const rawLine = content.slice(cursor, lineEnd)
const line = rawLine.replace(/\r$/, '')
const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1
- const headerKey = isInsideTomlMultilineString(multilineState)
- ? null
- : parseHookStateHeaderKey(line)
+ const headerKey = isTomlStructuralLine(scanState) ? parseHookStateHeaderKey(line) : null
if (
headerKey !== null &&
normalizeHookTrustKeyForLookup(headerKey) === normalizeHookTrustKeyForLookup(key)
@@ -498,7 +501,7 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
cursor = Math.max(blockEnd, nextCursor)
continue
}
- multilineState = updateTomlMultilineState(multilineState, line)
+ scanState = updateTomlLineScanState(scanState, line)
cursor = nextCursor
}
return ranges
@@ -596,13 +599,13 @@ function skipTomlInlineWhitespace(line: string, startIndex: number): number {
// a flat regex misclassifies both cases.
function findNextTableHeader(text: string): number {
let cursor = 0
- let multilineState: TomlMultilineState = { basic: false, literal: false }
+ let scanState = createTomlLineScanState()
while (cursor < text.length) {
const newlineIdx = text.indexOf('\n', cursor)
const lineEnd = newlineIdx === -1 ? text.length : newlineIdx
const rawLine = text.slice(cursor, lineEnd)
const line = rawLine.replace(/\r$/, '')
- if (!isInsideTomlMultilineString(multilineState)) {
+ if (isTomlStructuralLine(scanState)) {
const trimmed = line.trimStart()
// Why: stop at both `[table]` and `[[array.of.tables]]` — both end our
// block. Skipping `[[ ]]` here would let our slice consume past array
@@ -611,7 +614,7 @@ function findNextTableHeader(text: string): number {
return cursor
}
}
- multilineState = updateTomlMultilineState(multilineState, line)
+ scanState = updateTomlLineScanState(scanState, line)
if (newlineIdx === -1) {
return -1
}
@@ -678,92 +681,6 @@ function isCompleteTableHeader(line: string): boolean {
return false
}
-type TomlMultilineState = {
- basic: boolean
- literal: boolean
-}
-
-type TomlMultilineMode = 'basic' | 'literal' | null
-
-function isInsideTomlMultilineString(state: TomlMultilineState): boolean {
- return state.basic || state.literal
-}
-
-function updateTomlMultilineState(state: TomlMultilineState, line: string): TomlMultilineState {
- let mode: TomlMultilineMode = state.basic ? 'basic' : state.literal ? 'literal' : null
- let index = 0
- while (index < line.length) {
- if (mode === 'basic') {
- if (line[index] === '\\') {
- index += 2
- continue
- }
- if (line.startsWith('"""', index)) {
- mode = null
- index += 3
- continue
- }
- index++
- continue
- }
- if (mode === 'literal') {
- if (line.startsWith("'''", index)) {
- mode = null
- index += 3
- continue
- }
- index++
- continue
- }
-
- const char = line[index]
- if (char === '#') {
- break
- }
- if (line.startsWith('"""', index)) {
- mode = 'basic'
- index += 3
- continue
- }
- if (line.startsWith("'''", index)) {
- mode = 'literal'
- index += 3
- continue
- }
- if (char === '"') {
- index = skipTomlBasicString(line, index + 1)
- continue
- }
- if (char === "'") {
- index = skipTomlLiteralString(line, index + 1)
- continue
- }
- index++
- }
- return { basic: mode === 'basic', literal: mode === 'literal' }
-}
-
-function skipTomlBasicString(line: string, startIndex: number): number {
- let index = startIndex
- while (index < line.length) {
- const char = line[index]
- if (char === '\\') {
- index += 2
- continue
- }
- if (char === '"') {
- return index + 1
- }
- index++
- }
- return index
-}
-
-function skipTomlLiteralString(line: string, startIndex: number): number {
- const endIndex = line.indexOf("'", startIndex)
- return endIndex === -1 ? line.length : endIndex + 1
-}
-
// Why: same atomic-rename + .bak rotation pattern as writeHooksJson — a
// half-written config.toml can brick a user's Codex install, so write to
// tmp and rename. Random-suffix tmp name avoids cross-process races on
@@ -830,14 +747,14 @@ export function readHookTrustEntries(configPath: string): Map {
if (process.send) {
process.send({ type: 'ready' })
}
+
+ warmWindowsConptyOnce()
}
// Only auto-run when executed directly (not imported for testing)
diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts
index e32415aa9a3..af7756e45e7 100644
--- a/src/main/daemon/daemon-pty-adapter.test.ts
+++ b/src/main/daemon/daemon-pty-adapter.test.ts
@@ -6,6 +6,7 @@ import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonServer } from './daemon-server'
import { getHistorySessionDirName } from './history-paths'
+import type { HistoryReader } from './history-reader'
import type { SubprocessHandle } from './session'
import type * as DaemonHealthModule from './daemon-health'
import { getDaemonSocketPath } from './daemon-spawner'
@@ -1239,6 +1240,159 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false)
})
+ it('skips the cold-restore replay when the daemon session is still alive', async () => {
+ const sessionId = 'warm-reattach-skip-replay'
+ const first = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
+ await first.spawn({ cols: 80, rows: 24, cwd: '/home/user', sessionId })
+ // Why disconnectOnly: the production app-quit path leaves meta.endedAt
+ // null so the session stays crash-recoverable — the state every app
+ // relaunch with a live daemon sees.
+ await first.disconnectOnly()
+
+ historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
+ const reader = (historyAdapter as unknown as { historyReader: HistoryReader }).historyReader
+ const detectSpy = vi.spyOn(reader, 'detectColdRestore')
+ const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
+
+ expect(result.isReattach).toBe(true)
+ expect(result.coldRestore).toBeUndefined()
+ expect(detectSpy).not.toHaveBeenCalled()
+ // The unmanaged-reattach re-anchor must survive the skipped detect.
+ const internals = historyAdapter as unknown as {
+ sessionsNeedingFullCheckpoint: Set
+ lastFullCheckpointAt: Map
+ }
+ expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(true)
+ expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false)
+ })
+
+ it('does not probe session aliveness when there is no restorable history', async () => {
+ historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
+ const client = (
+ historyAdapter as unknown as {
+ client: { request: (type: string, payload?: unknown) => Promise }
+ }
+ ).client
+ const requestSpy = vi.spyOn(client, 'request')
+
+ await historyAdapter.spawn({ cols: 80, rows: 24, sessionId: 'fresh-no-history' })
+
+ expect(requestSpy.mock.calls.map((call) => call[0])).not.toContain('getSize')
+ })
+
+ it('recovers cold restore when the probed session dies before createOrAttach', async () => {
+ const sessionId = 'probe-race-cold-restore'
+ const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
+ mkdirSync(sessionDir, { recursive: true })
+ writeFileSync(
+ join(sessionDir, 'meta.json'),
+ JSON.stringify({
+ cwd: '/projects/raced',
+ cols: 100,
+ rows: 30,
+ startedAt: '2026-04-15T10:00:00Z',
+ endedAt: null,
+ exitCode: null
+ })
+ )
+ writeFileSync(join(sessionDir, 'scrollback.bin'), 'raced output\r\n')
+
+ historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
+ const client = (
+ historyAdapter as unknown as {
+ client: { request: (type: string, payload?: unknown) => Promise }
+ }
+ ).client
+ const originalRequest = client.request.bind(client)
+ // Why: simulates the probe→createOrAttach race — the probe sees the
+ // session alive, but it is gone by the time createOrAttach runs. The
+ // meta rewrite mimics the dying session's exit event beating the
+ // createOrAttach reply and writing endedAt via closeSession; the
+ // fallback detect must still restore instead of falling through to
+ // openSession (which would delete the checkpoint).
+ vi.spyOn(client, 'request').mockImplementation(async (type: string, payload?: unknown) => {
+ if (type === 'getSize') {
+ return { size: { cols: 100, rows: 30 } }
+ }
+ const response = await originalRequest(type, payload)
+ if (type === 'createOrAttach') {
+ writeFileSync(
+ join(sessionDir, 'meta.json'),
+ JSON.stringify({
+ cwd: '/projects/raced',
+ cols: 100,
+ rows: 30,
+ startedAt: '2026-04-15T10:00:00Z',
+ endedAt: '2026-04-15T10:05:00Z',
+ exitCode: 0
+ })
+ )
+ }
+ return response
+ })
+
+ const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
+
+ expect(result.coldRestore).toBeDefined()
+ expect(result.coldRestore!.scrollback).toContain('raced output')
+ // Documented race delta: the fresh shell spawns with the renderer's
+ // requested params, not the recovered ones.
+ expect(lastSpawnOpts).toMatchObject({ sessionId, cols: 80, rows: 24 })
+ // The recovery data must survive — openSession would have deleted it.
+ expect(existsSync(join(sessionDir, 'scrollback.bin'))).toBe(true)
+ const internals = historyAdapter as unknown as {
+ sessionsNeedingFullCheckpoint: Set
+ lastFullCheckpointAt: Map
+ }
+ expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(true)
+ expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false)
+ })
+
+ it('falls back to the full cold-restore detect when the aliveness probe fails', async () => {
+ const sessionId = 'probe-error-cold-restore'
+ const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
+ mkdirSync(sessionDir, { recursive: true })
+ writeFileSync(
+ join(sessionDir, 'meta.json'),
+ JSON.stringify({
+ cwd: '/projects/probeless',
+ cols: 132,
+ rows: 43,
+ startedAt: '2026-04-15T10:00:00Z',
+ endedAt: null,
+ exitCode: null
+ })
+ )
+ writeFileSync(join(sessionDir, 'scrollback.bin'), 'probeless output\r\n')
+
+ historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
+ const client = (
+ historyAdapter as unknown as {
+ client: { request: (type: string, payload?: unknown) => Promise }
+ }
+ ).client
+ const originalRequest = client.request.bind(client)
+ // Why: an old daemon rejects the unknown getSize method; the spawn must
+ // behave exactly like the unprobed path.
+ vi.spyOn(client, 'request').mockImplementation((type: string, payload?: unknown) => {
+ if (type === 'getSize') {
+ return Promise.reject(new Error('Unknown request type'))
+ }
+ return originalRequest(type, payload)
+ })
+
+ const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
+
+ expect(result.coldRestore).toBeDefined()
+ expect(result.coldRestore!.scrollback).toContain('probeless output')
+ expect(lastSpawnOpts).toMatchObject({
+ sessionId,
+ cwd: '/projects/probeless',
+ cols: 132,
+ rows: 43
+ })
+ })
+
it('returns same cold restore on StrictMode double-mount (sticky cache)', async () => {
const sessionId = 'sticky-cache-test'
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts
index f4d717f1999..56a775a8c99 100644
--- a/src/main/daemon/daemon-pty-adapter.ts
+++ b/src/main/daemon/daemon-pty-adapter.ts
@@ -6,7 +6,7 @@ import { existsSync } from 'node:fs'
import { DaemonClient } from './client'
import { getMacDaemonSystemResolverHealth } from './daemon-health'
import { HistoryManager } from './history-manager'
-import { HistoryReader } from './history-reader'
+import { HistoryReader, type ColdRestoreInfo } from './history-reader'
import { mintPtySessionId, parsePtySessionId } from './pty-session-id'
import { supportsPtyStartupBarrier } from './shell-ready'
import { CODEX_SHELL_READY_TIMEOUT_MS } from './session'
@@ -19,7 +19,12 @@ import {
type SessionInfo,
type TakePendingOutputResult
} from './types'
-import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
+import type {
+ IPtyProvider,
+ PtyProcessInfo,
+ PtySpawnOptions,
+ PtySpawnResult
+} from '../providers/types'
import { isShellProcess } from '../../shared/agent-detection'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
@@ -141,16 +146,30 @@ export class DaemonPtyAdapter implements IPtyProvider {
await this.replaceUnhealthyMacResolverDaemonBeforeNewPty()
}
+ await this.ensureConnected()
+
// Why: detect crash-recovery history before spawning a replacement PTY so
// the revived shell inherits the recovered cwd and dimensions instead of
// whatever the current renderer happened to request on mount.
- const restoreInfo = this.historyReader?.detectColdRestore(sessionId) ?? null
+ // Why probe aliveness first: detectColdRestore synchronously replays the
+ // full checkpoint + log (up to ~5MB) through a scratch emulator on the
+ // main process, but a live daemon session ignores spawn params and its
+ // own snapshot supersedes disk — the replay result would be discarded.
+ // getSize is a read-only probe; on error/unsupported it degrades to the
+ // full detect.
+ let restoreInfo: ColdRestoreInfo | null = null
+ let restoreSkippedForLiveSession = false
+ if (this.historyReader?.hasRestorableHistory(sessionId)) {
+ if ((await this.getAppliedSize(sessionId)) !== null) {
+ restoreSkippedForLiveSession = true
+ } else {
+ restoreInfo = this.historyReader.detectColdRestore(sessionId)
+ }
+ }
const effectiveCwd = restoreInfo?.cwd ?? opts.cwd
const effectiveCols = restoreInfo?.cols ?? opts.cols
const effectiveRows = restoreInfo?.rows ?? opts.rows
- await this.ensureConnected()
-
const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false
const isCodexStartupCommand =
recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex'
@@ -208,6 +227,19 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
}
+ // Why: the probe→createOrAttach gap is racy — the session can exit (or
+ // enter termination) in between, so the daemon spawned a fresh shell.
+ // Detect now so scrollback restore matches the unprobed path; only the
+ // new shell's cwd/dims came from the renderer request in this rare case.
+ // Why ignoreCleanEnd: the raced session's exit event (stream socket) can
+ // beat the createOrAttach reply and write endedAt via closeSession; that
+ // must not null the restore here, or the openSession branch below would
+ // delete the checkpoint instead of restoring it.
+ if (result.isNew && restoreSkippedForLiveSession) {
+ restoreInfo =
+ this.historyReader?.detectColdRestore(sessionId, { ignoreCleanEnd: true }) ?? null
+ }
+
const wasAlreadyManaged = this.activeSessionIds.has(sessionId)
this.activeSessionIds.add(sessionId)
@@ -490,7 +522,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
return { alive, killed }
}
- async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
+ async listProcesses(): Promise {
await this.ensureConnected()
const result = await this.client.request('listSessions', undefined)
return result.sessions
@@ -498,7 +530,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
.map((s) => ({
id: s.sessionId,
cwd: s.cwd ?? '',
- title: 'shell'
+ title: 'shell',
+ ...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {})
}))
}
diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts
index 2212635d1fa..1f80f770cd9 100644
--- a/src/main/daemon/daemon-pty-router.ts
+++ b/src/main/daemon/daemon-pty-router.ts
@@ -1,5 +1,10 @@
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
-import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
+import type {
+ IPtyProvider,
+ PtyProcessInfo,
+ PtySpawnOptions,
+ PtySpawnResult
+} from '../providers/types'
export class DaemonPtyRouter implements IPtyProvider {
private current: DaemonPtyAdapter
@@ -124,7 +129,7 @@ export class DaemonPtyRouter implements IPtyProvider {
await this.current.revive(state)
}
- async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
+ async listProcesses(): Promise {
// Why: runtime exact-stop/liveness flows must fail closed if any adapter
// cannot provide a trustworthy process list.
const results = await Promise.all(this.allAdapters().map((adapter) => adapter.listProcesses()))
diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts
index acf95ffcdfe..a7b8c68fa19 100644
--- a/src/main/daemon/degraded-daemon-pty-provider.ts
+++ b/src/main/daemon/degraded-daemon-pty-provider.ts
@@ -1,5 +1,10 @@
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
-import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
+import type {
+ IPtyProvider,
+ PtyProcessInfo,
+ PtySpawnOptions,
+ PtySpawnResult
+} from '../providers/types'
type ManagedPtyProvider = IPtyProvider & {
disconnectOnly?: () => Promise
@@ -135,7 +140,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
await this.fallback.revive(state)
}
- async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
+ async listProcesses(): Promise {
const results = await Promise.all(
this.allProviders().map((provider) => provider.listProcesses())
)
diff --git a/src/main/daemon/headless-emulator-unicode-width.test.ts b/src/main/daemon/headless-emulator-unicode-width.test.ts
new file mode 100644
index 00000000000..f358ce79189
--- /dev/null
+++ b/src/main/daemon/headless-emulator-unicode-width.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import { HeadlessEmulator } from './headless-emulator'
+
+// Why these oracles: agent TUIs position the cursor assuming the widths the
+// on-screen xterm uses (Unicode 11 + ZWJ joining). If this mirror measures a
+// preceding emoji differently, the positioned write lands on the wrong cell
+// and the mirrored row tears — which snapshot restores then paint back.
+describe('headless emulator unicode widths', () => {
+ it('advances emoji as two cells so positioned writes land like the renderer', async () => {
+ const emulator = new HeadlessEmulator({ cols: 40, rows: 4 })
+ // 🤖 occupies columns 1-2 under Unicode 11 (one cell under the v6 default),
+ // so "A" is at column 3, "B" at 4, and column 5 overwrites just past "B".
+ await emulator.write('\x1b[H\u{1F916}AB')
+ await emulator.write('\x1b[1;5HZ')
+ expect(emulator.getVisibleLines()[0]).toBe('\u{1F916}ABZ')
+ emulator.dispose()
+ })
+
+ it('joins ZWJ emoji into one wide pair like the renderer provider', async () => {
+ const emulator = new HeadlessEmulator({ cols: 40, rows: 4 })
+ // 👩💻 (woman + ZWJ + laptop) renders as a single two-cell glyph in CLIs;
+ // plain Unicode 11 would budget four cells and shift everything after it.
+ await emulator.write('\x1b[H\u{1F469}\u{200D}\u{1F4BB}X')
+ await emulator.write('\x1b[1;3HY')
+ expect(emulator.getVisibleLines()[0]).toBe('\u{1F469}\u{200D}\u{1F4BB}Y')
+ emulator.dispose()
+ })
+})
diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts
index e2e396170aa..f5c2bb34049 100644
--- a/src/main/daemon/headless-emulator.ts
+++ b/src/main/daemon/headless-emulator.ts
@@ -1,10 +1,13 @@
import './xterm-env-polyfill'
import { Terminal } from '@xterm/headless'
import { SerializeAddon } from '@xterm/addon-serialize'
+import { Unicode11Addon } from '@xterm/addon-unicode11'
+import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider'
import { extractLastOscTitle } from '../../shared/agent-detection'
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction'
import { parseFileUriPath } from './osc7-file-uri'
+import { TerminalPrivateModeTracker } from './terminal-private-mode-tracker'
import type { TerminalSnapshot, TerminalModes } from './types'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
@@ -12,6 +15,8 @@ export type HeadlessEmulatorOptions = {
cols: number
rows: number
scrollback?: number
+ pathFlavor?: 'posix' | 'win32'
+ remotePosixFileUriAuthority?: boolean
}
type TerminalWithSynchronousWrite = Terminal & {
@@ -22,10 +27,6 @@ type TerminalWithSynchronousWrite = Terminal & {
const DEFAULT_SCROLLBACK = 5000
const OSC_SCAN_TAIL_LIMIT = 4096
-// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
-// Keep parser state far beyond normal mode lists while still bounding memory.
-const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
-type MouseTrackingMode = NonNullable
export class HeadlessEmulator {
private terminal: Terminal
@@ -33,14 +34,15 @@ export class HeadlessEmulator {
private cwd: string | null = null
private lastTitle: string | null = null
private oscScanTail = ''
- private privateModeScanTail = ''
- private mouseTrackingMode: MouseTrackingMode = 'none'
- private sgrMouseMode = false
- private sgrMousePixelsMode = false
+ private privateModes = new TerminalPrivateModeTracker()
private restoredOscLinks: TerminalOscLinkRange[] = []
private disposed = false
+ private readonly pathFlavor?: 'posix' | 'win32'
+ private readonly remotePosixFileUriAuthority: boolean
constructor(opts: HeadlessEmulatorOptions) {
+ this.pathFlavor = opts.pathFlavor
+ this.remotePosixFileUriAuthority = opts.remotePosixFileUriAuthority === true
this.terminal = new Terminal({
cols: opts.cols,
rows: opts.rows,
@@ -52,6 +54,14 @@ export class HeadlessEmulator {
this.serializer = new SerializeAddon()
this.terminal.loadAddon(this.serializer)
+ // Why: this mirror must measure character widths exactly like the
+ // renderer's xterm (Unicode 11 + ZWJ emoji joining). With the default v6
+ // tables, emoji-dense rows (agent status lines) advance the cursor
+ // differently here than on screen, so the mirrored buffer accumulates
+ // cell-shifted tears that snapshot restores then paint back as garbage.
+ this.terminal.loadAddon(new Unicode11Addon())
+ activateOrcaTerminalUnicodeProvider(this.terminal)
+
// Why no onData wiring: this emulator exists purely for state tracking
// (snapshots, cwd, mode flags). It MUST NOT respond to terminal query
// sequences (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The emulator parses
@@ -78,7 +88,7 @@ export class HeadlessEmulator {
this.terminal.write(data, () => {
// Why: snapshots combine serialized xterm state with mirrored mouse
// modes. Commit the mirror only after xterm has parsed the same bytes.
- this.scanPrivateModes(data)
+ this.privateModes.scan(data)
resolve()
})
})
@@ -104,7 +114,7 @@ export class HeadlessEmulator {
// Why: hidden renderer restore snapshots are requested immediately after
// PTY bursts; queued headless writes can snapshot half-cleared TUI rows.
writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data)
- this.scanPrivateModes(data)
+ this.privateModes.scan(data)
return true
}
@@ -207,78 +217,6 @@ export class HeadlessEmulator {
return extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT)
}
- private scanPrivateModes(data: string): void {
- const input = this.privateModeScanTail + data
- this.privateModeScanTail = this.extractPrivateModeScanTail(input)
- // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars
- const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g
- let match: RegExpExecArray | null
- while ((match = privateModeRe.exec(input)) !== null) {
- if (match[0] === '\x1bc') {
- this.mouseTrackingMode = 'none'
- this.sgrMouseMode = false
- this.sgrMousePixelsMode = false
- continue
- }
- const params = match[1] ?? match[3]
- const enabled = (match[2] ?? match[4]) === 'h'
- for (const rawParam of params.split(';')) {
- if (rawParam === '') {
- continue
- }
- const param = Number(rawParam)
- if (!Number.isInteger(param)) {
- continue
- }
- if (param === 9) {
- this.mouseTrackingMode = enabled ? 'x10' : 'none'
- }
- if (param === 1000) {
- this.mouseTrackingMode = enabled ? 'vt200' : 'none'
- }
- if (param === 1002) {
- this.mouseTrackingMode = enabled ? 'drag' : 'none'
- }
- if (param === 1003) {
- this.mouseTrackingMode = enabled ? 'any' : 'none'
- }
- if (param === 1006) {
- this.sgrMouseMode = enabled
- this.sgrMousePixelsMode = false
- }
- if (param === 1016) {
- this.sgrMouseMode = false
- this.sgrMousePixelsMode = enabled
- }
- }
- }
- }
-
- private extractPrivateModeScanTail(input: string): string {
- const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b'))
- if (start === -1) {
- return ''
- }
- const tail = input.slice(start)
- if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) {
- return ''
- }
- if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') {
- return tail
- }
- if (tail.startsWith('\x1b[?')) {
- return this.isIncompletePrivateModeParams(tail.slice(3)) ? tail : ''
- }
- if (tail.startsWith('\x9b?')) {
- return this.isIncompletePrivateModeParams(tail.slice(2)) ? tail : ''
- }
- return ''
- }
-
- private isIncompletePrivateModeParams(params: string): boolean {
- return /^[0-9;]*$/.test(params)
- }
-
private normalizeSnapshotAnsiForModes(snapshotAnsi: string, modes: TerminalModes): string {
if (!modes.alternateScreen) {
return snapshotAnsi
@@ -295,7 +233,10 @@ export class HeadlessEmulator {
}
private parseOsc7Uri(uri: string): void {
- const parsed = parseFileUriPath(uri)
+ const parsed = parseFileUriPath(uri, {
+ pathFlavor: this.pathFlavor,
+ remotePosixAuthority: this.remotePosixFileUriAuthority
+ })
if (parsed) {
this.cwd = parsed
}
@@ -303,13 +244,13 @@ export class HeadlessEmulator {
private getModes(): TerminalModes {
const buffer = this.terminal.buffer.active
- const mouseTrackingMode = this.mouseTrackingMode
+ const mouseTrackingMode = this.privateModes.mouseTrackingMode
return {
bracketedPaste: this.terminal.modes.bracketedPasteMode,
mouseTracking: mouseTrackingMode !== 'none',
mouseTrackingMode,
- sgrMouseMode: this.sgrMouseMode,
- sgrMousePixelsMode: this.sgrMousePixelsMode,
+ sgrMouseMode: this.privateModes.sgrMouseMode,
+ sgrMousePixelsMode: this.privateModes.sgrMousePixelsMode,
applicationCursor:
buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false,
alternateScreen: buffer.type === 'alternate'
diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts
index 2914ed21aa7..f6661b50044 100644
--- a/src/main/daemon/history-reader.ts
+++ b/src/main/daemon/history-reader.ts
@@ -28,12 +28,28 @@ export class HistoryReader {
this.basePath = basePath
}
- detectColdRestore(sessionId: string): ColdRestoreInfo | null {
+ // Why: spawn needs a cheap "could this cold-restore?" predicate before
+ // deciding to pay detectColdRestore's full checkpoint+log replay. Reads only
+ // the small meta.json, using the same unclean-shutdown test detectColdRestore
+ // starts with.
+ hasRestorableHistory(sessionId: string): boolean {
+ const meta = this.readMeta(sessionId)
+ return meta !== null && meta.endedAt === null
+ }
+
+ detectColdRestore(
+ sessionId: string,
+ opts?: { ignoreCleanEnd?: boolean }
+ ): ColdRestoreInfo | null {
const meta = this.readMeta(sessionId)
if (!meta) {
return null
}
- if (meta.endedAt !== null) {
+ // Why ignoreCleanEnd: in the spawn probe race, the dying session's exit
+ // event can write endedAt between the aliveness probe and the post-spawn
+ // fallback detect. The caller established restore eligibility before the
+ // probe, so the just-written clean end must not downgrade the restore.
+ if (meta.endedAt !== null && !opts?.ignoreCleanEnd) {
return null
}
diff --git a/src/main/daemon/osc7-file-uri.test.ts b/src/main/daemon/osc7-file-uri.test.ts
new file mode 100644
index 00000000000..faa84879c55
--- /dev/null
+++ b/src/main/daemon/osc7-file-uri.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest'
+import { parseFileUriPath, parseFileUriPathParts } from './osc7-file-uri'
+
+describe('parseFileUriPath', () => {
+ it('accepts host-qualified POSIX file URI authorities as plain paths', () => {
+ expect(parseFileUriPath('file://remote-host/tmp/result.json')).toBe('/tmp/result.json')
+ })
+
+ it('accepts empty and localhost POSIX file URI authorities', () => {
+ expect(parseFileUriPath('file:///tmp/result.json')).toBe('/tmp/result.json')
+ expect(parseFileUriPath('file://localhost/tmp/result.json')).toBe('/tmp/result.json')
+ })
+
+ it('keeps remote OSC7 authorities as POSIX paths for SSH PTYs on Windows', () => {
+ const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
+ Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+ try {
+ expect(
+ parseFileUriPathParts('file://remote-host/home/me/repo', {
+ remotePosixAuthority: true
+ })
+ ).toEqual({ path: '/home/me/repo', hostname: 'remote-host' })
+ expect(parseFileUriPathParts('file://server/share/repo')).toEqual({
+ path: '\\\\server\\share\\repo',
+ hostname: 'server'
+ })
+ } finally {
+ if (originalPlatform) {
+ Object.defineProperty(process, 'platform', originalPlatform)
+ }
+ }
+ })
+
+ it('parses Windows SSH OSC7 drive paths independent of the desktop platform', () => {
+ const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
+ Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
+ try {
+ expect(parseFileUriPathParts('file:///C:/Users/me/repo', { pathFlavor: 'win32' })).toEqual({
+ path: 'C:/Users/me/repo',
+ hostname: ''
+ })
+ expect(
+ parseFileUriPathParts('file://remote-host/C:/Users/me/repo', { pathFlavor: 'win32' })
+ ).toEqual({
+ path: 'C:/Users/me/repo',
+ hostname: 'remote-host'
+ })
+ } finally {
+ if (originalPlatform) {
+ Object.defineProperty(process, 'platform', originalPlatform)
+ }
+ }
+ })
+})
diff --git a/src/main/daemon/osc7-file-uri.ts b/src/main/daemon/osc7-file-uri.ts
index 5ae2dd06d52..f931174ee03 100644
--- a/src/main/daemon/osc7-file-uri.ts
+++ b/src/main/daemon/osc7-file-uri.ts
@@ -1,4 +1,24 @@
-export function parseFileUriPath(uri: string): string | null {
+export type ParsedFileUriPath = {
+ path: string
+ hostname: string
+}
+
+export type ParseFileUriPathOptions = {
+ pathFlavor?: 'posix' | 'win32'
+ remotePosixAuthority?: boolean
+}
+
+export function parseFileUriPath(
+ uri: string,
+ options: ParseFileUriPathOptions = {}
+): string | null {
+ return parseFileUriPathParts(uri, options)?.path ?? null
+}
+
+export function parseFileUriPathParts(
+ uri: string,
+ options: ParseFileUriPathOptions = {}
+): ParsedFileUriPath | null {
try {
const url = new URL(uri)
if (url.protocol !== 'file:') {
@@ -6,19 +26,21 @@ export function parseFileUriPath(uri: string): string | null {
}
const decodedPath = decodeURIComponent(url.pathname)
- if (process.platform !== 'win32') {
- return decodedPath
+ const hostname = url.hostname.toLowerCase()
+ const pathFlavor = options.pathFlavor ?? (process.platform === 'win32' ? 'win32' : 'posix')
+ if (pathFlavor !== 'win32') {
+ return { path: decodedPath, hostname }
}
if (/^\/[A-Za-z]:/.test(decodedPath)) {
- return decodedPath.slice(1)
+ return { path: decodedPath.slice(1), hostname }
}
// Why: localhost/empty-host OSC-7 URIs are POSIX paths even when parsed by
// a Windows app; only non-local hosts describe Windows UNC shares.
- if (url.hostname && url.hostname !== 'localhost') {
- return `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}`
+ if (hostname && hostname !== 'localhost' && !options.remotePosixAuthority) {
+ return { path: `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}`, hostname }
}
- return decodedPath
+ return { path: decodedPath, hostname }
} catch {
return null
}
diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts
index 551b8ad276c..b4d3433a5f8 100644
--- a/src/main/daemon/session.ts
+++ b/src/main/daemon/session.ts
@@ -56,6 +56,7 @@ export type SessionOptions = {
sessionId: string
cols: number
rows: number
+ terminalHandle?: string
subprocess: SubprocessHandle
shellReadySupported: boolean
shellReadyTimeoutMs?: number
@@ -76,6 +77,7 @@ type AttachedClient = {
export class Session {
readonly sessionId: string
+ readonly terminalHandle: string | null
private _state: SessionState = 'running'
private _shellState: ShellReadyState
private _exitCode: number | null = null
@@ -97,6 +99,7 @@ export class Session {
constructor(opts: SessionOptions) {
this.sessionId = opts.sessionId
+ this.terminalHandle = opts.terminalHandle ?? null
this.subprocess = opts.subprocess
this.onSessionExit = opts.onExit
const size = normalizePtySize(opts.cols, opts.rows)
diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts
index 4731322e526..41e1f96b1bb 100644
--- a/src/main/daemon/terminal-host.ts
+++ b/src/main/daemon/terminal-host.ts
@@ -136,6 +136,7 @@ export class TerminalHost {
sessionId: opts.sessionId,
cols: size.cols,
rows: size.rows,
+ terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE,
subprocess,
shellReadySupported: opts.shellReadySupported ?? false,
// Why: reap the dead session (dispose emulator + drop from the map) the
@@ -301,6 +302,7 @@ export class TerminalHost {
state: session.state,
shellState: session.shellState,
isAlive: true,
+ ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
pid: session.pid,
cwd: session.getCwd(),
cols: size?.cols ?? 0,
diff --git a/src/main/daemon/terminal-private-mode-tracker.ts b/src/main/daemon/terminal-private-mode-tracker.ts
new file mode 100644
index 00000000000..a8f20ef7a4d
--- /dev/null
+++ b/src/main/daemon/terminal-private-mode-tracker.ts
@@ -0,0 +1,89 @@
+import type { TerminalModes } from './types'
+
+// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
+// Keep parser state far beyond normal mode lists while still bounding memory.
+const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
+
+type MouseTrackingMode = NonNullable
+
+// Why: @xterm/headless doesn't expose DECSET mouse/SGR encoding state, so the
+// emulator mirrors it by scanning the raw byte stream; snapshots restore these
+// modes on rehydrate.
+export class TerminalPrivateModeTracker {
+ private scanTail = ''
+ mouseTrackingMode: MouseTrackingMode = 'none'
+ sgrMouseMode = false
+ sgrMousePixelsMode = false
+
+ scan(data: string): void {
+ const input = this.scanTail + data
+ this.scanTail = this.extractScanTail(input)
+ // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars
+ const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g
+ let match: RegExpExecArray | null
+ while ((match = privateModeRe.exec(input)) !== null) {
+ if (match[0] === '\x1bc') {
+ this.mouseTrackingMode = 'none'
+ this.sgrMouseMode = false
+ this.sgrMousePixelsMode = false
+ continue
+ }
+ const params = match[1] ?? match[3]
+ const enabled = (match[2] ?? match[4]) === 'h'
+ for (const rawParam of params.split(';')) {
+ if (rawParam === '') {
+ continue
+ }
+ const param = Number(rawParam)
+ if (!Number.isInteger(param)) {
+ continue
+ }
+ if (param === 9) {
+ this.mouseTrackingMode = enabled ? 'x10' : 'none'
+ }
+ if (param === 1000) {
+ this.mouseTrackingMode = enabled ? 'vt200' : 'none'
+ }
+ if (param === 1002) {
+ this.mouseTrackingMode = enabled ? 'drag' : 'none'
+ }
+ if (param === 1003) {
+ this.mouseTrackingMode = enabled ? 'any' : 'none'
+ }
+ if (param === 1006) {
+ this.sgrMouseMode = enabled
+ this.sgrMousePixelsMode = false
+ }
+ if (param === 1016) {
+ this.sgrMouseMode = false
+ this.sgrMousePixelsMode = enabled
+ }
+ }
+ }
+ }
+
+ private extractScanTail(input: string): string {
+ const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b'))
+ if (start === -1) {
+ return ''
+ }
+ const tail = input.slice(start)
+ if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) {
+ return ''
+ }
+ if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') {
+ return tail
+ }
+ if (tail.startsWith('\x1b[?')) {
+ return this.isIncompleteParams(tail.slice(3)) ? tail : ''
+ }
+ if (tail.startsWith('\x9b?')) {
+ return this.isIncompleteParams(tail.slice(2)) ? tail : ''
+ }
+ return ''
+ }
+
+ private isIncompleteParams(params: string): boolean {
+ return /^[0-9;]*$/.test(params)
+ }
+}
diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts
index 868afa81610..a38cc21a425 100644
--- a/src/main/daemon/types.ts
+++ b/src/main/daemon/types.ts
@@ -324,6 +324,7 @@ export type SessionInfo = {
state: SessionState
shellState: ShellReadyState
isAlive: boolean
+ terminalHandle?: string
pid: number | null
cwd: string | null
cols: number
diff --git a/src/main/daemon/windows-conpty-warmup.test.ts b/src/main/daemon/windows-conpty-warmup.test.ts
new file mode 100644
index 00000000000..1ab19b0c97e
--- /dev/null
+++ b/src/main/daemon/windows-conpty-warmup.test.ts
@@ -0,0 +1,91 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type * as pty from 'node-pty'
+import { warmWindowsConptyOnce } from './windows-conpty-warmup'
+
+function setPlatform(platform: NodeJS.Platform): () => void {
+ const original = process.platform
+ Object.defineProperty(process, 'platform', { configurable: true, value: platform })
+ return () => Object.defineProperty(process, 'platform', { configurable: true, value: original })
+}
+
+function flushImmediates(): Promise {
+ return new Promise((resolve) => setImmediate(resolve))
+}
+
+let restorePlatform: (() => void) | null = null
+afterEach(() => {
+ restorePlatform?.()
+ restorePlatform = null
+ vi.restoreAllMocks()
+})
+
+function makeFakePty(): { proc: pty.IPty; fireExit: () => void } {
+ let exitListener: (() => void) | null = null
+ const proc = {
+ pid: 4321,
+ kill: vi.fn(),
+ onExit: vi.fn((listener: () => void) => {
+ exitListener = listener
+ return { dispose: () => undefined }
+ })
+ } as unknown as pty.IPty
+ return { proc, fireExit: () => exitListener?.() }
+}
+
+describe('warmWindowsConptyOnce', () => {
+ it('is a no-op off Windows', async () => {
+ restorePlatform = setPlatform('darwin')
+ const spawnPty = vi.fn() as unknown as typeof pty.spawn
+
+ warmWindowsConptyOnce(spawnPty)
+ await flushImmediates()
+
+ expect(spawnPty).not.toHaveBeenCalled()
+ })
+
+ it('spawns a short-lived cmd.exe with the bundled ConPTY on Windows', async () => {
+ restorePlatform = setPlatform('win32')
+ const { proc, fireExit } = makeFakePty()
+ const spawnPty = vi.fn(() => proc) as unknown as typeof pty.spawn
+
+ warmWindowsConptyOnce(spawnPty)
+ await flushImmediates()
+
+ expect(spawnPty).toHaveBeenCalledTimes(1)
+ const [file, args, options] = vi.mocked(spawnPty).mock.calls[0]
+ expect(String(file).toLowerCase()).toContain('cmd')
+ expect(args).toEqual(['/c', 'exit'])
+ expect(options).toMatchObject({ useConptyDll: true, cols: 2, rows: 1 })
+
+ // A clean exit must not leave the kill timer to fire later.
+ fireExit()
+ expect(proc.kill).not.toHaveBeenCalled()
+ })
+
+ it('kills the warm-up shell if it never exits', async () => {
+ restorePlatform = setPlatform('win32')
+ vi.useFakeTimers()
+ try {
+ const { proc } = makeFakePty()
+ const spawnPty = vi.fn(() => proc) as unknown as typeof pty.spawn
+
+ warmWindowsConptyOnce(spawnPty)
+ await vi.runOnlyPendingTimersAsync()
+ vi.advanceTimersByTime(10_000)
+
+ expect(proc.kill).toHaveBeenCalledTimes(1)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('swallows spawn failures', async () => {
+ restorePlatform = setPlatform('win32')
+ const spawnPty = vi.fn(() => {
+ throw new Error('conpty unavailable')
+ }) as unknown as typeof pty.spawn
+
+ expect(() => warmWindowsConptyOnce(spawnPty)).not.toThrow()
+ await flushImmediates()
+ })
+})
diff --git a/src/main/daemon/windows-conpty-warmup.ts b/src/main/daemon/windows-conpty-warmup.ts
new file mode 100644
index 00000000000..aea1c50b2db
--- /dev/null
+++ b/src/main/daemon/windows-conpty-warmup.ts
@@ -0,0 +1,45 @@
+import os from 'node:os'
+import * as pty from 'node-pty'
+
+const WARMUP_KILL_TIMEOUT_MS = 10_000
+
+/**
+ * Pays the one-time cost of the first ConPTY spawn (conpty native module
+ * load, bundled conpty.dll + OpenConsole.exe first launch, Defender scans of
+ * those binaries) at daemon boot instead of on the user's first terminal.
+ * Measured ~2.7s on a Windows dev profile for the first spawn vs ~70ms after.
+ */
+export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = pty.spawn): void {
+ if (process.platform !== 'win32') {
+ return
+ }
+ // Why: setImmediate keeps the ready/handshake path ahead of the warm-up; a
+ // real spawn arriving first simply does the warming itself.
+ setImmediate(() => {
+ try {
+ const proc = spawnPty(process.env.COMSPEC || 'cmd.exe', ['/c', 'exit'], {
+ name: 'xterm-256color',
+ cols: 2,
+ rows: 1,
+ cwd: os.homedir(),
+ env: process.env as Record,
+ // Match real terminal spawns so the bundled ConPTY binaries are the
+ // ones warmed, not the legacy system ConPTY.
+ useConptyDll: true
+ })
+ const killTimer = setTimeout(() => {
+ try {
+ proc.kill()
+ } catch {
+ /* best-effort cleanup of a stuck warm-up shell */
+ }
+ }, WARMUP_KILL_TIMEOUT_MS)
+ killTimer.unref?.()
+ proc.onExit(() => {
+ clearTimeout(killTimer)
+ })
+ } catch {
+ /* warm-up is best-effort; real spawns surface their own errors */
+ }
+ })
+}
diff --git a/src/main/emulator/backends/ios-emulator-backend.test.ts b/src/main/emulator/backends/ios-emulator-backend.test.ts
index d82e05231b2..fd7df588f19 100644
--- a/src/main/emulator/backends/ios-emulator-backend.test.ts
+++ b/src/main/emulator/backends/ios-emulator-backend.test.ts
@@ -3,6 +3,7 @@ import type { SimulatorDevice } from '../simctl-simulator-devices'
import type { ServeSimHelperProcess } from '../serve-sim-helper-processes'
const {
+ ensureSimulatorBootedMock,
execServeSimCommandMock,
hideNativeSimulatorAppMock,
killServeSimHelperProcessesForDeviceMock,
@@ -12,7 +13,8 @@ const {
sendEmulatorGestureSequenceMock,
parseServeSimDetachedSessionMock
} = vi.hoisted(() => ({
- execServeSimCommandMock: vi.fn(async () => ({})),
+ ensureSimulatorBootedMock: vi.fn(async () => {}),
+ execServeSimCommandMock: vi.fn(async (_executable?: unknown, _args?: string[]) => ({})),
hideNativeSimulatorAppMock: vi.fn(async () => {}),
killServeSimHelperProcessesForDeviceMock: vi.fn(async () => {}),
listSimulatorDevicesMock: vi.fn(async (): Promise => []),
@@ -30,7 +32,7 @@ vi.mock('../serve-sim-execution', () => ({
}))
vi.mock('../simctl-simulator-devices', () => ({
- ensureSimulatorBooted: vi.fn(async () => {}),
+ ensureSimulatorBooted: ensureSimulatorBootedMock,
listSimulatorDevices: listSimulatorDevicesMock,
resolveSimulatorUdid: vi.fn(async (device: string) => device),
shutdownSimulatorDevice: shutdownSimulatorDeviceMock
@@ -53,12 +55,15 @@ vi.mock('../serve-sim-detached-session', () => ({
parseServeSimDetachedSession: parseServeSimDetachedSessionMock
}))
+import { EmulatorError } from '../emulator-errors'
import { IosEmulatorBackend } from './ios-emulator-backend'
const EXECUTABLE = { command: '/serve-sim', env: {} }
describe('IosEmulatorBackend', () => {
beforeEach(() => {
+ ensureSimulatorBootedMock.mockReset()
+ ensureSimulatorBootedMock.mockImplementation(async () => {})
execServeSimCommandMock.mockReset()
execServeSimCommandMock.mockImplementation(async () => ({}))
listSimulatorDevicesMock.mockReset()
@@ -186,6 +191,113 @@ describe('IosEmulatorBackend', () => {
expect(hideNativeSimulatorAppMock).toHaveBeenCalledTimes(1)
})
+ it('recycles the device and retries when the helper finds no framebuffer', async () => {
+ // The real-world failure: simctl reports Booted but the display IO ports
+ // never came up, so serve-sim --detach dies with the framebuffer error.
+ execServeSimCommandMock
+ .mockRejectedValueOnce(
+ new EmulatorError(
+ 'emulator_error',
+ 'Helper failed:\n[main] Starting serve-sim-bin\n[main] Failed to start capture: No framebuffer display descriptor found'
+ )
+ )
+ .mockResolvedValueOnce({})
+ parseServeSimDetachedSessionMock.mockReturnValue({
+ deviceUdid: 'device-1',
+ streamUrl: 'http://127.0.0.1:3102/stream.mjpeg',
+ wsUrl: 'ws://127.0.0.1:3102',
+ helperPid: 1234
+ })
+ const backend = new IosEmulatorBackend({ waitForEndpointReady: async () => true })
+ const info = await backend.startSession('device-1')
+ expect(info.deviceUdid).toBe('device-1')
+ expect(shutdownSimulatorDeviceMock).toHaveBeenCalledWith('device-1')
+ // Booted once up front, again after the recycle shutdown.
+ expect(ensureSimulatorBootedMock).toHaveBeenCalledTimes(2)
+ expect(execServeSimCommandMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not recycle the device for unrelated helper start failures', async () => {
+ execServeSimCommandMock.mockRejectedValueOnce(
+ new EmulatorError('emulator_error', 'Helper failed:\n[main] Port 3100 already in use')
+ )
+ const backend = new IosEmulatorBackend({ waitForEndpointReady: async () => true })
+ await expect(backend.startSession('device-1')).rejects.toMatchObject({
+ message: expect.stringContaining('Port 3100 already in use')
+ })
+ expect(shutdownSimulatorDeviceMock).not.toHaveBeenCalled()
+ expect(execServeSimCommandMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('propagates mid-recycle failures instead of masking them', async () => {
+ execServeSimCommandMock.mockRejectedValue(
+ new EmulatorError(
+ 'emulator_error',
+ 'Helper failed:\n[main] Failed to start capture: No framebuffer display descriptor found'
+ )
+ )
+ shutdownSimulatorDeviceMock.mockRejectedValueOnce(
+ new EmulatorError('emulator_error', 'xcrun simctl shutdown timed out')
+ )
+ const backend = new IosEmulatorBackend({ waitForEndpointReady: async () => true })
+ await expect(backend.startSession('device-1')).rejects.toMatchObject({
+ message: expect.stringContaining('timed out')
+ })
+ expect(execServeSimCommandMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('surfaces an actionable error when the display stays wedged after one recycle', async () => {
+ execServeSimCommandMock.mockRejectedValue(
+ new EmulatorError(
+ 'emulator_error',
+ 'Helper failed:\n[main] Failed to start capture: No framebuffer display descriptor found'
+ )
+ )
+ const backend = new IosEmulatorBackend({ waitForEndpointReady: async () => true })
+ await expect(backend.startSession('device-1')).rejects.toMatchObject({
+ code: 'emulator_helper_failed',
+ // Actionable headline plus the raw helper log for diagnosis.
+ message: expect.stringMatching(/simctl erase[\s\S]*No framebuffer display descriptor found/)
+ })
+ // Exactly one recycle attempt; no shutdown/boot loop against a broken device.
+ expect(shutdownSimulatorDeviceMock).toHaveBeenCalledTimes(1)
+ expect(execServeSimCommandMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not recycle more than once during one start attempt', async () => {
+ let detachCalls = 0
+ execServeSimCommandMock.mockImplementation(
+ async (_executable?: unknown, args: string[] = []) => {
+ if (args[0] !== '--detach') {
+ return {}
+ }
+ detachCalls += 1
+ if (detachCalls === 2) {
+ return {}
+ }
+ throw new EmulatorError(
+ 'emulator_error',
+ 'Helper failed:\n[main] Failed to start capture: No framebuffer display descriptor found'
+ )
+ }
+ )
+ parseServeSimDetachedSessionMock.mockReturnValue({
+ deviceUdid: 'device-1',
+ streamUrl: 'http://127.0.0.1:3102/stream.mjpeg',
+ wsUrl: 'ws://127.0.0.1:3102',
+ helperPid: 1234
+ })
+ const backend = new IosEmulatorBackend({ waitForEndpointReady: async () => false })
+
+ await expect(backend.startSession('device-1')).rejects.toMatchObject({
+ code: 'emulator_helper_failed',
+ message: expect.stringContaining('even after a reboot')
+ })
+ expect(detachCalls).toBe(3)
+ expect(shutdownSimulatorDeviceMock).toHaveBeenCalledTimes(1)
+ expect(ensureSimulatorBootedMock).toHaveBeenCalledTimes(2)
+ })
+
it('stops a helper via serve-sim kill plus the orphan sweep', async () => {
const backend = new IosEmulatorBackend()
await backend.stopHelperForDevice('device-1', { helperPid: 1234, includeOrphaned: true })
diff --git a/src/main/emulator/backends/ios-emulator-backend.ts b/src/main/emulator/backends/ios-emulator-backend.ts
index 36cf6091242..8d3c31604e4 100644
--- a/src/main/emulator/backends/ios-emulator-backend.ts
+++ b/src/main/emulator/backends/ios-emulator-backend.ts
@@ -190,9 +190,43 @@ export class IosEmulatorBackend implements EmulatorBackend {
return false
}
- let info = await startDetachedHelper()
+ const throwPersistentMissingFramebuffer = (error: EmulatorError): never => {
+ throw new EmulatorError(
+ 'emulator_helper_failed',
+ `Simulator ${udid} keeps booting without a working display (no framebuffer descriptor), even after a reboot. Erase it with \`xcrun simctl erase ${udid}\` or recreate it in Xcode > Window > Devices and Simulators.\n\n${error.message}`
+ )
+ }
+
+ // Why: CoreSimulator can report "Booted" with the display IO ports down
+ // (HID alive, no framebuffer); a shutdown/boot recycle is the only recovery.
+ let didRecycleWedgedBoot = false
+ const startHelperRecyclingWedgedBoot = async (): Promise => {
+ try {
+ return await startDetachedHelper()
+ } catch (error) {
+ if (!isMissingFramebufferError(error)) {
+ throw error
+ }
+ if (didRecycleWedgedBoot) {
+ return throwPersistentMissingFramebuffer(error)
+ }
+ didRecycleWedgedBoot = true
+ await shutdownSimulatorDevice(udid)
+ await ensureSimulatorBooted(udid)
+ try {
+ return await startDetachedHelper()
+ } catch (retryError) {
+ if (!isMissingFramebufferError(retryError)) {
+ throw retryError
+ }
+ return throwPersistentMissingFramebuffer(retryError)
+ }
+ }
+ }
+
+ let info = await startHelperRecyclingWedgedBoot()
if (!(await waitForReadyOrKill(info))) {
- info = await startDetachedHelper()
+ info = await startHelperRecyclingWedgedBoot()
if (!(await waitForReadyOrKill(info))) {
throw new EmulatorError(
'emulator_helper_failed',
@@ -242,6 +276,18 @@ export class IosEmulatorBackend implements EmulatorBackend {
}
}
+// Why: serve-sim's capture helper prints exactly this when a booted device has
+// no com.apple.framebuffer.display IO port: the signature of a wedged boot.
+const MISSING_FRAMEBUFFER_RE = /No framebuffer display descriptor found/i
+
+function isMissingFramebufferError(error: unknown): error is EmulatorError {
+ return (
+ error instanceof EmulatorError &&
+ error.code === 'emulator_error' &&
+ MISSING_FRAMEBUFFER_RE.test(error.message)
+ )
+}
+
function toEmulatorDevice(device: SimulatorDevice): EmulatorDevice {
return {
backend: 'ios',
diff --git a/src/main/emulator/simctl-simulator-devices.test.ts b/src/main/emulator/simctl-simulator-devices.test.ts
index ba7c54aa1fc..deff0defbe6 100644
--- a/src/main/emulator/simctl-simulator-devices.test.ts
+++ b/src/main/emulator/simctl-simulator-devices.test.ts
@@ -22,7 +22,7 @@ vi.mock('./serve-sim-execution', () => ({
execServeSimCommand: vi.fn()
}))
-import { listSimulatorDevices } from './simctl-simulator-devices'
+import { listSimulatorDevices, shutdownSimulatorDevice } from './simctl-simulator-devices'
describe('listSimulatorDevices', () => {
beforeEach(() => {
@@ -94,3 +94,64 @@ describe('listSimulatorDevices', () => {
expect((error as Error).message).not.toContain('Command failed')
})
})
+
+describe('shutdownSimulatorDevice', () => {
+ beforeEach(() => {
+ execFileMock.mockReset()
+ platformMock.mockReset()
+ platformMock.mockReturnValue('darwin')
+ })
+
+ it('treats an already-shut-down device as success', async () => {
+ execFileMock.mockImplementation((_command, _args, _options, callback) => {
+ callback(
+ Object.assign(
+ new Error('Command failed: xcrun simctl shutdown AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'),
+ { code: 164 }
+ ),
+ '',
+ 'Unable to shutdown device in current state: Shutdown'
+ )
+ })
+
+ await expect(
+ shutdownSimulatorDevice('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE')
+ ).resolves.toBeUndefined()
+ })
+
+ it('propagates real shutdown failures instead of swallowing them', async () => {
+ // execFile's message echoes the command line, which always contains
+ // "shutdown"; a hung device (timeout kill) must still reject.
+ execFileMock.mockImplementation((_command, _args, _options, callback) => {
+ callback(
+ Object.assign(
+ new Error('Command failed: xcrun simctl shutdown AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'),
+ { killed: true, signal: 'SIGTERM' }
+ ),
+ '',
+ ''
+ )
+ })
+
+ await expect(
+ shutdownSimulatorDevice('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE')
+ ).rejects.toMatchObject({ code: 'emulator_error' })
+ })
+
+ it('rejects current-state failures that are not already shut down', async () => {
+ execFileMock.mockImplementation((_command, _args, _options, callback) => {
+ callback(
+ Object.assign(
+ new Error('Command failed: xcrun simctl shutdown AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'),
+ { code: 164 }
+ ),
+ '',
+ 'Unable to shutdown device in current state: Booting'
+ )
+ })
+
+ await expect(
+ shutdownSimulatorDevice('AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE')
+ ).rejects.toMatchObject({ code: 'emulator_error' })
+ })
+})
diff --git a/src/main/emulator/simctl-simulator-devices.ts b/src/main/emulator/simctl-simulator-devices.ts
index c9f86ee9812..6c913d45cc4 100644
--- a/src/main/emulator/simctl-simulator-devices.ts
+++ b/src/main/emulator/simctl-simulator-devices.ts
@@ -191,8 +191,10 @@ export async function shutdownSimulatorDevice(udid: string): Promise {
resolve()
return
}
- const message = error.message.toLowerCase()
- if (message.includes('shutdown') || message.includes('current state')) {
+ // Why: execFile's message echoes the command line, so only the actual
+ // already-off state is idempotent; other current states are real failures.
+ const message = `${error.message}\n${stderr?.toString() ?? ''}`.toLowerCase()
+ if (/\bcurrent state:\s*shutdown\b/.test(message)) {
resolve()
return
}
diff --git a/src/main/ipc/pty-spawn-timing.ts b/src/main/ipc/pty-spawn-timing.ts
new file mode 100644
index 00000000000..0ab0e12c800
--- /dev/null
+++ b/src/main/ipc/pty-spawn-timing.ts
@@ -0,0 +1,41 @@
+// Why: pty:spawn latency has four very different suspects (startup barrier,
+// Claude auth prep, buildPtyHostEnv filesystem work, provider/daemon spawn).
+// A single opt-in log line per spawn lets benchmarks attribute the cost
+// without a tracing dependency. Enabled via ORCA_PTY_SPAWN_TIMING=1.
+
+export type PtySpawnTiming = {
+ mark(phase: string): void
+ log(id: string, extra?: Record): void
+}
+
+const noopTiming: PtySpawnTiming = {
+ mark: () => undefined,
+ log: () => undefined
+}
+
+export function createPtySpawnTiming(): PtySpawnTiming {
+ const flag = process.env.ORCA_PTY_SPAWN_TIMING
+ if (!flag || flag === '0' || flag.toLowerCase() === 'false') {
+ return noopTiming
+ }
+ const startedAt = Date.now()
+ let lastAt = startedAt
+ const phases: string[] = []
+ return {
+ mark(phase: string): void {
+ const now = Date.now()
+ phases.push(`${phase}=${now - lastAt}ms`)
+ lastAt = now
+ },
+ log(id: string, extra?: Record): void {
+ const extras = extra
+ ? ` ${Object.entries(extra)
+ .map(([key, value]) => `${key}=${value}`)
+ .join(' ')}`
+ : ''
+ console.log(
+ `[pty-spawn-timing] id=${id} total=${Date.now() - startedAt}ms ${phases.join(' ')}${extras}`
+ )
+ }
+ }
+}
diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts
index 0afc0c3e4e0..26c1bda9157 100644
--- a/src/main/ipc/pty.test.ts
+++ b/src/main/ipc/pty.test.ts
@@ -2331,7 +2331,9 @@ describe('registerPtyHandlers', () => {
}
expect(controller.kill('remote-pty')).toBe(true)
- await Promise.resolve()
+ // Why: kill's shutdown now runs through the exit-detection wrapper,
+ // which adds async hops; a single microtask flush is no longer enough.
+ await new Promise((resolve) => setImmediate(resolve))
expect(shutdown).toHaveBeenCalledWith('remote-pty', { immediate: false })
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
@@ -2342,6 +2344,113 @@ describe('registerPtyHandlers', () => {
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1)
})
+ it('controller kill does not duplicate exits when the provider emits exit during shutdown', async () => {
+ const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
+ const shutdown = vi.fn(async (id: string) => {
+ for (const listener of exitListeners) {
+ listener({ id, code: 0 })
+ }
+ })
+ const runtime = {
+ setPtyController: vi.fn(),
+ onPtyExit: vi.fn()
+ }
+ setLocalPtyProvider({
+ spawn: vi.fn(),
+ write: vi.fn(),
+ resize: vi.fn(),
+ shutdown,
+ sendSignal: vi.fn(),
+ getCwd: vi.fn(),
+ getInitialCwd: vi.fn(),
+ clearBuffer: vi.fn(),
+ acknowledgeDataEvent: vi.fn(),
+ hasChildProcesses: vi.fn(),
+ getForegroundProcess: vi.fn(),
+ serialize: vi.fn(),
+ revive: vi.fn(),
+ onData: vi.fn(() => () => {}),
+ onReplay: vi.fn(() => () => {}),
+ onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => {
+ exitListeners.add(listener)
+ return () => exitListeners.delete(listener)
+ }),
+ listProcesses: vi.fn(async () => []),
+ attach: vi.fn(),
+ getDefaultShell: vi.fn(),
+ getProfiles: vi.fn()
+ } as never)
+ handlers.clear()
+ registerPtyHandlers(mainWindow as never, runtime as never)
+ const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
+ kill: (ptyId: string) => boolean
+ }
+
+ expect(controller.kill('local-pty')).toBe(true)
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
+ expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0)
+ expect(
+ mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')
+ ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]])
+ })
+
+ it('controller stopAndWait skips the synthetic exit when the provider emitted one', async () => {
+ vi.useFakeTimers()
+ const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
+ const shutdown = vi.fn(async (id: string) => {
+ for (const listener of exitListeners) {
+ listener({ id, code: 0 })
+ }
+ })
+ const runtime = {
+ setPtyController: vi.fn(),
+ onPtyExit: vi.fn()
+ }
+ setLocalPtyProvider({
+ spawn: vi.fn(),
+ write: vi.fn(),
+ resize: vi.fn(),
+ shutdown,
+ sendSignal: vi.fn(),
+ getCwd: vi.fn(),
+ getInitialCwd: vi.fn(),
+ clearBuffer: vi.fn(),
+ acknowledgeDataEvent: vi.fn(),
+ hasChildProcesses: vi.fn(),
+ getForegroundProcess: vi.fn(),
+ serialize: vi.fn(),
+ revive: vi.fn(),
+ onData: vi.fn(() => () => {}),
+ onReplay: vi.fn(() => () => {}),
+ onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => {
+ exitListeners.add(listener)
+ return () => exitListeners.delete(listener)
+ }),
+ listProcesses: vi.fn(async () => []),
+ attach: vi.fn(),
+ getDefaultShell: vi.fn(),
+ getProfiles: vi.fn()
+ } as never)
+ handlers.clear()
+ registerPtyHandlers(mainWindow as never, runtime as never)
+ const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
+ stopAndWait: (ptyId: string, opts?: { keepHistory?: boolean }) => Promise
+ }
+
+ const stopPromise = controller.stopAndWait('local-pty')
+ await vi.advanceTimersByTimeAsync(1_200)
+ await expect(stopPromise).resolves.toBe(true)
+
+ expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
+ expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0)
+ expect(
+ mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')
+ ).toEqual([['pty:exit', { id: 'local-pty', code: 0 }]])
+ })
+
it('passes keepHistory through runtime controller stopAndWait', async () => {
vi.useFakeTimers()
const shutdown = vi.fn(async () => undefined)
@@ -2566,7 +2675,7 @@ describe('registerPtyHandlers', () => {
}
expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(true)
- await Promise.resolve()
+ await new Promise((resolve) => setImmediate(resolve))
expect(shutdown).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', { immediate: false })
expect(localShutdown).not.toHaveBeenCalled()
@@ -2701,8 +2810,7 @@ describe('registerPtyHandlers', () => {
try {
expect(controller.kill('remote-pty')).toBe(true)
- await Promise.resolve()
- await Promise.resolve()
+ await new Promise((resolve) => setImmediate(resolve))
} finally {
warnSpy.mockRestore()
deletePtyOwnership('remote-pty')
@@ -2911,6 +3019,104 @@ describe('registerPtyHandlers', () => {
keepHistory: true
})
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1)
+ expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', {
+ id: 'local-pty',
+ code: -1
+ })
+ })
+
+ it('does not synthesize a duplicate renderer exit when kill emits provider exit', async () => {
+ const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
+ const shutdown = vi.fn(async (id: string) => {
+ for (const listener of exitListeners) {
+ listener({ id, code: 0 })
+ }
+ })
+ const runtime = {
+ setPtyController: vi.fn(),
+ onPtyExit: vi.fn()
+ }
+ setLocalPtyProvider({
+ spawn: vi.fn(),
+ write: vi.fn(),
+ resize: vi.fn(),
+ shutdown,
+ sendSignal: vi.fn(),
+ getCwd: vi.fn(),
+ getInitialCwd: vi.fn(),
+ clearBuffer: vi.fn(),
+ acknowledgeDataEvent: vi.fn(),
+ hasChildProcesses: vi.fn(),
+ getForegroundProcess: vi.fn(),
+ serialize: vi.fn(),
+ revive: vi.fn(),
+ onData: vi.fn(() => () => {}),
+ onReplay: vi.fn(() => () => {}),
+ onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => {
+ exitListeners.add(listener)
+ return () => exitListeners.delete(listener)
+ }),
+ listProcesses: vi.fn(async () => []),
+ attach: vi.fn(),
+ getDefaultShell: vi.fn(),
+ getProfiles: vi.fn()
+ } as never)
+ handlers.clear()
+ registerPtyHandlers(mainWindow as never, runtime as never)
+
+ await handlers.get('pty:kill')!(null, { id: 'local-pty' })
+
+ expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
+ expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', 0)
+ expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual(
+ [['pty:exit', { id: 'local-pty', code: 0 }]]
+ )
+ })
+
+ it('ignores a late provider exit after synthesizing kill exit', async () => {
+ const exitListeners = new Set<(payload: { id: string; code: number }) => void>()
+ const runtime = {
+ setPtyController: vi.fn(),
+ onPtyExit: vi.fn()
+ }
+ setLocalPtyProvider({
+ spawn: vi.fn(),
+ write: vi.fn(),
+ resize: vi.fn(),
+ shutdown: vi.fn(async () => undefined),
+ sendSignal: vi.fn(),
+ getCwd: vi.fn(),
+ getInitialCwd: vi.fn(),
+ clearBuffer: vi.fn(),
+ acknowledgeDataEvent: vi.fn(),
+ hasChildProcesses: vi.fn(),
+ getForegroundProcess: vi.fn(),
+ serialize: vi.fn(),
+ revive: vi.fn(),
+ onData: vi.fn(() => () => {}),
+ onReplay: vi.fn(() => () => {}),
+ onExit: vi.fn((listener: (payload: { id: string; code: number }) => void) => {
+ exitListeners.add(listener)
+ return () => exitListeners.delete(listener)
+ }),
+ listProcesses: vi.fn(async () => []),
+ attach: vi.fn(),
+ getDefaultShell: vi.fn(),
+ getProfiles: vi.fn()
+ } as never)
+ handlers.clear()
+ registerPtyHandlers(mainWindow as never, runtime as never)
+
+ await handlers.get('pty:kill')!(null, { id: 'local-pty' })
+ for (const listener of exitListeners) {
+ listener({ id: 'local-pty', code: 0 })
+ }
+
+ expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)
+ expect(runtime.onPtyExit).toHaveBeenCalledWith('local-pty', -1)
+ expect(mainWindow.webContents.send.mock.calls.filter((call) => call[0] === 'pty:exit')).toEqual(
+ [['pty:exit', { id: 'local-pty', code: -1 }]]
+ )
})
it('waits for the desktop startup barrier before renderer local spawns resolve the provider', async () => {
@@ -3623,6 +3829,62 @@ describe('registerPtyHandlers', () => {
const reported = await handlers.get('pty:getSize')!(null, { id })
expect(reported).toEqual({ cols: 100, rows: 30 })
})
+
+ it('fans out accepted desktop resizes to the runtime after provider resize', async () => {
+ const resize = vi.fn()
+ setupProviderWithAppliedSize({ applied: { cols: 120, rows: 30 }, resize })
+ const runtime = {
+ setPtyController: vi.fn(),
+ createPreAllocatedTerminalHandle: vi.fn(() => null),
+ registerPty: vi.fn(),
+ getDriver: vi.fn(() => ({ kind: 'host' })),
+ isResizeSuppressed: vi.fn(() => false),
+ onPtySpawned: vi.fn(),
+ onPtyExit: vi.fn(),
+ onPtyData: vi.fn(),
+ onExternalPtyResize: vi.fn()
+ }
+ handlers.clear()
+ registerPtyHandlers(mainWindow as never, runtime as never)
+ const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} })
+ const id = (spawn as { id: string }).id
+
+ resizeListener()(mainWindowIpcEvent, { id, cols: 120, rows: 30 })
+
+ expect(resize).toHaveBeenCalledWith(id, 120, 30)
+ expect(runtime.onExternalPtyResize).toHaveBeenCalledWith(id, 120, 30)
+ expect(resize.mock.invocationCallOrder[0]).toBeLessThan(
+ runtime.onExternalPtyResize.mock.invocationCallOrder[0]!
+ )
+ })
+
+ it('does not fan out rejected desktop resizes to the runtime', async () => {
+ setupProviderWithAppliedSize({
+ applied: { cols: 80, rows: 24 },
+ resize: () => {
+ throw new Error('resize rejected')
+ }
+ })
+ const runtime = {
+ setPtyController: vi.fn(),
+ createPreAllocatedTerminalHandle: vi.fn(() => null),
+ registerPty: vi.fn(),
+ getDriver: vi.fn(() => ({ kind: 'host' })),
+ isResizeSuppressed: vi.fn(() => false),
+ onPtySpawned: vi.fn(),
+ onPtyExit: vi.fn(),
+ onPtyData: vi.fn(),
+ onExternalPtyResize: vi.fn()
+ }
+ handlers.clear()
+ registerPtyHandlers(mainWindow as never, runtime as never)
+ const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} })
+ const id = (spawn as { id: string }).id
+
+ resizeListener()(mainWindowIpcEvent, { id, cols: 120, rows: 30 })
+
+ expect(runtime.onExternalPtyResize).not.toHaveBeenCalled()
+ })
})
it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => {
@@ -4108,7 +4370,8 @@ describe('registerPtyHandlers', () => {
worktreeId: 'wt-1',
tabId: 'tab-race',
leafId,
- ptyId: 'pty-shared'
+ ptyId: 'pty-shared',
+ startupCwd: '/tmp'
})
})
@@ -4221,7 +4484,8 @@ describe('registerPtyHandlers', () => {
worktreeId: 'wt-1',
tabId: 'tab-race',
leafId,
- ptyId: 'pty-renderer'
+ ptyId: 'pty-renderer',
+ startupCwd: '/tmp'
})
})
diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts
index 9f654ea1e1f..42806caac11 100644
--- a/src/main/ipc/pty.ts
+++ b/src/main/ipc/pty.ts
@@ -39,6 +39,7 @@ import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import { SSH_SESSION_EXPIRED_ERROR, isSshPtyNotFoundError } from '../providers/ssh-pty-provider'
import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
+import { createPtySpawnTiming } from './pty-spawn-timing'
import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id'
import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
@@ -76,6 +77,7 @@ import {
parseLegacyNumericPaneKey,
parsePaneKey
} from '../../shared/stable-pane-id'
+import { resolveTerminalStartupCwdForWorkspace } from '../../shared/terminal-startup-cwd'
import {
clearMigrationUnsupportedPty,
clearMigrationUnsupportedPtysForPaneKey
@@ -111,6 +113,7 @@ type FreshLocalFallbackProvider = IPtyProvider & {
routesFreshSpawnsToLocalProvider?: true
}
const sshProviders = new Map()
+const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000
// Why: PTY IDs are assigned at spawn time with a connectionId, but subsequent
// write/resize/kill calls only carry the PTY ID. This map lets us route
// post-spawn operations to the correct provider without the renderer needing
@@ -1656,6 +1659,82 @@ export function registerPtyHandlers(
flushTimer = null
}
+ const syntheticKillExitPtyIds = new Map()
+
+ function rememberSyntheticKillExit(id: string): void {
+ const existing = syntheticKillExitPtyIds.get(id)
+ if (existing) {
+ clearTimeout(existing)
+ }
+ // Why: some providers can report the real exit after kill has already
+ // completed; skip only that late duplicate, not a future reused id forever.
+ const cleanupTimer = setTimeout(() => {
+ syntheticKillExitPtyIds.delete(id)
+ }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS)
+ cleanupTimer.unref?.()
+ syntheticKillExitPtyIds.set(id, cleanupTimer)
+ }
+
+ function consumeSyntheticKillExit(id: string): boolean {
+ const cleanupTimer = syntheticKillExitPtyIds.get(id)
+ if (!cleanupTimer) {
+ return false
+ }
+ clearTimeout(cleanupTimer)
+ syntheticKillExitPtyIds.delete(id)
+ return true
+ }
+
+ function sendPtyExitToRenderer(payload: { id: string; code: number }): void {
+ if (mainWindow.isDestroyed()) {
+ return
+ }
+ // Why: flush any batched data for this PTY before sending the exit event,
+ // otherwise the last <=8ms of output is silently lost because the renderer
+ // tears down the terminal on pty:exit before the batch timer fires.
+ const remaining = pendingData.get(payload.id)
+ if (remaining) {
+ sendPtyDataToRenderer(
+ payload.id,
+ makePtyDataPayload(
+ payload.id,
+ remaining.data,
+ remaining.startSeq,
+ remaining.containsBackgroundOutput
+ )
+ )
+ pendingData.delete(payload.id)
+ }
+ lastInputAtByPty.delete(payload.id)
+ interactiveOutputCharsByPty.delete(payload.id)
+ rendererInFlightTotalChars = Math.max(
+ 0,
+ rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0)
+ )
+ rendererInFlightCharsByPty.delete(payload.id)
+ recordPtyRendererDeliveryPressure()
+ mainWindow.webContents.send('pty:exit', payload)
+ }
+
+ async function shutdownProviderAndDetectExit(
+ provider: IPtyProvider,
+ id: string,
+ opts: { immediate?: boolean; keepHistory?: boolean }
+ ): Promise {
+ let providerExitObserved = false
+ const unsubscribe = provider.onExit((payload) => {
+ if (payload.id === id) {
+ providerExitObserved = true
+ }
+ })
+ try {
+ await provider.shutdown(id, opts)
+ } finally {
+ unsubscribe()
+ }
+ return providerExitObserved
+ }
+
// Why: extracted so the "Restart daemon" flow can rebind against the fresh
// adapter after replaceDaemonProvider runs. Both the startup registration
// and the post-restart rebind go through the same code path — no risk of
@@ -1745,39 +1824,16 @@ export function registerPtyHandlers(
}
})
localExitUnsub = localProvider.onExit((payload) => {
+ if (consumeSyntheticKillExit(payload.id)) {
+ return
+ }
if (!isLocalProvider) {
clearProviderPtyState(payload.id)
ptyOwnership.delete(payload.id)
markClaudePtyExited(payload.id)
runtime?.onPtyExit(payload.id, payload.code)
}
- if (!mainWindow.isDestroyed()) {
- // Why: flush any batched data for this PTY before sending the exit event,
- // otherwise the last ≤8ms of output is silently lost because the renderer
- // tears down the terminal on pty:exit before the batch timer fires.
- const remaining = pendingData.get(payload.id)
- if (remaining) {
- sendPtyDataToRenderer(
- payload.id,
- makePtyDataPayload(
- payload.id,
- remaining.data,
- remaining.startSeq,
- remaining.containsBackgroundOutput
- )
- )
- pendingData.delete(payload.id)
- }
- lastInputAtByPty.delete(payload.id)
- interactiveOutputCharsByPty.delete(payload.id)
- rendererInFlightTotalChars = Math.max(
- 0,
- rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0)
- )
- rendererInFlightCharsByPty.delete(payload.id)
- recordPtyRendererDeliveryPressure()
- mainWindow.webContents.send('pty:exit', payload)
- }
+ sendPtyExitToRenderer(payload)
})
}
@@ -1905,6 +1961,17 @@ export function registerPtyHandlers(
assertFolderWorkspacePathUsable(status)
}
+ const resolveGuardedPtySpawnCwd = (
+ worktreeId: string | undefined,
+ cwd: string | undefined
+ ): string | undefined =>
+ resolveTerminalStartupCwdForWorkspace({
+ workspaceId: worktreeId,
+ requestedCwd: cwd,
+ resolveFolderWorkspacePath: (folderWorkspaceId) =>
+ store?.getFolderWorkspace(folderWorkspaceId)?.folderPath
+ })
+
// Why: the runtime controller must route through getProviderForPty() so that
// CLI commands (terminal.send, terminal.stop) work for both local and remote PTYs.
// Hardcoding localProvider.getPtyProcess() would silently fail for remote PTYs.
@@ -1915,6 +1982,7 @@ export function registerPtyHandlers(
await startupPromise
}
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
+ const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd)
const provider = getProvider(args.connectionId)
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
@@ -1934,7 +2002,7 @@ export function registerPtyHandlers(
const daemonShellOverride = terminalRuntimeOptions.shellOverride
const codexSelectionTarget = getCodexSelectionTargetForPty(
daemonShellOverride,
- args.cwd,
+ cwd,
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
)
const claudeAuth =
@@ -2002,7 +2070,7 @@ export function registerPtyHandlers(
: null
const skipCodexHomeEnv =
isDaemonHostSpawn &&
- shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, args.cwd) &&
+ shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) &&
!selectedCodexHomePath
if (isDaemonHostSpawn && sessionId) {
if (!isSafePtySessionId(sessionId, app.getPath('userData'))) {
@@ -2016,7 +2084,7 @@ export function registerPtyHandlers(
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
launchCommand: args.command,
shellPath: daemonShellOverride ?? process.env.COMSPEC,
- isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, args.cwd),
+ isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd),
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
})
@@ -2029,7 +2097,7 @@ export function registerPtyHandlers(
const spawnOptions: PtySpawnOptions = {
cols: args.cols,
rows: args.rows,
- cwd: args.cwd,
+ cwd,
env,
...(isMintedSessionId ? { isNewSession: true } : {})
}
@@ -2149,7 +2217,8 @@ export function registerPtyHandlers(
worktreeId: hostSessionBinding.worktreeId,
tabId: hostSessionBinding.tabId,
leafId: hostSessionBinding.leafId,
- ptyId: result.id
+ ptyId: result.id,
+ ...(cwd ? { startupCwd: cwd } : {})
})
} catch (err) {
console.error('[pty] failed to persist runtime PTY binding after spawn:', err)
@@ -2240,6 +2309,8 @@ export function registerPtyHandlers(
// not revive a terminal the user explicitly closed.
finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1)
+ rememberSyntheticKillExit(ptyId)
+ sendPtyExitToRenderer({ id: ptyId, code: -1 })
return true
}
return false
@@ -2247,16 +2318,25 @@ export function registerPtyHandlers(
// Why: shutdown() is async but the PtyController interface is sync. Defer
// cleanup until shutdown resolves so transient SSH/daemon failures don't
// hide a still-running remote process or local daemon session.
- void provider
- .shutdown(ptyId, { immediate: false })
- .then(() => {
+ //
+ // Same synthetic-exit contract as the renderer pty:kill handler: when the
+ // provider emitted its own exit during shutdown, the exit listener already
+ // delivered runtime + renderer exits — synthesizing again would double-fire.
+ void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false })
+ .then((providerExitObserved) => {
finishPtyShutdown(ptyId, connectionId, store)
- runtime?.onPtyExit(ptyId, -1)
+ if (!providerExitObserved) {
+ runtime?.onPtyExit(ptyId, -1)
+ rememberSyntheticKillExit(ptyId)
+ sendPtyExitToRenderer({ id: ptyId, code: -1 })
+ }
})
.catch((err) => {
if (isPtyAlreadyGoneError(err)) {
finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1)
+ rememberSyntheticKillExit(ptyId)
+ sendPtyExitToRenderer({ id: ptyId, code: -1 })
return
}
console.warn(
@@ -2283,12 +2363,15 @@ export function registerPtyHandlers(
// await, but the relay lease must still be tombstoned.
finishPtyShutdown(ptyId, connectionId, store)
runtime?.onPtyExit(ptyId, -1)
+ rememberSyntheticKillExit(ptyId)
+ sendPtyExitToRenderer({ id: ptyId, code: -1 })
return true
}
return false
}
+ let providerExitObserved = false
try {
- await provider.shutdown(ptyId, {
+ providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, {
immediate: true,
keepHistory: opts?.keepHistory ?? false
})
@@ -2313,7 +2396,11 @@ export function registerPtyHandlers(
return false
}
finishPtyShutdown(ptyId, connectionId, store)
- runtime?.onPtyExit(ptyId, -1)
+ if (!providerExitObserved) {
+ runtime?.onPtyExit(ptyId, -1)
+ rememberSyntheticKillExit(ptyId)
+ sendPtyExitToRenderer({ id: ptyId, code: -1 })
+ }
return true
},
getForegroundProcess: async (ptyId) => {
@@ -2323,6 +2410,14 @@ export function registerPtyHandlers(
return null
}
},
+ getCwd: async (ptyId) => {
+ try {
+ const cwd = await getProviderForPty(ptyId).getCwd(ptyId)
+ return cwd || null
+ } catch {
+ return null
+ }
+ },
hasChildProcesses: async (ptyId) => {
try {
return await getProviderForPty(ptyId).hasChildProcesses(ptyId)
@@ -2461,11 +2556,14 @@ export function registerPtyHandlers(
}
}
) => {
+ const spawnTiming = createPtySpawnTiming()
const startupPromise = getLocalPtyStartupPromise(args.connectionId)
if (startupPromise) {
await startupPromise
}
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
+ const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd)
+ spawnTiming.mark('preflight')
const provider = getProvider(args.connectionId)
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
@@ -2483,11 +2581,12 @@ export function registerPtyHandlers(
const initialShellOverride = terminalRuntimeOptions.shellOverride
const initialSelectionTarget = getCodexSelectionTargetForPty(
initialShellOverride,
- args.cwd,
+ cwd,
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
)
const claudeAuth =
isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null
+ spawnTiming.mark('auth')
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
}
@@ -2653,7 +2752,7 @@ export function registerPtyHandlers(
const effectiveShellOverride = terminalRuntimeOptions.shellOverride
const codexSelectionTarget = getCodexSelectionTargetForPty(
effectiveShellOverride,
- args.cwd,
+ cwd,
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
)
const selectedCodexHomePath = isDaemonHostSpawn
@@ -2664,7 +2763,7 @@ export function registerPtyHandlers(
: null
const skipCodexHomeEnv =
isDaemonHostSpawn &&
- shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, args.cwd) &&
+ shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) &&
!selectedCodexHomePath
if (isDaemonHostSpawn) {
if (effectiveSessionId === undefined) {
@@ -2693,7 +2792,7 @@ export function registerPtyHandlers(
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
launchCommand: args.command,
shellPath: effectiveShellOverride ?? process.env.COMSPEC,
- isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, args.cwd),
+ isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd),
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
})
@@ -2713,6 +2812,7 @@ export function registerPtyHandlers(
throw err
}
}
+ spawnTiming.mark('host_env')
const spawnEnv = preAllocatedHandle
? { ...env, ORCA_TERMINAL_HANDLE: preAllocatedHandle }
: env
@@ -2734,7 +2834,7 @@ export function registerPtyHandlers(
const spawnOptions: PtySpawnOptions = {
cols: args.cols,
rows: args.rows,
- cwd: args.cwd,
+ cwd,
env: spawnEnv,
...(isMintedSessionId ? { isNewSession: true } : {})
}
@@ -2807,7 +2907,9 @@ export function registerPtyHandlers(
startupTerminalColorQueryReplyColors
)
}
+ spawnTiming.mark('options')
result = await provider.spawn(spawnOptions)
+ spawnTiming.mark('provider_spawn')
} catch (err) {
const rawMessage = err instanceof Error ? err.message : String(err)
const spawnError = normalizeNodePtySpawnError(err)
@@ -2868,6 +2970,10 @@ export function registerPtyHandlers(
trustedTerminalHandleEnv.delete(preAllocatedHandle)
}
}
+ spawnTiming.log(result.id, {
+ daemon: isDaemonHostSpawn,
+ reattach: result.isReattach ?? false
+ })
ptyOwnership.set(result.id, args.connectionId ?? null)
if (startupTerminalColorQueryReplyColors) {
if (result.isReattach) {
@@ -2920,7 +3026,8 @@ export function registerPtyHandlers(
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: validatedLeafId,
- ptyId: result.id
+ ptyId: result.id,
+ ...(cwd ? { startupCwd: cwd } : {})
})
} catch (err) {
console.error('[pty] failed to persist PTY binding after spawn:', err)
@@ -3396,10 +3503,14 @@ export function registerPtyHandlers(
// before ownership is rebuilt. Tombstone instead of falling back local.
finishPtyShutdown(args.id, connectionId, store)
runtime?.onPtyExit(args.id, -1)
+ rememberSyntheticKillExit(args.id)
+ sendPtyExitToRenderer({ id: args.id, code: -1 })
return
}
+ const shutdownProvider = provider ?? getProviderForPty(args.id)
+ let providerExitObserved = false
try {
- await (provider ?? getProviderForPty(args.id)).shutdown(args.id, {
+ providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, {
immediate: true,
keepHistory: args.keepHistory ?? false
})
@@ -3412,11 +3523,14 @@ export function registerPtyHandlers(
}
/* session already dead — cleanup below handles the rest */
}
- // Why: onExit clears provider state for LocalPtyProvider, but remote SSH
- // and daemon shutdown paths do not emit onExit through the local provider's
- // listener. Explicit cleanup is idempotent and covers already-dead PTYs.
+ // Why: some shutdown paths do not emit onExit through the provider listener.
+ // Explicit cleanup is idempotent and covers already-dead PTYs.
finishPtyShutdown(args.id, connectionId, store)
- runtime?.onPtyExit(args.id, -1)
+ if (!providerExitObserved) {
+ runtime?.onPtyExit(args.id, -1)
+ rememberSyntheticKillExit(args.id)
+ sendPtyExitToRenderer({ id: args.id, code: -1 })
+ }
})
ipcMain.handle(
diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts
index 890449f51b6..c7ff8f45c82 100644
--- a/src/main/ipc/worktrees.ts
+++ b/src/main/ipc/worktrees.ts
@@ -1402,13 +1402,9 @@ export function registerWorktreeHandlers(
if (!registeredWorktree) {
const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null
let canCleanOrphanedDirectory = false
- const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo)
if (
canCleanupUnregisteredOrcaWorktreeDirectory({
- meta: removedMeta,
- worktreePath,
- repo,
- knownOrcaLayouts
+ meta: removedMeta
})
) {
if (repo.connectionId) {
@@ -1481,7 +1477,6 @@ export function registerWorktreeHandlers(
runtimeWorktreePath,
repo,
runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions),
- knownOrcaLayouts,
registeredWorktrees,
statPath: access.statPath,
isGitRepository: (path) => isLocalGitRepository(path, localWorktreeGitOptions)
diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts
index b1856b1a8c4..2e609b20558 100644
--- a/src/main/persistence.test.ts
+++ b/src/main/persistence.test.ts
@@ -2828,6 +2828,24 @@ describe('Store', () => {
expect(store.getSettings().terminalUseSeparateLightTheme).toBe(false)
})
+ it('round-trips selected terminal theme names across reload', async () => {
+ const store = await createStore()
+
+ store.updateSettings({
+ terminalThemeDark: 'One Light',
+ terminalThemeLight: 'GitHub Light'
+ })
+ store.flush()
+
+ const persisted = readDataFile() as PersistedState
+ expect(persisted.settings.terminalThemeDark).toBe('One Light')
+ expect(persisted.settings.terminalThemeLight).toBe('GitHub Light')
+
+ const reopened = await createStore()
+ expect(reopened.getSettings().terminalThemeDark).toBe('One Light')
+ expect(reopened.getSettings().terminalThemeLight).toBe('GitHub Light')
+ })
+
// ── 5. addRepo and getRepo ──────────────────────────────────────────
it('addRepo stores a repo retrievable by getRepo', async () => {
@@ -4584,6 +4602,135 @@ describe('Store', () => {
expect(statSync(dataFile()).ino).toBe(inoBefore)
})
+ // ── worktreeMeta startup GC ────────────────────────────────────────
+
+ it('garbage-collects stale local worktreeMeta at load with a 30-day grace', async () => {
+ const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000
+ const RECENT = Date.now() - 1 * 24 * 60 * 60 * 1000
+ const missing = (name: string): string => join(testState.dir, 'gone', name)
+ const meta = (lastActivityAt: number, extra: Record = {}) => ({
+ displayName: '',
+ comment: '',
+ lastActivityAt,
+ ...extra
+ })
+ const liveKey = `r1::${testState.dir}`
+ const deadKey = `r1::${missing('dead')}`
+ const recentKey = `r1::${missing('recent')}`
+ const sshKey = `ssh-repo::/home/alice/gone`
+ const remoteHostKey = `r1::${missing('remote-host')}`
+ const orphanKey = `removed-repo::${missing('orphan')}`
+ const wslKey = `r1::\\\\wsl$\\Ubuntu\\home\\gone`
+
+ writeDataFile({
+ repos: [
+ makeRepo(),
+ makeRepo({ id: 'ssh-repo', path: '/home/alice/repo', connectionId: 'conn-1' })
+ ],
+ worktreeMeta: {
+ [liveKey]: meta(OLD),
+ [deadKey]: meta(OLD),
+ [recentKey]: meta(RECENT),
+ [sshKey]: meta(OLD),
+ [remoteHostKey]: meta(OLD, { hostId: 'ssh:conn-1' }),
+ [orphanKey]: meta(OLD),
+ [wslKey]: meta(OLD)
+ },
+ worktreeLineageById: { [deadKey]: { parentWorktreeId: liveKey } }
+ })
+
+ const store = await createStore()
+ const kept = Object.keys(store.getAllWorktreeMeta())
+
+ expect(kept).toContain(liveKey) // path exists
+ expect(kept).toContain(recentKey) // inside the grace window
+ expect(kept).toContain(sshKey) // SSH repo: remote paths never checked locally
+ expect(kept).toContain(remoteHostKey) // remote hostId on the meta itself
+ expect(kept).toContain(wslKey) // WSL UNC path
+ expect(kept).not.toContain(deadKey)
+ expect(kept).not.toContain(orphanKey)
+ expect(store.getWorktreeLineage(deadKey)).toBeUndefined()
+ })
+
+ it('never GCs folder-workspace instance metas — the meta IS the workspace', async () => {
+ const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000
+ const folderInstanceKey = `r1::${join(testState.dir, 'gone-folder')}::workspace:11111111-1111-4111-8111-111111111111`
+ writeDataFile({
+ repos: [makeRepo({ kind: 'folder' })],
+ worktreeMeta: {
+ [folderInstanceKey]: { displayName: 'Session A', comment: '', lastActivityAt: OLD }
+ }
+ })
+
+ const store = await createStore()
+ expect(Object.keys(store.getAllWorktreeMeta())).toContain(folderInstanceKey)
+ })
+
+ it('never GCs Linux-style WSL worktree paths on Windows', async () => {
+ const OLD = Date.now() - 40 * 24 * 60 * 60 * 1000
+ const wslLinkedKey = 'r1::/home/user/gone-worktree'
+ writeDataFile({
+ repos: [makeRepo()],
+ worktreeMeta: {
+ [wslLinkedKey]: { displayName: '', comment: '', lastActivityAt: OLD }
+ }
+ })
+
+ await withPlatform('win32', async () => {
+ const store = await createStore()
+ expect(Object.keys(store.getAllWorktreeMeta())).toContain(wslLinkedKey)
+ })
+ })
+
+ it('tolerates a null worktreeMeta map in the durable file', async () => {
+ writeDataFile({ worktreeMeta: null })
+ const store = await createStore()
+ expect(store.getAllWorktreeMeta()).toEqual({})
+ })
+
+ // ── GitHub cache sidecar ───────────────────────────────────────────
+
+ it('cache refreshes never rewrite the durable state file', async () => {
+ vi.useFakeTimers()
+ try {
+ const store = await createStore()
+ store.updateUI({ sidebarWidth: 411 })
+ vi.advanceTimersByTime(1000)
+ await store.waitForPendingWrite()
+ const inoBefore = statSync(dataFile()).ino
+ expect((readDataFile() as { githubCache?: unknown }).githubCache).toBeUndefined()
+
+ store.setGitHubCache({ pr: { 'o/r#1': { fetchedAt: 123 } as never }, issue: {} })
+ vi.advanceTimersByTime(6000)
+ await store.waitForPendingWrite()
+
+ expect(statSync(dataFile()).ino).toBe(inoBefore)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('snapshots the cache at flush and seeds the next Store from the sidecar', async () => {
+ const store = await createStore()
+ store.setGitHubCache({ pr: { 'o/r#7': { fetchedAt: 7 } as never }, issue: {} })
+ store.flush()
+ expect(existsSync(join(testState.dir, 'orca-github-cache.json'))).toBe(true)
+
+ const restarted = await createStore()
+ expect(restarted.getGitHubCache().pr['o/r#7']).toEqual({ fetchedAt: 7 })
+ })
+
+ it('keeps a legacy in-file cache as the seed and strips it from disk', async () => {
+ writeDataFile({ githubCache: { pr: { legacy: { fetchedAt: 1 } }, issue: {} } })
+
+ const store = await createStore()
+ expect(store.getGitHubCache().pr.legacy).toEqual({ fetchedAt: 1 })
+
+ // The legacy key marks the state dirty at load; the next write drops it.
+ store.flush()
+ expect((readDataFile() as { githubCache?: unknown }).githubCache).toBeUndefined()
+ })
+
// ── UI state ───────────────────────────────────────────────────────
it('updateUI merges partial updates', async () => {
diff --git a/src/main/persistence.ts b/src/main/persistence.ts
index 5d9ef610f48..9ac1fcc4220 100644
--- a/src/main/persistence.ts
+++ b/src/main/persistence.ts
@@ -112,6 +112,7 @@ import {
type ExecutionHostId
} from '../shared/execution-host'
import { toRelaySshPtyId } from './providers/ssh-pty-id'
+import { isWslUncPath } from '../shared/wsl-paths'
import {
isTerminalLeafId,
makePaneKey,
@@ -125,9 +126,14 @@ import {
import { agentHookServer } from './agent-hooks/server'
import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers'
import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history'
-import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../shared/worktree-id'
+import {
+ FOLDER_WORKSPACE_INSTANCE_SEPARATOR,
+ getRepoIdFromWorktreeId,
+ getWorktreePathBasenameFromId
+} from '../shared/worktree-id'
import {
isPathInsideOrEqual,
+ isWindowsAbsolutePathLike,
normalizeRuntimePathForComparison
} from '../shared/cross-platform-path'
import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands'
@@ -325,6 +331,110 @@ function getDataFile(): string {
return _dataFile
}
+// Why a sidecar: githubCache is a refetchable 5-min-TTL poll cache whose
+// fetchedAt stamps change on every refresh — keeping it inside orca-data.json
+// made every poll cycle rewrite the whole multi-MB durable state (defeating
+// the content-hash guard by design). It lives in memory during the session
+// and is snapshotted here best-effort at quit so PR/issue badges still paint
+// instantly on the next launch. Loss of this file costs nothing.
+function getGithubCacheFile(): string {
+ return join(dirname(getDataFile()), 'orca-github-cache.json')
+}
+
+// Why: worktrees deleted outside Orca (git CLI worktree remove, rm -rf,
+// agent scripts) purge renderer session state but nothing removed their
+// worktreeMeta, so the map grew monotonically (63% dead entries measured on
+// a heavy install). GC is deliberately narrow: local-host entries only
+// (SSH/runtime metas embed remote paths a local existsSync would falsely
+// condemn; WSL UNC paths are skipped the same way), and only after a
+// 30-day idle grace so pushTarget cleanup for recently-vanished worktrees
+// and quick recreations keep their metadata.
+const WORKTREE_META_GC_GRACE_MS = 30 * 24 * 60 * 60 * 1000
+
+function gcStaleWorktreeMeta(state: PersistedState): number {
+ // Why: a hand-corrupted file with `"worktreeMeta": null` overrides the
+ // defaults merge; normalize instead of throwing outside the parse guard.
+ state.worktreeMeta ??= {}
+ const repoById = new Map(state.repos.map((repo) => [repo.id, repo]))
+ const projectIds = new Set((state.projects ?? []).map((project) => project.id))
+ const now = Date.now()
+ let removed = 0
+ for (const key of Object.keys(state.worktreeMeta)) {
+ // Why: folder-project workspace instances are keyed
+ // `repoId::path::workspace:` and their meta IS the workspace
+ // record — never a filesystem-checkout row. Skip them entirely.
+ if (key.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR)) {
+ continue
+ }
+ const separator = key.indexOf('::')
+ if (separator === -1) {
+ continue
+ }
+ const ownerId = key.slice(0, separator)
+ const worktreePath = key.slice(separator + 2)
+ const meta = state.worktreeMeta[key]
+ const repo = repoById.get(ownerId)
+ if (repo) {
+ if (repo.connectionId || getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) {
+ continue
+ }
+ } else if (projectIds.has(ownerId)) {
+ // Project-owned metas keep project/host semantics on the entry itself;
+ // stay conservative and leave them to their own lifecycle.
+ continue
+ }
+ // Unowned entries (repo removed before removeProject pruned metas) fall
+ // through to the same missing-path + idle-grace gate.
+ if (meta?.hostId && meta.hostId !== LOCAL_EXECUTION_HOST_ID) {
+ continue
+ }
+ if (!isAbsolute(worktreePath) || isWslUncPath(worktreePath)) {
+ continue
+ }
+ // Why: WSL linked worktrees on Windows carry Linux-style paths from git
+ // porcelain; a Windows existsSync cannot probe those and would falsely
+ // condemn live worktrees.
+ if (process.platform === 'win32' && !isWindowsAbsolutePathLike(worktreePath)) {
+ continue
+ }
+ // Why keep timestamp-less entries: without lastActivityAt/createdAt we
+ // cannot prove the 30-day idle grace elapsed; the measured dead entries
+ // all carry timestamps, so this costs almost nothing in reclaimed bytes.
+ // Grace runs before the stat so healthy profiles skip the existsSync
+ // fan-out (and its slow-NFS tail) for active entries entirely.
+ const newestTouch = Math.max(meta?.lastActivityAt ?? 0, meta?.createdAt ?? 0)
+ if (newestTouch === 0 || now - newestTouch < WORKTREE_META_GC_GRACE_MS) {
+ continue
+ }
+ if (existsSync(worktreePath)) {
+ continue
+ }
+ delete state.worktreeMeta[key]
+ delete state.worktreeLineageById[key]
+ delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)]
+ removed++
+ }
+ return removed
+}
+
+function readGithubCacheSnapshot(): PersistedState['githubCache'] | null {
+ try {
+ const parsed = JSON.parse(readFileSync(getGithubCacheFile(), 'utf-8')) as unknown
+ const isPlainRecord = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null && !Array.isArray(value)
+ if (
+ isPlainRecord(parsed) &&
+ isPlainRecord((parsed as { pr?: unknown }).pr) &&
+ isPlainRecord((parsed as { issue?: unknown }).issue)
+ ) {
+ return parsed as PersistedState['githubCache']
+ }
+ } catch {
+ // Missing or corrupt snapshot: start with an empty cache and refetch.
+ }
+ return null
+}
+
/**
* Return the userData directory captured at initDataPath() time, before
* app.setName() can change how app.getPath('userData') resolves.
@@ -2242,6 +2352,7 @@ function createMinimalPersistedTerminalTab(args: {
tabId: string
ptyId: string
existingTabCount: number
+ startupCwd?: string
}): TerminalTab {
const ordinal = args.existingTabCount + 1
const defaultTitle = `Terminal ${ordinal}`
@@ -2255,6 +2366,7 @@ function createMinimalPersistedTerminalTab(args: {
color: null,
sortOrder: args.existingTabCount,
createdAt: Date.now(),
+ ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}),
pendingActivationSpawn: true
}
}
@@ -2451,6 +2563,7 @@ export class Store {
// on-disk bytes differ even for identical state.
private lastWrittenStateHash: string | null = null
private firstPendingSaveAt: number | null = null
+ private githubCacheDirty = false
private gitUsernameCache = new Map()
private loadNeedsSave = false
private settingsChangeListeners = new Set<
@@ -3293,7 +3406,29 @@ export class Store {
}
result = folderScopeConnectionMigration.state
+ if (gcStaleWorktreeMeta(result) > 0) {
+ this.loadNeedsSave = true
+ }
+
const migrated = this.migrateTelemetry(result, fileExistedOnLoad)
+
+ // githubCache lives in a sidecar file now (see getGithubCacheFile). A
+ // legacy in-file cache (pre-sidecar build, or a downgrade round-trip) is
+ // kept as this session's seed and stripped from the durable file by the
+ // save scheduled below; otherwise seed from the sidecar snapshot.
+ const legacyCache = migrated.githubCache
+ const hasLegacyCache =
+ Object.keys(legacyCache?.pr ?? {}).length > 0 ||
+ Object.keys(legacyCache?.issue ?? {}).length > 0
+ if (hasLegacyCache) {
+ this.loadNeedsSave = true
+ // Why: mark dirty so the first flush writes the sidecar even if no
+ // poll refresh happens this session — the seed survives the migration.
+ this.githubCacheDirty = true
+ } else {
+ migrated.githubCache = readGithubCacheSnapshot() ?? migrated.githubCache
+ }
+
logPersistenceStartupMilestone('persistence-load-done', {
repos: migrated.repos.length,
workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession))
@@ -3403,8 +3538,16 @@ export class Store {
}
}
+ // Why githubCache is omitted: it is memory-only during the session (see
+ // getGithubCacheFile) — excluding it from both the payload and the hash
+ // keeps cache refreshes from ever touching the durable file.
+ private getDurableState(): Omit {
+ const { githubCache: _memoryOnly, ...durable } = this.state
+ return durable
+ }
+
private computeStateHash(): string {
- return createHash('sha1').update(JSON.stringify(this.state)).digest('hex')
+ return createHash('sha1').update(JSON.stringify(this.getDurableState())).digest('hex')
}
// Why: builds the on-disk payload synchronously so the hash and the
@@ -3414,7 +3557,7 @@ export class Store {
// Why: secrets must be encrypted on disk. Clone state so the in-memory
// this.state stays plaintext for the rest of the app.
const stateToSave = {
- ...this.state,
+ ...this.getDurableState(),
settings: {
...this.state.settings,
opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie),
@@ -5241,8 +5384,12 @@ export class Store {
}
setGitHubCache(cache: PersistedState['githubCache']): void {
+ // Why no scheduleSave: the cache is memory-only during the session and
+ // snapshotted to its sidecar file at flush (quit/reload) time. Every poll
+ // refresh restamps fetchedAt, so persisting here rewrote the whole
+ // durable state file once per poll cycle for refetchable data.
this.state.githubCache = cache
- this.scheduleSave()
+ this.githubCacheDirty = true
}
// ── Workspace Session ─────────────────────────────────────────────
@@ -5604,6 +5751,7 @@ export class Store {
tabId: string
leafId: string
ptyId: string
+ startupCwd?: string
}): void {
const session = this.state.workspaceSession
if (!session) {
@@ -5947,6 +6095,29 @@ export class Store {
} catch (err) {
console.error('[persistence] Failed to flush state:', err)
}
+ this.writeGithubCacheSnapshotSync()
+ }
+
+ // Why best-effort: the sidecar is a refetchable cache — a failed write only
+ // costs a cold badge paint on next launch, never data.
+ private writeGithubCacheSnapshotSync(): void {
+ if (!this.githubCacheDirty) {
+ return
+ }
+ const cacheFile = getGithubCacheFile()
+ const tmpFile = `${cacheFile}.${process.pid}.tmp`
+ try {
+ writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8')
+ renameSync(tmpFile, cacheFile)
+ this.githubCacheDirty = false
+ } catch (err) {
+ try {
+ unlinkSync(tmpFile)
+ } catch {
+ // Best-effort cleanup.
+ }
+ console.warn('[persistence] Failed to write github cache snapshot:', err)
+ }
}
}
diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts
index 01ea3aeea57..7e8588a6b64 100644
--- a/src/main/providers/local-pty-provider.ts
+++ b/src/main/providers/local-pty-provider.ts
@@ -21,7 +21,7 @@ import {
updateHistFileForFallback,
logHistoryInjection
} from '../terminal-history'
-import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types'
+import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types'
import {
ensureNodePtySpawnHelperExecutable,
validateWorkingDirectory,
@@ -67,6 +67,7 @@ let ptyCounter = 0
const ptyProcesses = new Map()
const ptyShellName = new Map()
const ptyAgentForegroundContextPaths = new Map()
+const ptyTerminalHandle = new Map()
// Why: node-pty's onData/onExit register native NAPI ThreadSafeFunction
// callbacks. If the PTY is killed without disposing these listeners, the
// stale callbacks survive into node::FreeEnvironment() where NAPI attempts
@@ -187,6 +188,7 @@ function clearPtyState(id: string): void {
ptyProcesses.delete(id)
ptyShellName.delete(id)
ptyAgentForegroundContextPaths.delete(id)
+ ptyTerminalHandle.delete(id)
ptyLoadGeneration.delete(id)
}
@@ -680,6 +682,9 @@ export class LocalPtyProvider implements IPtyProvider {
const proc = spawnResult.process
ptyProcesses.set(id, proc)
ptyShellName.set(id, getSpawnedShellName(shellPath))
+ if (finalEnv.ORCA_TERMINAL_HANDLE) {
+ ptyTerminalHandle.set(id, finalEnv.ORCA_TERMINAL_HANDLE)
+ }
ptyAgentForegroundContextPaths.set(
id,
getAgentForegroundContextPaths({ cwd: args.cwd, worktreeId: args.worktreeId })
@@ -945,11 +950,12 @@ export class LocalPtyProvider implements IPtyProvider {
/* re-spawning handles local revival */
}
- async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
+ async listProcesses(): Promise {
return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({
id,
cwd: '',
- title: proc.process || ptyShellName.get(id) || 'shell'
+ title: proc.process || ptyShellName.get(id) || 'shell',
+ ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {})
}))
}
diff --git a/src/main/providers/local-pty-utils-windows-fallback.test.ts b/src/main/providers/local-pty-utils-windows-fallback.test.ts
index 48b35cd27c0..0de16d03f17 100644
--- a/src/main/providers/local-pty-utils-windows-fallback.test.ts
+++ b/src/main/providers/local-pty-utils-windows-fallback.test.ts
@@ -76,13 +76,13 @@ describe('spawnShellWithFallback on Windows', () => {
1,
PWSH7,
attempts[0].shellArgs,
- expect.objectContaining({ cwd: 'C:\\repo' })
+ expect.objectContaining({ cwd: 'C:\\repo', useConptyDll: true })
)
expect(ptySpawn).toHaveBeenNthCalledWith(
2,
WINDOWS_POWERSHELL,
attempts[1].shellArgs,
- expect.objectContaining({ cwd: 'C:\\repo' })
+ expect.objectContaining({ cwd: 'C:\\repo', useConptyDll: true })
)
})
diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts
index 0ad6effc9c9..c8eb87c249d 100644
--- a/src/main/providers/local-pty-utils.ts
+++ b/src/main/providers/local-pty-utils.ts
@@ -160,6 +160,14 @@ export type ShellSpawnResult = {
* executables with per-shell args, so when the primary fails we retry with the
* next safe shell instead of leaving the user with no terminal.
*/
+// Why: match the daemon spawn path (pty-subprocess.ts) — the bundled ConPTY
+// has the modern wrap-marker behavior xterm expects; legacy system ConPTY can
+// corrupt full-width TUI rows in scrollback. Without this, degraded-mode and
+// fresh-local spawns silently behave differently from daemon terminals.
+function windowsConptyDllOptions(): { useConptyDll: true } | Record {
+ return process.platform === 'win32' ? { useConptyDll: true } : {}
+}
+
function spawnWindowsFallbackChain(
params: ShellSpawnParams,
primaryError: string
@@ -174,7 +182,8 @@ function spawnWindowsFallbackChain(
cols,
rows,
cwd: attempt.effectiveCwd,
- env
+ env,
+ ...windowsConptyDllOptions()
})
console.warn(
`[pty] Primary shell "${params.shellPath}" failed (${primaryError}), fell back to "${attempt.shellPath}"`
@@ -217,7 +226,14 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
if (!primaryError) {
try {
return {
- process: ptySpawn(shellPath, shellArgs, { name: termName, cols, rows, cwd, env }),
+ process: ptySpawn(shellPath, shellArgs, {
+ name: termName,
+ cols,
+ rows,
+ cwd,
+ env,
+ ...windowsConptyDllOptions()
+ }),
shellPath
}
} catch (err) {
diff --git a/src/main/providers/ssh-filesystem-provider-sftp.ts b/src/main/providers/ssh-filesystem-provider-sftp.ts
new file mode 100644
index 00000000000..31c003303d8
--- /dev/null
+++ b/src/main/providers/ssh-filesystem-provider-sftp.ts
@@ -0,0 +1,46 @@
+import type { SFTPWrapper, Stats } from 'ssh2'
+import type { FileStat } from './types'
+
+export function fileStatFromSftpStats(stats: Stats): FileStat {
+ let type: FileStat['type'] = 'file'
+ if (stats.isDirectory()) {
+ type = 'directory'
+ } else if (stats.isSymbolicLink()) {
+ type = 'symlink'
+ }
+ const maybeNlink = (stats as Stats & { nlink?: unknown }).nlink
+ return {
+ size: stats.size,
+ type,
+ mtime: stats.mtime * 1000,
+ ...(typeof maybeNlink === 'number' ? { nlink: maybeNlink } : {})
+ }
+}
+
+export function lstatViaSftp(sftp: SFTPWrapper, filePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.lstat(filePath, (err, stats) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ resolve(fileStatFromSftpStats(stats))
+ })
+ })
+}
+
+export function fastGetViaSftp(
+ sftp: SFTPWrapper,
+ sourcePath: string,
+ destinationPath: string
+): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.fastGet(sourcePath, destinationPath, (err) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ resolve()
+ })
+ })
+}
diff --git a/src/main/providers/ssh-filesystem-provider-watch.ts b/src/main/providers/ssh-filesystem-provider-watch.ts
new file mode 100644
index 00000000000..7beea078ac3
--- /dev/null
+++ b/src/main/providers/ssh-filesystem-provider-watch.ts
@@ -0,0 +1,86 @@
+import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
+import type { FsChangeEvent } from '../../shared/types'
+
+export type WatchRegistration = {
+ callbacks: Set<(events: FsChangeEvent[]) => void>
+ setupPromise: Promise
+}
+
+export async function registerSshFilesystemWatch(args: {
+ mux: SshChannelMultiplexer
+ disposed: () => boolean
+ registrations: Map
+ rootPath: string
+ callback: (events: FsChangeEvent[]) => void
+}): Promise<() => void> {
+ if (args.disposed()) {
+ throw new Error('SSH filesystem provider disposed')
+ }
+ let registration = args.registrations.get(args.rootPath)
+ if (registration) {
+ registration.callbacks.add(args.callback)
+ await registration.setupPromise
+ assertActiveWatch(args, registration)
+ return createSshFilesystemWatchUnsubscribe(args, registration)
+ }
+
+ const callbacks = new Set<(events: FsChangeEvent[]) => void>([args.callback])
+ const setupPromise = args.mux.request('fs.watch', { rootPath: args.rootPath }).then(
+ () => undefined,
+ (error) => {
+ if (args.registrations.get(args.rootPath) === registration) {
+ args.registrations.delete(args.rootPath)
+ }
+ throw error
+ }
+ )
+ registration = { callbacks, setupPromise }
+ args.registrations.set(args.rootPath, registration)
+ await setupPromise
+ if (args.disposed() || args.registrations.get(args.rootPath) !== registration) {
+ notifySshFilesystemUnwatch(args.mux, args.rootPath)
+ throw new Error('SSH filesystem provider disposed')
+ }
+
+ return createSshFilesystemWatchUnsubscribe(args, registration)
+}
+
+export function notifySshFilesystemUnwatch(mux: SshChannelMultiplexer, rootPath: string): void {
+ try {
+ mux.notify('fs.unwatch', { rootPath })
+ } catch {}
+}
+
+function assertActiveWatch(
+ args: {
+ disposed: () => boolean
+ registrations: Map
+ rootPath: string
+ },
+ registration: WatchRegistration
+): void {
+ if (args.disposed() || args.registrations.get(args.rootPath) !== registration) {
+ throw new Error('SSH filesystem provider disposed')
+ }
+}
+
+function createSshFilesystemWatchUnsubscribe(
+ args: {
+ mux: SshChannelMultiplexer
+ registrations: Map
+ rootPath: string
+ callback: (events: FsChangeEvent[]) => void
+ },
+ registration: WatchRegistration
+): () => void {
+ return () => {
+ registration.callbacks.delete(args.callback)
+ if (
+ registration.callbacks.size === 0 &&
+ args.registrations.get(args.rootPath) === registration
+ ) {
+ args.registrations.delete(args.rootPath)
+ notifySshFilesystemUnwatch(args.mux, args.rootPath)
+ }
+ }
+}
diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts
index be483d2d651..f008c380dde 100644
--- a/src/main/providers/ssh-filesystem-provider.test.ts
+++ b/src/main/providers/ssh-filesystem-provider.test.ts
@@ -84,6 +84,38 @@ describe('SshFilesystemProvider', () => {
})
})
+ describe('readTerminalArtifact', () => {
+ it('sends fs.readTerminalArtifact request with verification metadata', async () => {
+ mux.request.mockResolvedValue({ content: '{}', isBinary: false })
+
+ await expect(
+ provider.readTerminalArtifact('/tmp/result.json', {
+ expectedRealPath: '/tmp/result.json',
+ expectedStatIdentity: '1:2:3:4',
+ maxBytes: 1024
+ })
+ ).resolves.toEqual({ content: '{}', isBinary: false })
+ expect(mux.request).toHaveBeenCalledWith('fs.readTerminalArtifact', {
+ filePath: '/tmp/result.json',
+ expectedRealPath: '/tmp/result.json',
+ expectedStatIdentity: '1:2:3:4',
+ maxBytes: 1024
+ })
+ })
+
+ it('asks the user to reconnect when the relay is too old for artifact reads', async () => {
+ mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 }))
+
+ await expect(
+ provider.readTerminalArtifact('/tmp/result.json', {
+ expectedRealPath: '/tmp/result.json',
+ expectedStatIdentity: '1:2:3:4',
+ maxBytes: 1024
+ })
+ ).rejects.toThrow('Reconnect the SSH target')
+ })
+ })
+
describe('writeFile', () => {
it('sends fs.writeFile request', async () => {
await provider.writeFile('/home/user/file.txt', 'new content')
@@ -94,6 +126,39 @@ describe('SshFilesystemProvider', () => {
})
})
+ describe('writeTerminalArtifact', () => {
+ it('returns the verified artifact write stat', async () => {
+ mux.request.mockResolvedValue({ stat: { type: 'file', size: 2, mtime: 3 } })
+
+ await expect(
+ provider.writeTerminalArtifact('/tmp/result.json', '{}', {
+ expectedRealPath: '/tmp/result.json',
+ expectedStatIdentity: '1:2:3:4',
+ maxBytes: 1024
+ })
+ ).resolves.toEqual({ type: 'file', size: 2, mtime: 3 })
+ expect(mux.request).toHaveBeenCalledWith('fs.writeTerminalArtifact', {
+ filePath: '/tmp/result.json',
+ content: '{}',
+ expectedRealPath: '/tmp/result.json',
+ expectedStatIdentity: '1:2:3:4',
+ maxBytes: 1024
+ })
+ })
+
+ it('asks the user to reconnect when the relay is too old for artifact writes', async () => {
+ mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 }))
+
+ await expect(
+ provider.writeTerminalArtifact('/tmp/result.json', '{}', {
+ expectedRealPath: '/tmp/result.json',
+ expectedStatIdentity: '1:2:3:4',
+ maxBytes: 1024
+ })
+ ).rejects.toThrow('Reconnect the SSH target')
+ })
+ })
+
describe('getTempDir', () => {
it('reads and caches the remote temp directory from the relay', async () => {
mux.request.mockResolvedValue('/var/folders/remote')
diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts
index 4ac373dd88e..3adeeec6cb8 100644
--- a/src/main/providers/ssh-filesystem-provider.ts
+++ b/src/main/providers/ssh-filesystem-provider.ts
@@ -1,74 +1,33 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader'
import { uploadBuffer } from '../ssh/sftp-upload'
-import type { IFilesystemProvider, FileStat, FileReadResult } from './types'
+import { fastGetViaSftp, lstatViaSftp } from './ssh-filesystem-provider-sftp'
+import {
+ notifySshFilesystemUnwatch,
+ registerSshFilesystemWatch,
+ type WatchRegistration
+} from './ssh-filesystem-provider-watch'
+import type {
+ IFilesystemProvider,
+ FileStat,
+ FileReadResult,
+ TerminalArtifactAccessOptions
+} from './types'
import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types'
-import type { SFTPWrapper, Stats } from 'ssh2'
+import type { SFTPWrapper } from 'ssh2'
type SftpFactory = () => Promise
-type WatchRegistration = {
- callbacks: Set<(events: FsChangeEvent[]) => void>
- setupPromise: Promise
-}
-
const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000
-function fileStatFromSftpStats(stats: Stats): FileStat {
- let type: FileStat['type'] = 'file'
- if (stats.isDirectory()) {
- type = 'directory'
- } else if (stats.isSymbolicLink()) {
- type = 'symlink'
- }
- return { size: stats.size, type, mtime: stats.mtime * 1000 }
-}
-
-function lstatViaSftp(sftp: SFTPWrapper, filePath: string): Promise {
- return new Promise((resolve, reject) => {
- sftp.lstat(filePath, (err, stats) => {
- if (err) {
- reject(err)
- return
- }
- resolve(fileStatFromSftpStats(stats))
- })
- })
-}
-
-function fastGetViaSftp(
- sftp: SFTPWrapper,
- sourcePath: string,
- destinationPath: string
-): Promise