diff --git a/mobile/scripts/repro-terminal-colors.ts b/mobile/scripts/repro-terminal-colors.ts index 100643d57a7..977d2c00eec 100644 --- a/mobile/scripts/repro-terminal-colors.ts +++ b/mobile/scripts/repro-terminal-colors.ts @@ -311,7 +311,11 @@ ws.on('open', () => { }) ws.on('message', (data) => { - const plaintext = decrypt(data.toString()) + const raw = data.toString() + if (raw.startsWith('{')) { + return + } + const plaintext = decrypt(raw) if (!plaintext) return const response = JSON.parse(plaintext) as RpcResponse diff --git a/mobile/scripts/repro-worktree-startup-stream.ts b/mobile/scripts/repro-worktree-startup-stream.ts new file mode 100644 index 00000000000..3d5e38dcd1b --- /dev/null +++ b/mobile/scripts/repro-worktree-startup-stream.ts @@ -0,0 +1,350 @@ +/** + * Captures the mobile WebSocket stream during worktree startup. + * + * Usage: + * pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand] + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import nacl from 'tweetnacl' +import WebSocket from 'ws' + +const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768' +const USER_DATA = + process.env.ORCA_USER_DATA ?? `${process.env.HOME}/Library/Application Support/orca-dev` +const repoSelector = process.argv[2] +const worktreeName = process.argv[3] +const startupCommand = process.argv[4] || 'claude' +const ESC = String.fromCharCode(27) + +type RpcResponse = { + id: string + ok: boolean + streaming?: true + result?: Record + error?: { code: string; message: string } +} + +type PendingRequest = { + method: string + resolve: (response: RpcResponse) => void + reject: (error: Error) => void +} + +type TerminalInfo = { + handle: string + title: string | null +} + +type Capture = { + handle: string + title: string | null + scrollback: Record | null + chunks: string[] +} + +if (!repoSelector || !worktreeName) { + console.error( + 'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand]' + ) + process.exit(1) +} + +function readJson(path: string): T { + if (!existsSync(path)) { + throw new Error(`Missing ${path}`) + } + return JSON.parse(readFileSync(path, 'utf8')) as T +} + +const devices = readJson>(join(USER_DATA, 'orca-devices.json')) +const token = devices[0]?.token +const keypair = readJson<{ publicKeyB64: string }>(join(USER_DATA, 'orca-e2ee-keypair.json')) + +if (!token || !keypair.publicKeyB64) { + throw new Error(`Missing mobile token or E2EE public key in ${USER_DATA}`) +} + +let reqId = 0 +const pending = new Map() +const streamListeners = new Map) => void>() +const clientKeys = nacl.box.keyPair() +const serverPublicKey = Buffer.from(keypair.publicKeyB64, 'base64') +const sharedKey = nacl.box.before(new Uint8Array(serverPublicKey), clientKeys.secretKey) + +function nextId(): string { + reqId += 1 + return `startup-repro-${reqId}` +} + +function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64') +} + +function fromBase64(value: string): Uint8Array { + return new Uint8Array(Buffer.from(value, 'base64')) +} + +function encrypt(plaintext: string): string { + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const message = new TextEncoder().encode(plaintext) + const ciphertext = nacl.box.after(message, nonce, sharedKey) + const bundle = new Uint8Array(nonce.length + ciphertext.length) + bundle.set(nonce) + bundle.set(ciphertext, nonce.length) + return toBase64(bundle) +} + +function decrypt(payload: string): string | null { + const bundle = fromBase64(payload) + if (bundle.length < nacl.box.nonceLength + nacl.box.overheadLength) { + return null + } + const nonce = bundle.subarray(0, nacl.box.nonceLength) + const ciphertext = bundle.subarray(nacl.box.nonceLength) + const plaintext = nacl.box.open.after(ciphertext, nonce, sharedKey) + return plaintext ? new TextDecoder().decode(plaintext) : null +} + +function sendRaw(ws: WebSocket, payload: unknown): void { + ws.send(encrypt(JSON.stringify(payload))) +} + +function send( + ws: WebSocket, + method: string, + params?: unknown, + timeoutMs = 30_000 +): Promise { + const id = nextId() + sendRaw(ws, { id, deviceToken: token, method, params }) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id) + reject(new Error(`Timed out waiting for ${method}`)) + }, timeoutMs) + pending.set(id, { + method, + resolve: (response) => { + clearTimeout(timeout) + resolve(response) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + }) +} + +function formatError(response: RpcResponse): string { + return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000) +} + +function stripAnsi(value: string): string { + return ( + value + // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries + .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '') + // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries + .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\r/g, '\n') + ) +} + +function normalizePreview(value: string): string { + return stripAnsi(value) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 8) + .join('\n') +} + +async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise { + const deadline = Date.now() + 60_000 + while (Date.now() < deadline) { + const response = await send(ws, 'terminal.list', { worktree: worktreeId }) + if (!response.ok) { + throw new Error(`terminal.list failed: ${formatError(response)}`) + } + const terminals = (response.result?.terminals ?? []) as Array<{ + handle?: string + title?: string | null + }> + const handles = terminals + .filter((terminal): terminal is { handle: string; title?: string | null } => + Boolean(terminal.handle) + ) + .map((terminal) => ({ handle: terminal.handle, title: terminal.title ?? null })) + if (handles.length > 0) { + return handles + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error('Timed out waiting for startup terminals') +} + +async function subscribe(ws: WebSocket, capture: Capture): Promise { + const id = nextId() + streamListeners.set(id, (result) => { + if (result.type === 'scrollback') { + capture.scrollback = result + return + } + if (result.type === 'data' && typeof result.chunk === 'string') { + capture.chunks.push(result.chunk) + } + }) + sendRaw(ws, { + id, + deviceToken: token, + method: 'terminal.subscribe', + params: { terminal: capture.handle } + }) +} + +function summarize(capture: Capture): Record { + const serialized = + typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' + const live = capture.chunks.join('') + const serializedPreview = normalizePreview(serialized) + const livePreview = normalizePreview(live) + return { + handle: capture.handle, + title: capture.title, + cols: capture.scrollback?.cols ?? null, + rows: capture.scrollback?.rows ?? null, + serializedBytes: Buffer.byteLength(serialized), + liveBytes: Buffer.byteLength(live), + liveChunks: capture.chunks.length, + serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, + liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, + livePreviewContainedInSerialized: + livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''), + serializedPreview, + livePreview + } +} + +function save(captures: Capture[], worktreeName: string): string { + const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName) + mkdirSync(dir, { recursive: true }) + for (const capture of captures) { + const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-') + const serialized = + typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' + writeFileSync(join(dir, `${base}.serialized.ansi`), serialized) + writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join('')) + writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2)) + } + writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2)) + return dir +} + +async function run(ws: WebSocket): Promise { + ws.send( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: toBase64(clientKeys.publicKey), + deviceToken: token + }) + ) + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Timed out waiting for e2ee_ready')), 5000) + ws.once('message', (data) => { + clearTimeout(timeout) + const msg = JSON.parse(data.toString()) as { type?: string } + if (msg.type !== 'e2ee_ready') { + reject(new Error(`Unexpected handshake response: ${data.toString()}`)) + return + } + resolve() + }) + }) + + const created = await send( + ws, + 'worktree.create', + { repo: repoSelector, name: worktreeName, startupCommand }, + 120_000 + ) + if (!created.ok) { + throw new Error(`worktree.create failed: ${formatError(created)}`) + } + const worktree = created.result?.worktree as { id?: string; path?: string } | undefined + if (!worktree?.id) { + throw new Error(`worktree.create returned no worktree id: ${formatError(created)}`) + } + + console.log(`worktree: ${worktree.id}`) + const terminals = await waitForTerminals(ws, worktree.id) + console.log(`terminals: ${terminals.map((terminal) => terminal.handle).join(', ')}`) + const captures = terminals.map((terminal) => ({ + handle: terminal.handle, + title: terminal.title, + scrollback: null, + chunks: [] + })) + for (const capture of captures) { + await subscribe(ws, capture) + } + + await new Promise((resolve) => setTimeout(resolve, 15_000)) + for (const capture of captures) { + await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null) + } + const dir = save(captures, worktreeName) + console.table(captures.map(summarize)) + console.log(`saved: ${dir}`) +} + +const ws = new WebSocket(WS_URL) + +ws.on('open', () => { + run(ws) + .then(() => { + ws.close() + process.exit(0) + }) + .catch((error) => { + console.error(error.message) + ws.close() + process.exit(1) + }) +}) + +ws.on('message', (data) => { + const raw = data.toString() + if (raw.startsWith('{')) { + return + } + const plaintext = decrypt(raw) + if (!plaintext) return + + const response = JSON.parse(plaintext) as RpcResponse + const result = response.result + const streamListener = streamListeners.get(response.id) + if (streamListener && response.ok && (response.streaming || result?.type)) { + streamListener(result ?? {}) + return + } + + const request = pending.get(response.id) + if (!request || request.method === 'terminal.subscribe') return + pending.delete(response.id) + request.resolve(response) +}) + +ws.on('close', () => { + for (const request of pending.values()) { + request.reject(new Error('WebSocket closed')) + } + pending.clear() +}) + +ws.on('error', (error) => { + console.error(`WebSocket error: ${error.message}`) + process.exit(1) +}) diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index f7a2e021fdb..1bccaf63263 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -134,7 +134,11 @@ if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then __orca_prompt_mark() { printf "${SHELL_READY_MARKER}" } - precmd_functions=(\${precmd_functions[@]} __orca_prompt_mark) + # Why: zsh precmd fires before zle switches the PTY into line-editing mode, + # so writing startup input there can be echoed once outside the prompt. + autoload -Uz add-zle-hook-widget + zle -N __orca_prompt_mark + add-zle-hook-widget line-init __orca_prompt_mark fi ` const bashRc = `# Orca daemon bash shell-ready wrapper diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 7ad3c09b902..b15d8f59bbc 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -210,13 +210,15 @@ __orca_restore_attribution_path() { export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH" } __orca_restore_attribution_path -# Why: emit OSC 133;A only after the user's startup hooks finish so Orca knows -# the prompt is actually ready for a long startup command paste. +# Why: zsh precmd runs before the prompt is drawn and before zle owns input, +# which can double-echo startup commands. line-init fires when zle is ready. if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then __orca_prompt_mark() { printf "\\033]133;A\\007" } - precmd_functions=(\${precmd_functions[@]} __orca_prompt_mark) + autoload -Uz add-zle-hook-widget + zle -N __orca_prompt_mark + add-zle-hook-widget line-init __orca_prompt_mark fi ` const bashRc = getBashShellReadyRcfileContent()