From fd4f986c593dd84724938541fbf48970cd60f4a5 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:42:41 -0400 Subject: [PATCH] feat: terminal persistence via out-of-process daemon (#729) --- config/electron-builder.config.cjs | 4 +- electron.vite.config.ts | 11 +- package.json | 1 + pnpm-lock.yaml | 8 + src/main/daemon/binary-frame.test.ts | 167 +++++ src/main/daemon/binary-frame.ts | 56 ++ src/main/daemon/client.test.ts | 257 +++++++ src/main/daemon/client.ts | 295 ++++++++ src/main/daemon/daemon-entry.test.ts | 32 + src/main/daemon/daemon-entry.ts | 68 ++ src/main/daemon/daemon-init.ts | 174 +++++ src/main/daemon/daemon-main.test.ts | 106 +++ src/main/daemon/daemon-main.ts | 25 + src/main/daemon/daemon-pty-adapter.test.ts | 644 ++++++++++++++++++ src/main/daemon/daemon-pty-adapter.ts | 404 +++++++++++ src/main/daemon/daemon-pty-provider.test.ts | 195 ++++++ src/main/daemon/daemon-pty-provider.ts | 117 ++++ src/main/daemon/daemon-server.test.ts | 216 ++++++ src/main/daemon/daemon-server.ts | 274 ++++++++ src/main/daemon/daemon-spawner.test.ts | 161 +++++ src/main/daemon/daemon-spawner.ts | 68 ++ src/main/daemon/fixtures/ratatui-tui.py | 85 +++ src/main/daemon/headless-emulator.test.ts | 205 ++++++ src/main/daemon/headless-emulator.ts | 156 +++++ src/main/daemon/history-manager.test.ts | 376 ++++++++++ src/main/daemon/history-manager.ts | 325 +++++++++ src/main/daemon/history-paths.ts | 7 + src/main/daemon/history-reader.test.ts | 199 ++++++ src/main/daemon/history-reader.ts | 136 ++++ src/main/daemon/ndjson.test.ts | 124 ++++ src/main/daemon/ndjson.ts | 41 ++ src/main/daemon/priority-semaphore.test.ts | 148 ++++ src/main/daemon/priority-semaphore.ts | 57 ++ src/main/daemon/production-launcher.test.ts | 77 +++ src/main/daemon/production-launcher.ts | 78 +++ src/main/daemon/pty-subprocess.test.ts | 220 ++++++ src/main/daemon/pty-subprocess.ts | 99 +++ src/main/daemon/reattach-snapshot.test.ts | 553 +++++++++++++++ src/main/daemon/session.test.ts | 342 ++++++++++ src/main/daemon/session.ts | 309 +++++++++ src/main/daemon/shell-ready.ts | 135 ++++ src/main/daemon/terminal-host.test.ts | 307 +++++++++ src/main/daemon/terminal-host.ts | 194 ++++++ src/main/daemon/types.ts | 263 +++++++ src/main/daemon/xterm-env-polyfill.ts | 6 + src/main/index.ts | 12 + src/main/ipc/pty.test.ts | 50 +- src/main/ipc/pty.ts | 192 ++++-- src/main/providers/local-pty-provider.test.ts | 41 ++ src/main/providers/local-pty-provider.ts | 31 +- src/main/providers/local-pty-shell-ready.ts | 3 +- src/main/providers/types.ts | 22 + src/preload/api-types.d.ts | 14 +- src/preload/index.d.ts | 15 +- src/preload/index.ts | 22 +- .../status-bar/SessionsStatusSegment.tsx | 251 +++++++ .../src/components/status-bar/StatusBar.tsx | 13 +- .../terminal-pane/pty-connection.test.ts | 34 +- .../terminal-pane/pty-connection.ts | 95 ++- .../terminal-pane/pty-dispatcher.ts | 18 +- .../terminal-pane/pty-transport.test.ts | 59 ++ .../components/terminal-pane/pty-transport.ts | 39 +- .../slices/store-session-cascades.test.ts | 143 +++- .../store/slices/terminals-hydration.test.ts | 8 +- src/renderer/src/store/slices/terminals.ts | 231 ++++--- src/shared/constants.ts | 2 +- src/shared/types.ts | 2 +- 67 files changed, 8779 insertions(+), 243 deletions(-) create mode 100644 src/main/daemon/binary-frame.test.ts create mode 100644 src/main/daemon/binary-frame.ts create mode 100644 src/main/daemon/client.test.ts create mode 100644 src/main/daemon/client.ts create mode 100644 src/main/daemon/daemon-entry.test.ts create mode 100644 src/main/daemon/daemon-entry.ts create mode 100644 src/main/daemon/daemon-init.ts create mode 100644 src/main/daemon/daemon-main.test.ts create mode 100644 src/main/daemon/daemon-main.ts create mode 100644 src/main/daemon/daemon-pty-adapter.test.ts create mode 100644 src/main/daemon/daemon-pty-adapter.ts create mode 100644 src/main/daemon/daemon-pty-provider.test.ts create mode 100644 src/main/daemon/daemon-pty-provider.ts create mode 100644 src/main/daemon/daemon-server.test.ts create mode 100644 src/main/daemon/daemon-server.ts create mode 100644 src/main/daemon/daemon-spawner.test.ts create mode 100644 src/main/daemon/daemon-spawner.ts create mode 100644 src/main/daemon/fixtures/ratatui-tui.py create mode 100644 src/main/daemon/headless-emulator.test.ts create mode 100644 src/main/daemon/headless-emulator.ts create mode 100644 src/main/daemon/history-manager.test.ts create mode 100644 src/main/daemon/history-manager.ts create mode 100644 src/main/daemon/history-paths.ts create mode 100644 src/main/daemon/history-reader.test.ts create mode 100644 src/main/daemon/history-reader.ts create mode 100644 src/main/daemon/ndjson.test.ts create mode 100644 src/main/daemon/ndjson.ts create mode 100644 src/main/daemon/priority-semaphore.test.ts create mode 100644 src/main/daemon/priority-semaphore.ts create mode 100644 src/main/daemon/production-launcher.test.ts create mode 100644 src/main/daemon/production-launcher.ts create mode 100644 src/main/daemon/pty-subprocess.test.ts create mode 100644 src/main/daemon/pty-subprocess.ts create mode 100644 src/main/daemon/reattach-snapshot.test.ts create mode 100644 src/main/daemon/session.test.ts create mode 100644 src/main/daemon/session.ts create mode 100644 src/main/daemon/shell-ready.ts create mode 100644 src/main/daemon/terminal-host.test.ts create mode 100644 src/main/daemon/terminal-host.ts create mode 100644 src/main/daemon/types.ts create mode 100644 src/main/daemon/xterm-env-polyfill.ts create mode 100644 src/renderer/src/components/status-bar/SessionsStatusSegment.tsx diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 650f8b36c..685c8ee69 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -20,7 +20,9 @@ module.exports = { // from out/shared/ (e.g. runtime-bootstrap). Both directories must be // unpacked so that Node's require() can resolve the cross-directory imports // when the CLI runs outside the asar archive. - asarUnpack: ['out/cli/**', 'out/shared/**', 'resources/**'], + // Why: daemon-entry.js is forked as a separate Node.js process and must be + // accessible on disk (not inside the asar archive) for child_process.fork(). + asarUnpack: ['out/cli/**', 'out/shared/**', 'out/main/daemon-entry.js', 'out/main/chunks/**', 'resources/**'], win: { executableName: 'Orca', extraResources: [ diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 24b3bb22d..096a677e8 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -4,7 +4,16 @@ import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' export default defineConfig({ - main: {}, + main: { + build: { + rollupOptions: { + input: { + index: resolve('src/main/index.ts'), + 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts') + } + } + } + }, preload: { build: { externalizeDeps: { diff --git a/package.json b/package.json index 7e545c15b..464b2cb98 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", + "@xterm/headless": "^6.0.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6950b969a..f6e26036e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,9 @@ importers: '@xterm/addon-webgl': specifier: ^0.19.0 version: 0.19.0 + '@xterm/headless': + specifier: ^6.0.0 + version: 6.0.0 '@xterm/xterm': specifier: ^6.0.0 version: 6.0.0 @@ -2808,6 +2811,9 @@ packages: '@xterm/addon-webgl@0.19.0': resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==} + '@xterm/headless@6.0.0': + resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} + '@xterm/xterm@6.0.0': resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} @@ -8463,6 +8469,8 @@ snapshots: '@xterm/addon-webgl@0.19.0': {} + '@xterm/headless@6.0.0': {} + '@xterm/xterm@6.0.0': {} abbrev@3.0.1: {} diff --git a/src/main/daemon/binary-frame.test.ts b/src/main/daemon/binary-frame.test.ts new file mode 100644 index 000000000..81148f18a --- /dev/null +++ b/src/main/daemon/binary-frame.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest' +import { encodeFrame, createFrameParser, FrameType, FRAME_HEADER_SIZE } from './binary-frame' + +describe('encodeFrame', () => { + it('encodes a data frame with correct header', () => { + const payload = Buffer.from('hello') + const frame = encodeFrame(FrameType.Data, payload) + + expect(frame.length).toBe(FRAME_HEADER_SIZE + payload.length) + expect(frame[0]).toBe(FrameType.Data) + expect(frame.readUInt32BE(1)).toBe(payload.length) + expect(frame.subarray(FRAME_HEADER_SIZE).toString()).toBe('hello') + }) + + it('encodes a resize frame', () => { + const payload = Buffer.from(JSON.stringify({ cols: 120, rows: 40 })) + const frame = encodeFrame(FrameType.Resize, payload) + + expect(frame[0]).toBe(FrameType.Resize) + expect(frame.readUInt32BE(1)).toBe(payload.length) + }) + + it('encodes an exit frame with exit code', () => { + const payload = Buffer.from(JSON.stringify({ code: 0 })) + const frame = encodeFrame(FrameType.Exit, payload) + + expect(frame[0]).toBe(FrameType.Exit) + const decoded = JSON.parse(frame.subarray(FRAME_HEADER_SIZE).toString()) + expect(decoded.code).toBe(0) + }) + + it('encodes empty payload', () => { + const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0)) + + expect(frame.length).toBe(FRAME_HEADER_SIZE) + expect(frame[0]).toBe(FrameType.Kill) + expect(frame.readUInt32BE(1)).toBe(0) + }) + + it('throws on payload exceeding max size', () => { + const oversized = Buffer.alloc(1024 * 1024 + 1) + expect(() => encodeFrame(FrameType.Data, oversized)).toThrow() + }) +}) + +describe('createFrameParser', () => { + it('parses a single complete frame', () => { + const onFrame = vi.fn() + const parser = createFrameParser(onFrame) + + const payload = Buffer.from('test data') + const frame = encodeFrame(FrameType.Data, payload) + parser.feed(frame) + + expect(onFrame).toHaveBeenCalledOnce() + expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data) + expect(onFrame.mock.calls[0][1].toString()).toBe('test data') + }) + + it('parses multiple frames in one chunk', () => { + const onFrame = vi.fn() + const parser = createFrameParser(onFrame) + + const frame1 = encodeFrame(FrameType.Data, Buffer.from('one')) + const frame2 = encodeFrame(FrameType.Data, Buffer.from('two')) + parser.feed(Buffer.concat([frame1, frame2])) + + expect(onFrame).toHaveBeenCalledTimes(2) + expect(onFrame.mock.calls[0][1].toString()).toBe('one') + expect(onFrame.mock.calls[1][1].toString()).toBe('two') + }) + + it('handles frame split across header boundary', () => { + const onFrame = vi.fn() + const parser = createFrameParser(onFrame) + + const payload = Buffer.from('split test') + const frame = encodeFrame(FrameType.Data, payload) + + // Split in the middle of the header (3 bytes, then the rest) + parser.feed(frame.subarray(0, 3)) + expect(onFrame).not.toHaveBeenCalled() + + parser.feed(frame.subarray(3)) + expect(onFrame).toHaveBeenCalledOnce() + expect(onFrame.mock.calls[0][1].toString()).toBe('split test') + }) + + it('handles frame split in the middle of payload', () => { + const onFrame = vi.fn() + const parser = createFrameParser(onFrame) + + const payload = Buffer.from('payload split') + const frame = encodeFrame(FrameType.Data, payload) + + // Split after header + 3 bytes of payload + const splitPoint = FRAME_HEADER_SIZE + 3 + parser.feed(frame.subarray(0, splitPoint)) + expect(onFrame).not.toHaveBeenCalled() + + parser.feed(frame.subarray(splitPoint)) + expect(onFrame).toHaveBeenCalledOnce() + expect(onFrame.mock.calls[0][1].toString()).toBe('payload split') + }) + + it('handles byte-by-byte feeding', () => { + const onFrame = vi.fn() + const parser = createFrameParser(onFrame) + + const frame = encodeFrame(FrameType.Exit, Buffer.from('{"code":42}')) + + for (let i = 0; i < frame.length; i++) { + parser.feed(frame.subarray(i, i + 1)) + } + + expect(onFrame).toHaveBeenCalledOnce() + expect(onFrame.mock.calls[0][0]).toBe(FrameType.Exit) + expect(JSON.parse(onFrame.mock.calls[0][1].toString())).toEqual({ code: 42 }) + }) + + it('handles zero-length payload frame', () => { + const onFrame = vi.fn() + const parser = createFrameParser(onFrame) + + const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0)) + parser.feed(frame) + + expect(onFrame).toHaveBeenCalledOnce() + expect(onFrame.mock.calls[0][0]).toBe(FrameType.Kill) + expect(onFrame.mock.calls[0][1].length).toBe(0) + }) + + it('parses different frame types correctly', () => { + const onFrame = vi.fn() + const parser = createFrameParser(onFrame) + + const frames = [ + encodeFrame(FrameType.Data, Buffer.from('data')), + encodeFrame(FrameType.Resize, Buffer.from('resize')), + encodeFrame(FrameType.Signal, Buffer.from('signal')) + ] + + parser.feed(Buffer.concat(frames)) + + expect(onFrame).toHaveBeenCalledTimes(3) + expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data) + expect(onFrame.mock.calls[1][0]).toBe(FrameType.Resize) + expect(onFrame.mock.calls[2][0]).toBe(FrameType.Signal) + }) + + it('resets buffer state', () => { + const onFrame = vi.fn() + const parser = createFrameParser(onFrame) + + // Feed partial frame then reset + const frame = encodeFrame(FrameType.Data, Buffer.from('lost')) + parser.feed(frame.subarray(0, 3)) + parser.reset() + + // Feed a new complete frame + const frame2 = encodeFrame(FrameType.Data, Buffer.from('fresh')) + parser.feed(frame2) + + expect(onFrame).toHaveBeenCalledOnce() + expect(onFrame.mock.calls[0][1].toString()).toBe('fresh') + }) +}) diff --git a/src/main/daemon/binary-frame.ts b/src/main/daemon/binary-frame.ts new file mode 100644 index 000000000..e8c4fd6ae --- /dev/null +++ b/src/main/daemon/binary-frame.ts @@ -0,0 +1,56 @@ +export { FrameType } from './types' +import type { FrameType } from './types' +import { FRAME_HEADER_SIZE, FRAME_MAX_PAYLOAD } from './types' + +export { FRAME_HEADER_SIZE } + +export function encodeFrame(type: FrameType, payload: Buffer): Buffer { + if (payload.length > FRAME_MAX_PAYLOAD) { + throw new Error(`Frame payload ${payload.length} exceeds max ${FRAME_MAX_PAYLOAD}`) + } + + const frame = Buffer.allocUnsafe(FRAME_HEADER_SIZE + payload.length) + frame[0] = type + frame.writeUInt32BE(payload.length, 1) + payload.copy(frame, FRAME_HEADER_SIZE) + return frame +} + +export type FrameParser = { + feed(chunk: Buffer): void + reset(): void +} + +export function createFrameParser( + onFrame: (type: FrameType, payload: Buffer) => void +): FrameParser { + let buffer: Buffer = Buffer.alloc(0) + + function parse(): void { + while (buffer.length >= FRAME_HEADER_SIZE) { + const payloadLength = buffer.readUInt32BE(1) + const totalLength = FRAME_HEADER_SIZE + payloadLength + + if (buffer.length < totalLength) { + break + } + + const type = buffer[0] as FrameType + const payload = buffer.subarray(FRAME_HEADER_SIZE, totalLength) + buffer = buffer.subarray(totalLength) + + onFrame(type, payload) + } + } + + return { + feed(chunk: Buffer): void { + buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]) + parse() + }, + + reset(): void { + buffer = Buffer.alloc(0) + } + } +} diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts new file mode 100644 index 000000000..b1a31e0a7 --- /dev/null +++ b/src/main/daemon/client.test.ts @@ -0,0 +1,257 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createServer, type Server, type Socket } from 'net' +import { tmpdir } from 'os' +import { join } from 'path' +import { mkdtempSync, writeFileSync, rmSync } from 'fs' +import { DaemonClient } from './client' +import { encodeNdjson } from './ndjson' +import type { HelloMessage, DaemonRequest, DaemonEvent } from './types' + +function createTestDir(): string { + return mkdtempSync(join(tmpdir(), 'daemon-client-test-')) +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('waitFor timed out') + } + await new Promise((r) => setTimeout(r, 10)) + } +} + +describe('DaemonClient', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: Server + let client: DaemonClient + + beforeEach(() => { + dir = createTestDir() + socketPath = join(dir, 'test.sock') + tokenPath = join(dir, 'test.token') + writeFileSync(tokenPath, 'test-token-123') + }) + + afterEach(async () => { + client?.disconnect() + await new Promise((resolve) => { + if (server?.listening) { + server.close(() => resolve()) + } else { + resolve() + } + }) + rmSync(dir, { recursive: true, force: true }) + }) + + function startMockDaemon(opts?: { + onControlMessage?: (msg: unknown) => string | null + onStreamHello?: (msg: HelloMessage) => void + rejectVersion?: boolean + }): Promise { + return new Promise((resolve) => { + server = createServer((socket) => { + let buffer = '' + socket.on('data', (chunk) => { + buffer += chunk.toString() + let newlineIdx: number + while ((newlineIdx = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIdx) + buffer = buffer.slice(newlineIdx + 1) + if (!line) { + continue + } + + const msg = JSON.parse(line) as HelloMessage | DaemonRequest + + if (msg.type === 'hello') { + const hello = msg as HelloMessage + if (opts?.rejectVersion) { + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Version mismatch' })) + return + } + socket.write(encodeNdjson({ type: 'hello', ok: true })) + if (hello.role === 'stream') { + opts?.onStreamHello?.(hello) + } + } else if (opts?.onControlMessage) { + const response = opts.onControlMessage(msg) + if (response) { + socket.write(response) + } + } + } + }) + }) + + server.listen(socketPath, () => resolve()) + }) + } + + describe('connect', () => { + it('establishes connection with hello handshake', async () => { + const hellos: HelloMessage[] = [] + await startMockDaemon({ + onStreamHello: (msg) => hellos.push(msg) + }) + + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + expect(client.isConnected()).toBe(true) + // Both control and stream sockets should have sent hello + await waitFor(() => hellos.length > 0) + }) + + it('rejects on version mismatch', async () => { + await startMockDaemon({ rejectVersion: true }) + + client = new DaemonClient({ socketPath, tokenPath }) + await expect(client.ensureConnected()).rejects.toThrow() + }) + }) + + describe('RPC', () => { + it('sends request and receives response', async () => { + await startMockDaemon({ + onControlMessage: (msg) => { + const req = msg as { id: string; type: string } + if (req.type === 'listSessions') { + return encodeNdjson({ + id: req.id, + ok: true, + payload: { sessions: [] } + }) + } + return null + } + }) + + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + const result = await client.request('listSessions', undefined) + expect(result).toEqual({ sessions: [] }) + }) + + it('rejects on error response', async () => { + await startMockDaemon({ + onControlMessage: (msg) => { + const req = msg as { id: string; type: string } + return encodeNdjson({ + id: req.id, + ok: false, + error: 'Something went wrong' + }) + } + }) + + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + await expect(client.request('listSessions', undefined)).rejects.toThrow( + 'Something went wrong' + ) + }) + }) + + describe('events', () => { + it('receives stream events', async () => { + let streamSocket: Socket | null = null + await startMockDaemon({ + onStreamHello: () => { + // We need to capture the stream socket to send events on it + } + }) + + // Capture stream socket from server + const origListener = server.listeners('connection')[0] as (s: Socket) => void + server.removeAllListeners('connection') + let socketCount = 0 + server.on('connection', (socket) => { + socketCount++ + if (socketCount === 2) { + streamSocket = socket + } + origListener(socket) + }) + + const events: DaemonEvent[] = [] + client = new DaemonClient({ socketPath, tokenPath }) + client.onEvent((event) => events.push(event as DaemonEvent)) + await client.ensureConnected() + + await waitFor(() => streamSocket !== null) + + // Send a data event on the stream socket + const event: DaemonEvent = { + type: 'event', + event: 'data', + sessionId: 'session-1', + payload: { data: 'hello from daemon' } + } + streamSocket!.write(encodeNdjson(event)) + + await waitFor(() => events.length > 0) + expect(events[0]).toMatchObject({ + type: 'event', + event: 'data', + sessionId: 'session-1' + }) + }) + }) + + describe('disconnect', () => { + it('emits disconnected when server destroys sockets', async () => { + const serverSockets: Socket[] = [] + await startMockDaemon() + server.on('connection', (socket) => serverSockets.push(socket)) + + client = new DaemonClient({ socketPath, tokenPath }) + const disconnected = vi.fn() + client.onDisconnected(disconnected) + await client.ensureConnected() + + // Wait for both sockets to be tracked + await waitFor(() => serverSockets.length >= 2) + + // Destroy all server-side sockets to simulate daemon crash + for (const socket of serverSockets) { + socket.destroy() + } + + await waitFor(() => disconnected.mock.calls.length > 0, 3000) + expect(client.isConnected()).toBe(false) + }) + + it('disconnect() can be called safely when not connected', () => { + client = new DaemonClient({ socketPath, tokenPath }) + expect(() => client.disconnect()).not.toThrow() + }) + }) + + describe('notify (fire-and-forget)', () => { + it('sends request with notify_ prefix without expecting response', async () => { + const received: unknown[] = [] + await startMockDaemon({ + onControlMessage: (msg) => { + received.push(msg) + return null // no response + } + }) + + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + client.notify('write', { sessionId: 'session-1', data: 'hello' }) + + await waitFor(() => received.length > 0) + const msg = received[0] as { id: string; type: string } + expect(msg.id).toMatch(/^notify_/) + expect(msg.type).toBe('write') + }) + }) +}) diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts new file mode 100644 index 000000000..5c9803083 --- /dev/null +++ b/src/main/daemon/client.ts @@ -0,0 +1,295 @@ +import { connect, type Socket } from 'net' +import { readFileSync } from 'fs' +import { randomUUID } from 'crypto' +import { encodeNdjson, createNdjsonParser } from './ndjson' +import { PROTOCOL_VERSION, NOTIFY_PREFIX, DaemonProtocolError } from './types' +import type { HelloMessage, HelloResponse, RpcResponse, DaemonEvent } from './types' + +const CONNECT_TIMEOUT_MS = 5000 +const REQUEST_TIMEOUT_MS = 30000 + +export type DaemonClientOptions = { + socketPath: string + tokenPath: string +} + +type PendingRequest = { + resolve: (value: unknown) => void + reject: (reason: Error) => void + timer: ReturnType +} + +export class DaemonClient { + private socketPath: string + private tokenPath: string + private clientId = randomUUID() + + private controlSocket: Socket | null = null + private streamSocket: Socket | null = null + private connected = false + private disconnectArmed = false + // Why: multiple concurrent spawn() calls from simultaneous pane mounts + // all call ensureConnected(). Without a lock, each starts a separate + // connection attempt, overwriting sockets and triggering "Connection lost". + private connectingPromise: Promise | null = null + + private pendingRequests = new Map() + private eventListeners: ((event: unknown) => void)[] = [] + private disconnectedListeners: (() => void)[] = [] + private requestCounter = 0 + + constructor(opts: DaemonClientOptions) { + this.socketPath = opts.socketPath + this.tokenPath = opts.tokenPath + } + + isConnected(): boolean { + return this.connected + } + + async ensureConnected(): Promise { + if (this.connected) { + return + } + if (this.connectingPromise) { + return this.connectingPromise + } + + this.connectingPromise = this.doConnect() + try { + await this.connectingPromise + } finally { + this.connectingPromise = null + } + } + + private async doConnect(): Promise { + const token = readFileSync(this.tokenPath, 'utf-8').trim() + + try { + // Sequential: control first, then stream + this.controlSocket = await this.connectSocket() + await this.sendHello(this.controlSocket, token, 'control') + this.setupControlParser() + + this.streamSocket = await this.connectSocket() + await this.sendHello(this.streamSocket, token, 'stream') + this.setupStreamParser() + + this.connected = true + this.disconnectArmed = true + + // Handle socket close + const handleClose = () => this.handleDisconnect() + this.controlSocket.on('close', handleClose) + this.controlSocket.on('error', handleClose) + this.streamSocket.on('close', handleClose) + this.streamSocket.on('error', handleClose) + } catch (error) { + this.controlSocket?.destroy() + this.streamSocket?.destroy() + this.controlSocket = null + this.streamSocket = null + this.connected = false + this.disconnectArmed = false + throw error + } + } + + async request(type: string, payload: unknown): Promise { + if (!this.connected || !this.controlSocket) { + throw new DaemonProtocolError('Not connected') + } + + const id = `req-${++this.requestCounter}` + const msg = { id, type, ...(payload !== undefined ? { payload } : {}) } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(id) + reject(new DaemonProtocolError(`Request ${type} timed out after ${REQUEST_TIMEOUT_MS}ms`)) + }, REQUEST_TIMEOUT_MS) + + this.pendingRequests.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + timer + }) + + this.controlSocket!.write(encodeNdjson(msg)) + }) + } + + notify(type: string, payload: unknown): void { + if (!this.connected || !this.controlSocket) { + return + } + + const id = `${NOTIFY_PREFIX}${++this.requestCounter}` + const msg = { id, type, ...(payload !== undefined ? { payload } : {}) } + this.controlSocket.write(encodeNdjson(msg)) + } + + onEvent(listener: (event: unknown) => void): () => void { + this.eventListeners.push(listener) + return () => { + const idx = this.eventListeners.indexOf(listener) + if (idx !== -1) { + this.eventListeners.splice(idx, 1) + } + } + } + + onDisconnected(listener: () => void): () => void { + this.disconnectedListeners.push(listener) + return () => { + const idx = this.disconnectedListeners.indexOf(listener) + if (idx !== -1) { + this.disconnectedListeners.splice(idx, 1) + } + } + } + + disconnect(): void { + this.connected = false + this.disconnectArmed = false + + for (const [id, pending] of this.pendingRequests) { + clearTimeout(pending.timer) + pending.reject(new DaemonProtocolError('Disconnected')) + this.pendingRequests.delete(id) + } + + this.controlSocket?.destroy() + this.streamSocket?.destroy() + this.controlSocket = null + this.streamSocket = null + } + + private connectSocket(): Promise { + return new Promise((resolve, reject) => { + const socket = connect(this.socketPath) + const timer = setTimeout(() => { + socket.destroy() + reject(new DaemonProtocolError('Connection timed out')) + }, CONNECT_TIMEOUT_MS) + + socket.on('connect', () => { + clearTimeout(timer) + resolve(socket) + }) + + socket.on('error', (err) => { + clearTimeout(timer) + reject(err) + }) + }) + } + + private sendHello(socket: Socket, token: string, role: 'control' | 'stream'): Promise { + return new Promise((resolve, reject) => { + const hello: HelloMessage = { + type: 'hello', + version: PROTOCOL_VERSION, + token, + clientId: this.clientId, + role + } + + let buffer = '' + const onData = (chunk: Buffer): void => { + buffer += chunk.toString() + const newlineIdx = buffer.indexOf('\n') + if (newlineIdx === -1) { + return + } + + socket.removeListener('data', onData) + const line = buffer.slice(0, newlineIdx) + try { + const response = JSON.parse(line) as HelloResponse + if (response.ok) { + resolve() + } else { + reject(new DaemonProtocolError(response.error ?? 'Hello rejected')) + } + } catch { + reject(new DaemonProtocolError('Invalid hello response')) + } + } + + socket.on('data', onData) + socket.write(encodeNdjson(hello)) + }) + } + + private setupControlParser(): void { + if (!this.controlSocket) { + return + } + + const parser = createNdjsonParser( + (msg) => { + const response = msg as RpcResponse + if (response.id) { + const pending = this.pendingRequests.get(response.id) + if (pending) { + this.pendingRequests.delete(response.id) + clearTimeout(pending.timer) + if (response.ok) { + pending.resolve(response.payload) + } else { + pending.reject(new DaemonProtocolError(response.error)) + } + } + } + }, + () => {} // Ignore parse errors on control socket + ) + + this.controlSocket.on('data', (chunk) => parser.feed(chunk.toString())) + } + + private setupStreamParser(): void { + if (!this.streamSocket) { + return + } + + const parser = createNdjsonParser( + (msg) => { + const event = msg as DaemonEvent + if (event.type === 'event') { + for (const listener of this.eventListeners) { + listener(event) + } + } + }, + () => {} // Ignore parse errors on stream socket + ) + + this.streamSocket.on('data', (chunk) => parser.feed(chunk.toString())) + } + + private handleDisconnect(): void { + if (!this.disconnectArmed) { + return + } + this.disconnectArmed = false + this.connected = false + + for (const [id, pending] of this.pendingRequests) { + clearTimeout(pending.timer) + pending.reject(new DaemonProtocolError('Connection lost')) + this.pendingRequests.delete(id) + } + + this.controlSocket?.destroy() + this.streamSocket?.destroy() + this.controlSocket = null + this.streamSocket = null + + for (const listener of this.disconnectedListeners) { + listener() + } + } +} diff --git a/src/main/daemon/daemon-entry.test.ts b/src/main/daemon/daemon-entry.test.ts new file mode 100644 index 000000000..f2ac7a7ad --- /dev/null +++ b/src/main/daemon/daemon-entry.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { parseArgs } from './daemon-entry' + +describe('daemon-entry parseArgs', () => { + it('parses --socket and --token flags', () => { + const result = parseArgs(['--socket', '/tmp/test.sock', '--token', '/tmp/test.token']) + expect(result).toEqual({ + socketPath: '/tmp/test.sock', + tokenPath: '/tmp/test.token' + }) + }) + + it('handles flags in any order', () => { + const result = parseArgs(['--token', '/tmp/t.token', '--socket', '/tmp/t.sock']) + expect(result).toEqual({ + socketPath: '/tmp/t.sock', + tokenPath: '/tmp/t.token' + }) + }) + + it('throws when --socket is missing', () => { + expect(() => parseArgs(['--token', '/tmp/t.token'])).toThrow('Usage:') + }) + + it('throws when --token is missing', () => { + expect(() => parseArgs(['--socket', '/tmp/t.sock'])).toThrow('Usage:') + }) + + it('throws with no args', () => { + expect(() => parseArgs([])).toThrow('Usage:') + }) +}) diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts new file mode 100644 index 000000000..ce3e2f024 --- /dev/null +++ b/src/main/daemon/daemon-entry.ts @@ -0,0 +1,68 @@ +/** + * Daemon entry point — runs as a standalone Node.js process. + * + * Usage: node daemon-entry.js --socket /path/to/sock --token /path/to/token + * + * Signals readiness to parent via IPC: { type: 'ready' } + * Shuts down cleanly on SIGTERM. + */ +import { startDaemon, type DaemonHandle } from './daemon-main' +import { createPtySubprocess } from './pty-subprocess' + +export function parseArgs(argv: string[]): { socketPath: string; tokenPath: string } { + let socketPath = '' + let tokenPath = '' + + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--socket' && argv[i + 1]) { + socketPath = argv[i + 1] + i++ + } else if (argv[i] === '--token' && argv[i + 1]) { + tokenPath = argv[i + 1] + i++ + } + } + + if (!socketPath || !tokenPath) { + throw new Error('Usage: daemon-entry --socket --token ') + } + + return { socketPath, tokenPath } +} + +async function main(): Promise { + const { socketPath, tokenPath } = parseArgs(process.argv.slice(2)) + + let daemon: DaemonHandle | null = null + + const shutdown = async (): Promise => { + if (daemon) { + await daemon.shutdown() + daemon = null + } + process.exit(0) + } + + process.on('SIGTERM', () => void shutdown()) + process.on('SIGINT', () => void shutdown()) + + daemon = await startDaemon({ + socketPath, + tokenPath, + spawnSubprocess: (opts) => createPtySubprocess(opts) + }) + + // Signal readiness to parent via IPC (if available) + if (process.send) { + process.send({ type: 'ready' }) + } +} + +// Only auto-run when executed directly (not imported for testing) +const isDirectExecution = !process.env.VITEST +if (isDirectExecution) { + main().catch((err) => { + console.error('[daemon] Fatal:', err) + process.exit(1) + }) +} diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts new file mode 100644 index 000000000..1868af247 --- /dev/null +++ b/src/main/daemon/daemon-init.ts @@ -0,0 +1,174 @@ +import { join } from 'path' +import { app } from 'electron' +import { mkdirSync, existsSync, unlinkSync } from 'fs' +import { fork } from 'child_process' +import { connect } from 'net' +import { DaemonSpawner, type DaemonLauncher } from './daemon-spawner' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { setLocalPtyProvider } from '../ipc/pty' + +let spawner: DaemonSpawner | null = null +let adapter: DaemonPtyAdapter | null = null + +function getRuntimeDir(): string { + const dir = join(app.getPath('userData'), 'daemon') + mkdirSync(dir, { recursive: true }) + return dir +} + +function getHistoryDir(): string { + const dir = join(app.getPath('userData'), 'terminal-history') + mkdirSync(dir, { recursive: true }) + return dir +} + +function getDaemonEntryPath(): string { + const appPath = app.getAppPath() + // Why: electron-builder unpacks daemon-entry.js so child_process.fork() can + // execute it from disk. In packaged apps app.getAppPath() points at + // app.asar, so redirect to the unpacked sibling before joining the script. + const basePath = app.isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + return join(basePath, 'out', 'main', 'daemon-entry.js') +} + +// Why: before spawning a new daemon, check if an existing one is alive by +// attempting a TCP connection to the socket. If it connects, the daemon +// survived from a previous app session — reuse it instead of spawning. +function probeSocket(socketPath: string): Promise { + return new Promise((resolve) => { + if (process.platform !== 'win32' && !existsSync(socketPath)) { + resolve(false) + return + } + const sock = connect({ path: socketPath }) + const timer = setTimeout(() => { + sock.destroy() + resolve(false) + }, 1000) + sock.on('connect', () => { + clearTimeout(timer) + sock.destroy() + resolve(true) + }) + sock.on('error', () => { + clearTimeout(timer) + resolve(false) + }) + }) +} + +function createOutOfProcessLauncher(): DaemonLauncher { + return async (socketPath, tokenPath) => { + const alive = await probeSocket(socketPath) + if (alive) { + // Why: daemon is already running from a previous app session. + // No new process to manage — return a no-op shutdown handle. + return { shutdown: async () => {} } + } + + // Why: stale socket file from a crashed daemon blocks the new server + // from binding. Remove it before spawning. + if (process.platform !== 'win32' && existsSync(socketPath)) { + unlinkSync(socketPath) + } + + const entryPath = getDaemonEntryPath() + const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], { + // Why: detached + unref lets the daemon outlive the Electron process. + // stdio 'ignore' prevents the child from holding the parent's stdout + // open, which would prevent Electron from exiting cleanly. + detached: true, + stdio: ['ignore', 'ignore', 'ignore', 'ipc'] + }) + + // Wait for the daemon to signal readiness via IPC + await new Promise((resolve, reject) => { + const fail = (error: Error): void => { + clearTimeout(timer) + if (child.pid) { + try { + process.kill(child.pid, 'SIGTERM') + } catch { + // Already dead + } + } + reject(error) + } + const timer = setTimeout(() => { + fail(new Error('Daemon startup timed out')) + }, 10000) + + child.on('message', (msg: unknown) => { + if (msg && typeof msg === 'object' && (msg as { type?: string }).type === 'ready') { + clearTimeout(timer) + // Why: disconnect IPC channel and unref so Electron can exit + // without waiting for the daemon. The daemon keeps running. + child.disconnect() + child.unref() + resolve() + } + }) + + child.on('error', (err) => { + fail(err) + }) + + child.on('exit', (code) => { + fail(new Error(`Daemon exited during startup with code ${code}`)) + }) + }) + + return { + shutdown: async () => { + if (child.pid) { + try { + process.kill(child.pid, 'SIGTERM') + } catch { + // Already dead + } + } + } + } + } +} + +export async function initDaemonPtyProvider(): Promise { + const runtimeDir = getRuntimeDir() + + const newSpawner = new DaemonSpawner({ + runtimeDir, + launcher: createOutOfProcessLauncher() + }) + + // Why: assign spawner/adapter only after both succeed. If ensureRunning() + // throws, a stale spawner would prevent shutdownDaemon() from cleaning up + // correctly on retry. + const info = await newSpawner.ensureRunning() + + const newAdapter = new DaemonPtyAdapter({ + socketPath: info.socketPath, + tokenPath: info.tokenPath, + historyPath: getHistoryDir() + }) + + spawner = newSpawner + adapter = newAdapter + setLocalPtyProvider(adapter) +} + +// Why: disconnect from the daemon without killing it. The daemon runs as a +// separate process and survives app quit — sessions stay alive for warm +// reattach on next launch. Leave history sessions marked "unclean" here so a +// later daemon crash while Orca is closed is still recoverable on next launch. +export function disconnectDaemon(): void { + adapter?.disconnectOnly() + adapter = null +} + +/** Kill the daemon and all its sessions. Use for full cleanup only. */ +export async function shutdownDaemon(): Promise { + adapter?.dispose() + adapter = null + await spawner?.shutdown() + spawner = null +} diff --git a/src/main/daemon/daemon-main.test.ts b/src/main/daemon/daemon-main.test.ts new file mode 100644 index 000000000..69613650e --- /dev/null +++ b/src/main/daemon/daemon-main.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'os' +import { join } from 'path' +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs' +import { startDaemon, type DaemonHandle } from './daemon-main' +import { DaemonClient } from './client' + +function createTestDir(): string { + return mkdtempSync(join(tmpdir(), 'daemon-main-test-')) +} + +describe('startDaemon', () => { + let dir: string + let socketPath: string + let tokenPath: string + let daemon: DaemonHandle | null = null + + beforeEach(() => { + dir = createTestDir() + socketPath = join(dir, 'test.sock') + tokenPath = join(dir, 'test.token') + }) + + afterEach(async () => { + await daemon?.shutdown() + daemon = null + rmSync(dir, { recursive: true, force: true }) + }) + + it('creates token file and starts listening', async () => { + daemon = await startDaemon({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + + expect(existsSync(tokenPath)).toBe(true) + const token = readFileSync(tokenPath, 'utf-8') + expect(token.length).toBeGreaterThan(0) + }) + + it('accepts client connections', async () => { + daemon = await startDaemon({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + expect(client.isConnected()).toBe(true) + client.disconnect() + }) + + it('handles session creation via connected client', async () => { + daemon = await startDaemon({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + const result = await client.request<{ isNew: boolean; pid: number }>('createOrAttach', { + sessionId: 'test-session', + cols: 80, + rows: 24 + }) + + expect(result.isNew).toBe(true) + expect(result.pid).toBe(99999) + client.disconnect() + }) + + it('shuts down cleanly', async () => { + daemon = await startDaemon({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + + await daemon.shutdown() + daemon = null + + // Server should no longer accept connections + const client = new DaemonClient({ socketPath, tokenPath }) + await expect(client.ensureConnected()).rejects.toThrow() + }) +}) + +function createMockSubprocess() { + let onExitCb: ((code: number) => void) | null = null + return { + pid: 99999, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), + forceKill: vi.fn(), + signal: vi.fn(), + onData(_cb: (data: string) => void) {}, + onExit(cb: (code: number) => void) { + onExitCb = cb + } + } +} diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts new file mode 100644 index 000000000..63078067c --- /dev/null +++ b/src/main/daemon/daemon-main.ts @@ -0,0 +1,25 @@ +import { DaemonServer, type DaemonServerOptions } from './daemon-server' + +export type DaemonStartOptions = { + socketPath: string + tokenPath: string + spawnSubprocess: DaemonServerOptions['spawnSubprocess'] +} + +export type DaemonHandle = { + shutdown(): Promise +} + +export async function startDaemon(opts: DaemonStartOptions): Promise { + const server = new DaemonServer({ + socketPath: opts.socketPath, + tokenPath: opts.tokenPath, + spawnSubprocess: opts.spawnSubprocess + }) + + await server.start() + + return { + shutdown: () => server.shutdown() + } +} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts new file mode 100644 index 000000000..35070a74a --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -0,0 +1,644 @@ +/* oxlint-disable max-lines */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'os' +import { join } from 'path' +import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonServer } from './daemon-server' +import { getHistorySessionDirName } from './history-paths' +import type { SubprocessHandle } from './session' + +function createTestDir(): string { + return mkdtempSync(join(tmpdir(), 'daemon-adapter-test-')) +} + +function createMockSubprocess(): SubprocessHandle & { + _simulateData: (data: string) => void + _simulateExit: (code: number) => void +} { + let onDataCb: ((data: string) => void) | null = null + let onExitCb: ((code: number) => void) | null = null + return { + pid: 66666, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), + forceKill: vi.fn(), + signal: vi.fn(), + onData(cb) { + onDataCb = cb + }, + onExit(cb) { + onExitCb = cb + }, + _simulateData(data: string) { + onDataCb?.(data) + }, + _simulateExit(code: number) { + onExitCb?.(code) + } + } +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('waitFor timed out') + } + await new Promise((r) => setTimeout(r, 10)) + } +} + +describe('DaemonPtyAdapter (IPtyProvider)', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + let adapter: DaemonPtyAdapter + let lastSubprocess: ReturnType + let lastSpawnOpts: { + sessionId: string + cols: number + rows: number + cwd?: string + env?: Record + command?: string + } | null + + beforeEach(async () => { + dir = createTestDir() + socketPath = join(dir, 'test.sock') + tokenPath = join(dir, 'test.token') + + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: (opts) => { + lastSpawnOpts = opts + lastSubprocess = createMockSubprocess() + return lastSubprocess + } + }) + await server.start() + + adapter = new DaemonPtyAdapter({ socketPath, tokenPath }) + lastSpawnOpts = null + }) + + afterEach(async () => { + adapter?.dispose() + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + describe('spawn', () => { + it('returns a result with an id', async () => { + const result = await adapter.spawn({ cols: 80, rows: 24 }) + expect(result.id).toBeDefined() + expect(typeof result.id).toBe('string') + }) + + it('uses worktreeId as session prefix when provided', async () => { + const result = await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-1' }) + expect(result.id).toContain('wt-1') + }) + }) + + describe('write', () => { + it('sends data to the daemon session', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + adapter.write(id, 'ls\n') + + await new Promise((r) => setTimeout(r, 50)) + expect(lastSubprocess.write).toHaveBeenCalledWith('ls\n') + }) + }) + + describe('resize', () => { + it('resizes the daemon session', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + adapter.resize(id, 120, 40) + + await new Promise((r) => setTimeout(r, 50)) + expect(lastSubprocess.resize).toHaveBeenCalledWith(120, 40) + }) + }) + + describe('shutdown', () => { + it('kills the session', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + await adapter.shutdown(id, false) + expect(lastSubprocess.kill).toHaveBeenCalled() + }) + }) + + describe('sendSignal', () => { + it('sends signal to the session', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + await adapter.sendSignal(id, 'SIGINT') + + expect(lastSubprocess.signal).toHaveBeenCalledWith('SIGINT') + }) + }) + + describe('getCwd', () => { + it('returns empty string when no CWD tracked', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + const cwd = await adapter.getCwd(id) + expect(cwd).toBe('') + }) + }) + + describe('getInitialCwd', () => { + it('returns the cwd passed at spawn time', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24, cwd: '/home/user' }) + const cwd = await adapter.getInitialCwd(id) + expect(cwd).toBe('/home/user') + }) + + it('returns empty string when no cwd provided', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + const cwd = await adapter.getInitialCwd(id) + expect(cwd).toBe('') + }) + }) + + describe('clearBuffer', () => { + it('does not throw', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + await expect(adapter.clearBuffer(id)).resolves.toBeUndefined() + }) + }) + + describe('onData', () => { + it('routes data events from daemon', async () => { + const dataPayloads: { id: string; data: string }[] = [] + adapter.onData((payload) => dataPayloads.push(payload)) + + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + lastSubprocess._simulateData('hello') + + await waitFor(() => dataPayloads.length > 0) + expect(dataPayloads[0]).toEqual({ id, data: 'hello' }) + }) + }) + + describe('onExit', () => { + it('routes exit events from daemon', async () => { + const exits: { id: string; code: number }[] = [] + adapter.onExit((payload) => exits.push(payload)) + + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + lastSubprocess._simulateExit(42) + + await waitFor(() => exits.length > 0) + expect(exits[0]).toEqual({ id, code: 42 }) + }) + }) + + describe('spawn with sessionId (reattach)', () => { + it('returns full snapshot and isReattach when reattaching', async () => { + const sessionId = 'reattach-test-session' + const first = await adapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(first.id).toBe(sessionId) + expect(first.isReattach).toBeUndefined() + + // Write data so the headless emulator captures it + lastSubprocess._simulateData('hello from shell\r\n') + await new Promise((r) => setTimeout(r, 50)) + + // Spawn again with the same sessionId — should reattach + const second = await adapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(second.id).toBe(sessionId) + expect(second.isReattach).toBe(true) + expect(second.snapshot).toBeDefined() + expect(second.snapshot).toContain('hello from shell') + }) + + it('includes rehydrateSequences in snapshot when terminal modes are active', async () => { + const sessionId = 'rehydrate-test' + await adapter.spawn({ cols: 80, rows: 24, sessionId }) + + // Enable bracketed paste mode, then write visible output + lastSubprocess._simulateData('\x1b[?2004h') + lastSubprocess._simulateData('prompt$ ') + await new Promise((r) => setTimeout(r, 50)) + + const result = await adapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.isReattach).toBe(true) + expect(result.snapshot).toContain('\x1b[?2004h') + expect(result.snapshot).toContain('prompt$') + }) + + it('returns plain result for new sessionId', async () => { + const result = await adapter.spawn({ cols: 80, rows: 24, sessionId: 'brand-new' }) + expect(result.id).toBe('brand-new') + expect(result.isReattach).toBeUndefined() + expect(result.snapshot).toBeUndefined() + }) + }) + + describe('attach', () => { + it('reattaches to existing session and receives events', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + + // Create a second adapter simulating app restart + const adapter2 = new DaemonPtyAdapter({ socketPath, tokenPath }) + const dataPayloads: { id: string; data: string }[] = [] + adapter2.onData((payload) => dataPayloads.push(payload)) + + await adapter2.attach(id) + + lastSubprocess._simulateData('after-reattach') + await waitFor(() => dataPayloads.length > 0) + expect(dataPayloads[0]).toEqual({ id, data: 'after-reattach' }) + + adapter2.dispose() + }) + }) + + describe('listProcesses', () => { + it('returns active sessions', async () => { + await adapter.spawn({ cols: 80, rows: 24 }) + await adapter.spawn({ cols: 80, rows: 24 }) + + const procs = await adapter.listProcesses() + expect(procs).toHaveLength(2) + expect(procs[0]).toHaveProperty('id') + expect(procs[0]).toHaveProperty('cwd') + expect(procs[0]).toHaveProperty('title') + }) + }) + + describe('hasChildProcesses / getForegroundProcess', () => { + it('returns false for hasChildProcesses (stub)', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + expect(await adapter.hasChildProcesses(id)).toBe(false) + }) + + it('returns null for getForegroundProcess (stub)', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + expect(await adapter.getForegroundProcess(id)).toBeNull() + }) + }) + + describe('serialize / revive', () => { + it('serialize returns JSON', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + const state = await adapter.serialize([id]) + expect(() => JSON.parse(state)).not.toThrow() + }) + + it('revive does not throw', async () => { + await expect(adapter.revive('{}')).resolves.toBeUndefined() + }) + }) + + describe('getDefaultShell / getProfiles', () => { + it('returns a shell path', async () => { + const shell = await adapter.getDefaultShell() + expect(shell.length).toBeGreaterThan(0) + }) + + it('returns profiles', async () => { + const profiles = await adapter.getProfiles() + expect(Array.isArray(profiles)).toBe(true) + }) + }) + + describe('killed-session tombstones', () => { + it('prevents spawn after shutdown for same sessionId', async () => { + const sessionId = 'tombstone-test' + await adapter.spawn({ cols: 80, rows: 24, sessionId }) + await adapter.shutdown(sessionId, true) + + await expect(adapter.spawn({ cols: 80, rows: 24, sessionId })).rejects.toThrow( + 'was explicitly killed' + ) + }) + + it('allows spawn for different sessionId after shutdown', async () => { + await adapter.spawn({ cols: 80, rows: 24, sessionId: 'kill-me' }) + await adapter.shutdown('kill-me', true) + + const result = await adapter.spawn({ cols: 80, rows: 24, sessionId: 'fresh-one' }) + expect(result.id).toBe('fresh-one') + }) + + it('clearTombstone allows re-spawn', async () => { + const sessionId = 'cleared-tombstone' + await adapter.spawn({ cols: 80, rows: 24, sessionId }) + await adapter.shutdown(sessionId, true) + + adapter.clearTombstone(sessionId) + + const result = await adapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.id).toBe(sessionId) + }) + + it('evicts oldest tombstone when exceeding limit', async () => { + // Why: MAX_TOMBSTONES is 1000, but spawning that many real sessions is + // slow. Instead verify the eviction logic by spawning a small batch and + // checking the oldest tombstone is gone after crossing the cap. We access + // the private map size via the public API: the oldest session should + // become spawnable again once evicted. + const ids: string[] = [] + for (let i = 0; i < 5; i++) { + const id = `evict-${i}` + ids.push(id) + await adapter.spawn({ cols: 80, rows: 24, sessionId: id }) + await adapter.shutdown(id, true) + } + + // All 5 should be tombstoned + for (const id of ids) { + await expect(adapter.spawn({ cols: 80, rows: 24, sessionId: id })).rejects.toThrow( + 'was explicitly killed' + ) + } + + // clearTombstone the first one, then re-kill it — it should still work + adapter.clearTombstone(ids[0]) + await adapter.spawn({ cols: 80, rows: 24, sessionId: ids[0] }) + await adapter.shutdown(ids[0], true) + + // First tombstone was re-added at the end of the Map, so eviction + // order is now [evict-1, evict-2, evict-3, evict-4, evict-0] + await expect(adapter.spawn({ cols: 80, rows: 24, sessionId: ids[0] })).rejects.toThrow( + 'was explicitly killed' + ) + }) + }) + + describe('reconcileOnStartup', () => { + it('returns alive sessions for valid worktrees', async () => { + await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-active' }) + + const { alive, killed } = await adapter.reconcileOnStartup(new Set(['wt-active'])) + expect(alive).toHaveLength(1) + expect(alive[0]).toContain('wt-active') + expect(killed).toHaveLength(0) + }) + + it('kills sessions for removed worktrees', async () => { + await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-removed' }) + + const { alive, killed } = await adapter.reconcileOnStartup(new Set(['wt-other'])) + expect(alive).toHaveLength(0) + expect(killed).toHaveLength(1) + expect(killed[0]).toContain('wt-removed') + }) + + it('handles mix of valid and orphaned sessions', async () => { + await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-keep' }) + await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-delete' }) + + const { alive, killed } = await adapter.reconcileOnStartup(new Set(['wt-keep'])) + expect(alive).toHaveLength(1) + expect(killed).toHaveLength(1) + }) + + it('correctly parses hyphenated worktreeIds', async () => { + const complexId = 'repo-abc::/Users/dev/my-feature-branch' + await adapter.spawn({ cols: 80, rows: 24, worktreeId: complexId }) + + const { alive, killed } = await adapter.reconcileOnStartup(new Set([complexId])) + expect(alive).toHaveLength(1) + expect(killed).toHaveLength(0) + }) + }) + + describe('dispose', () => { + it('disconnects without killing sessions', async () => { + await adapter.spawn({ cols: 80, rows: 24, worktreeId: 'wt-1' }) + adapter.dispose() + + // Session survives — verify by connecting new adapter + const adapter2 = new DaemonPtyAdapter({ socketPath, tokenPath }) + const procs = await adapter2.listProcesses() + expect(procs).toHaveLength(1) + adapter2.dispose() + }) + }) + + describe('history integration', () => { + let historyDir: string + let historyAdapter: DaemonPtyAdapter + + beforeEach(() => { + historyDir = join(dir, 'history') + }) + + afterEach(async () => { + historyAdapter?.dispose() + }) + + it('writes scrollback to disk on data events', async () => { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: 'hist-test' + }) + + lastSubprocess._simulateData('hello from pty\r\n') + await new Promise((r) => setTimeout(r, 50)) + + const scrollback = readFileSync( + join(historyDir, getHistorySessionDirName(id), 'scrollback.bin'), + 'utf-8' + ) + expect(scrollback).toContain('hello from pty') + }) + + it('writes meta.json with endedAt on exit', async () => { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'exit-hist' + }) + + lastSubprocess._simulateExit(0) + await new Promise((r) => setTimeout(r, 50)) + + const meta = JSON.parse( + readFileSync(join(historyDir, getHistorySessionDirName(id), 'meta.json'), 'utf-8') + ) + expect(meta.endedAt).toBeDefined() + expect(meta.exitCode).toBe(0) + }) + + it('removes history on explicit shutdown', async () => { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'shutdown-hist' + }) + + lastSubprocess._simulateData('data') + await new Promise((r) => setTimeout(r, 50)) + + expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true) + + await historyAdapter.shutdown(id, true) + await new Promise((r) => setTimeout(r, 50)) + + expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(false) + }) + + it('returns cold restore data when disk history has unclean shutdown', async () => { + // Simulate a previous daemon crash: write history files without endedAt + const sessionId = 'cold-restore-test' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/myapp', + cols: 120, + rows: 40, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), '$ npm run dev\r\nServer running...\r\n') + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.id).toBe(sessionId) + expect(result.coldRestore).toBeDefined() + expect(result.coldRestore!.scrollback).toContain('Server running') + expect(result.coldRestore!.cwd).toBe('/projects/myapp') + expect(lastSpawnOpts).toMatchObject({ + sessionId, + cwd: '/projects/myapp', + cols: 120, + rows: 40 + }) + }) + + it('returns same cold restore on StrictMode double-mount (sticky cache)', async () => { + const sessionId = 'sticky-cache-test' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/tmp', + cols: 80, + rows: 24, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'cached output') + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const first = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(first.coldRestore).toBeDefined() + + // Second call (StrictMode remount) should get cached data + const second = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(second.coldRestore).toBeDefined() + expect(second.coldRestore!.scrollback).toBe('cached output') + + // After ack, cold restore should not be returned + historyAdapter.ackColdRestore(sessionId) + const third = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(third.coldRestore).toBeUndefined() + }) + + it('records post-cold-restore data to disk for future restores', async () => { + const sessionId = 'post-restore-data' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/tmp', + cols: 80, + rows: 24, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'old output') + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.coldRestore).toBeDefined() + + // Restored scrollback is seeded into the new history file immediately + const seeded = readFileSync( + join(historyDir, getHistorySessionDirName(sessionId), 'scrollback.bin'), + 'utf-8' + ) + expect(seeded).toContain('old output') + + // Simulate new data arriving after cold restore + lastSubprocess._simulateData('new post-restore output\r\n') + await new Promise((r) => setTimeout(r, 50)) + + // History should now contain both the seeded and new data + const scrollback = readFileSync( + join(historyDir, getHistorySessionDirName(sessionId), 'scrollback.bin'), + 'utf-8' + ) + expect(scrollback).toContain('old output') + expect(scrollback).toContain('new post-restore output') + }) + + it('does not cold-restore for clean shutdown (endedAt set)', async () => { + const sessionId = 'clean-exit' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/tmp', + cols: 80, + rows: 24, + startedAt: '2026-04-15T10:00:00Z', + endedAt: '2026-04-15T12:00:00Z', + exitCode: 0 + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'old data') + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.coldRestore).toBeUndefined() + }) + + it('stores history under an encoded directory key for Windows-safe session ids', async () => { + const sessionId = 'repo1::/path/wt1@@abcd' + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/tmp', + sessionId + }) + + expect(id).toBe(sessionId) + expect(existsSync(join(historyDir, getHistorySessionDirName(sessionId), 'meta.json'))).toBe( + true + ) + }) + }) +}) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts new file mode 100644 index 000000000..18edd0a6d --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -0,0 +1,404 @@ +/* oxlint-disable max-lines -- Why: history error-logging .catch() chains add ~10 lines of +safety wiring spread across spawn/event-routing; splitting would scatter tightly coupled +adapter ↔ history lifecycle logic. */ +import { basename } from 'path' +import { existsSync } from 'fs' +import { randomUUID } from 'crypto' +import { DaemonClient } from './client' +import { HistoryManager } from './history-manager' +import { HistoryReader } from './history-reader' +import { supportsPtyStartupBarrier } from './shell-ready' +import type { CreateOrAttachResult, DaemonEvent, ListSessionsResult } from './types' +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' + +export type DaemonPtyAdapterOptions = { + socketPath: string + tokenPath: string + /** Directory for disk-based terminal history. When set, the adapter writes + * raw PTY output to disk for cold restore on daemon crash. */ + historyPath?: string +} + +const MAX_TOMBSTONES = 1000 + +export class TerminalKilledError extends Error { + constructor(sessionId: string) { + super(`Session "${sessionId}" was explicitly killed`) + this.name = 'TerminalKilledError' + } +} + +export class DaemonPtyAdapter implements IPtyProvider { + private client: DaemonClient + private historyManager: HistoryManager | null + private historyReader: HistoryReader | null + private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] + private removeEventListener: (() => void) | null = null + private initialCwds = new Map() + // Why: React re-renders and StrictMode double-mounts can call createOrAttach + // for a session the user just killed. Without tombstones, the daemon would + // create a fresh session — resurrecting a terminal the user explicitly closed. + // Uses a Map so eviction removes the oldest by insertion order, + // matching terminal-host.ts tombstone semantics. + private killedSessionTombstones = new Map() + // Why: React StrictMode double-mounts: mount → cold restore → unmount → + // mount → ??? The sticky cache returns the same cold restore data on the + // second mount until the renderer explicitly acknowledges it. + private coldRestoreCache = new Map() + + constructor(opts: DaemonPtyAdapterOptions) { + this.client = new DaemonClient({ + socketPath: opts.socketPath, + tokenPath: opts.tokenPath + }) + this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null + this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null + } + + getHistoryManager(): HistoryManager | null { + return this.historyManager + } + + async spawn(opts: PtySpawnOptions): Promise { + await this.ensureConnected() + + const sessionId = + opts.sessionId ?? + (opts.worktreeId ? `${opts.worktreeId}@@${randomUUID().slice(0, 8)}` : randomUUID()) + + if (this.killedSessionTombstones.has(sessionId)) { + throw new TerminalKilledError(sessionId) + } + + // 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 + const effectiveCwd = restoreInfo?.cwd ?? opts.cwd + const effectiveCols = restoreInfo?.cols ?? opts.cols + const effectiveRows = restoreInfo?.rows ?? opts.rows + + const result = await this.client.request('createOrAttach', { + sessionId, + cols: effectiveCols, + rows: effectiveRows, + cwd: effectiveCwd, + env: opts.env, + command: opts.command, + shellReadySupported: opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false + }) + + if (effectiveCwd) { + this.initialCwds.set(sessionId, effectiveCwd) + } + + // Why: check sticky cache first — StrictMode double-mounts call spawn + // twice. The second call finds an existing daemon session (isNew=false) + // but should still return the cached cold restore data. + const cachedRestore = this.coldRestoreCache.get(sessionId) + if (cachedRestore) { + return { id: sessionId, coldRestore: cachedRestore } + } + + // Cold restore: daemon created a new session but disk history shows + // an unclean shutdown → return saved scrollback so the renderer can + // display the previous terminal content. Must run BEFORE openSession + // which would overwrite the saved history files. + if (result.isNew && restoreInfo) { + const coldRestore = { scrollback: restoreInfo.scrollback, cwd: restoreInfo.cwd } + this.coldRestoreCache.set(sessionId, coldRestore) + // Why: seed the reopened history with the recovered metadata, not the + // renderer's transient mount-time size, so a second crash restores the + // same terminal context the daemon just revived. + if (this.historyManager) { + void this.historyManager + .openSession(sessionId, { + cwd: restoreInfo.cwd, + cols: restoreInfo.cols, + rows: restoreInfo.rows, + initialScrollback: restoreInfo.scrollback + }) + .catch((err) => console.warn('[history] openSession failed:', sessionId, err)) + } + return { id: sessionId, coldRestore } + } + + if (this.historyManager && result.isNew) { + void this.historyManager + .openSession(sessionId, { + cwd: effectiveCwd ?? '', + cols: effectiveCols, + rows: effectiveRows, + initialScrollback: result.snapshot?.snapshotAnsi + }) + .catch((err) => console.warn('[history] openSession failed:', sessionId, err)) + } + + const isReattach = !result.isNew + if (!isReattach || !result.snapshot) { + return { id: sessionId } + } + + const isAltScreen = result.snapshot.modes.alternateScreen + const snapshotPayload = result.snapshot.rehydrateSequences + result.snapshot.snapshotAnsi + return { + id: sessionId, + snapshot: snapshotPayload, + snapshotCols: result.snapshot.cols, + snapshotRows: result.snapshot.rows, + isReattach: true, + isAlternateScreen: isAltScreen + } + } + + async attach(id: string): Promise { + await this.ensureConnected() + + await this.client.request('createOrAttach', { + sessionId: id, + cols: 80, + rows: 24 + }) + } + + write(id: string, data: string): void { + this.client.notify('write', { sessionId: id, data }) + } + + resize(id: string, cols: number, rows: number): void { + this.client.notify('resize', { sessionId: id, cols, rows }) + } + + async shutdown(id: string, _immediate: boolean): Promise { + await this.client.request('kill', { sessionId: id }) + this.initialCwds.delete(id) + // Why: user explicitly closed this terminal — clean up disk history + // so it doesn't trigger a false cold restore on next launch. + if (this.historyManager) { + void this.historyManager + .removeSession(id) + .catch((err) => console.warn('[history] removeSession failed:', id, err)) + } + + // Why: delete-then-set ensures the entry moves to the end of Map iteration + // order, so re-killing a session doesn't leave it as the first eviction target. + this.killedSessionTombstones.delete(id) + this.killedSessionTombstones.set(id, Date.now()) + if (this.killedSessionTombstones.size > MAX_TOMBSTONES) { + const oldest = this.killedSessionTombstones.keys().next().value + if (oldest) { + this.killedSessionTombstones.delete(oldest) + } + } + } + + ackColdRestore(sessionId: string): void { + this.coldRestoreCache.delete(sessionId) + } + + clearTombstone(sessionId: string): void { + this.killedSessionTombstones.delete(sessionId) + } + + async sendSignal(id: string, signal: string): Promise { + await this.client.request('signal', { sessionId: id, signal }) + } + + async getCwd(id: string): Promise { + try { + const result = await this.client.request<{ cwd: string | null }>('getCwd', { + sessionId: id + }) + return result.cwd ?? '' + } catch { + return '' + } + } + + async getInitialCwd(id: string): Promise { + return this.initialCwds.get(id) ?? '' + } + + async clearBuffer(id: string): Promise { + await this.client.request('clearScrollback', { sessionId: id }) + } + + acknowledgeDataEvent(_id: string, _charCount: number): void { + // No flow control for daemon-backed terminals + } + + async hasChildProcesses(_id: string): Promise { + return false + } + + async getForegroundProcess(_id: string): Promise { + return null + } + + async serialize(ids: string[]): Promise { + const sessions: Record = {} + for (const id of ids) { + sessions[id] = { initialCwd: this.initialCwds.get(id) } + } + return JSON.stringify(sessions) + } + + async revive(_state: string): Promise { + // Sessions already live in the daemon — no revival needed + } + + /** Called on app launch. Lists daemon sessions, kills orphans whose + * workspaceId no longer exists, and caches alive session IDs. */ + async reconcileOnStartup(validWorktreeIds: Set): Promise<{ + alive: string[] + killed: string[] + }> { + await this.ensureConnected() + const result = await this.client.request('listSessions', undefined) + + const alive: string[] = [] + const killed: string[] = [] + + for (const session of result.sessions) { + if (!session.isAlive) { + continue + } + // Why: session IDs use the format `${worktreeId}@@${shortUuid}`. The @@ + // separator is unambiguous — worktreeIds contain hyphens and colons but + // never @@. + const separatorIdx = session.sessionId.lastIndexOf('@@') + const worktreeId = + separatorIdx !== -1 ? session.sessionId.slice(0, separatorIdx) : session.sessionId + + if (!validWorktreeIds.has(worktreeId)) { + try { + await this.client.request('kill', { sessionId: session.sessionId }) + } catch { + /* already dead */ + } + killed.push(session.sessionId) + } else { + alive.push(session.sessionId) + } + } + + return { alive, killed } + } + + async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + await this.ensureConnected() + const result = await this.client.request('listSessions', undefined) + return result.sessions + .filter((s) => s.isAlive) + .map((s) => ({ + id: s.sessionId, + cwd: s.cwd ?? '', + title: 'shell' + })) + } + + async getDefaultShell(): Promise { + if (process.platform === 'win32') { + return process.env.COMSPEC || 'powershell.exe' + } + return process.env.SHELL || '/bin/zsh' + } + + async getProfiles(): Promise<{ name: string; path: string }[]> { + if (process.platform === 'win32') { + return [ + { name: 'PowerShell', path: 'powershell.exe' }, + { name: 'Command Prompt', path: 'cmd.exe' } + ] + } + const shells = ['/bin/zsh', '/bin/bash', '/bin/sh'] + return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s })) + } + + onData(callback: (payload: { id: string; data: string }) => void): () => void { + this.dataListeners.push(callback) + return () => { + const idx = this.dataListeners.indexOf(callback) + if (idx !== -1) { + this.dataListeners.splice(idx, 1) + } + } + } + + onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { + return () => {} + } + + onExit(callback: (payload: { id: string; code: number }) => void): () => void { + this.exitListeners.push(callback) + return () => { + const idx = this.exitListeners.indexOf(callback) + if (idx !== -1) { + this.exitListeners.splice(idx, 1) + } + } + } + + dispose(): void { + this.removeEventListener?.() + this.removeEventListener = null + if (this.historyManager) { + void this.historyManager + .dispose() + .catch((err) => console.warn('[history] dispose failed:', err)) + } + this.client.disconnect() + } + + // Why: for in-process daemon mode, disconnect without flushing history. + // dispose() writes endedAt for all sessions, which would prevent cold + // restore. disconnectOnly() leaves history files in unclean state so + // the next launch detects them as crash-recoverable. + disconnectOnly(): void { + this.removeEventListener?.() + this.removeEventListener = null + this.client.disconnect() + } + + private async ensureConnected(): Promise { + await this.client.ensureConnected() + this.setupEventRouting() + } + + private setupEventRouting(): void { + if (this.removeEventListener) { + return + } + + this.removeEventListener = this.client.onEvent((raw) => { + const event = raw as DaemonEvent + if (event.type !== 'event') { + return + } + + if (event.event === 'data') { + if (this.historyManager) { + void this.historyManager + .appendData(event.sessionId, event.payload.data) + .catch((err) => console.warn('[history] appendData failed:', event.sessionId, err)) + } + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.dataListeners]) { + listener({ id: event.sessionId, data: event.payload.data }) + } + } else if (event.event === 'exit') { + if (this.historyManager) { + void this.historyManager + .closeSession(event.sessionId, event.payload.code) + .catch((err) => console.warn('[history] closeSession failed:', event.sessionId, err)) + } + this.initialCwds.delete(event.sessionId) + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.exitListeners]) { + listener({ id: event.sessionId, code: event.payload.code }) + } + } + }) + } +} diff --git a/src/main/daemon/daemon-pty-provider.test.ts b/src/main/daemon/daemon-pty-provider.test.ts new file mode 100644 index 000000000..a8da8cead --- /dev/null +++ b/src/main/daemon/daemon-pty-provider.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'os' +import { join } from 'path' +import { mkdtempSync, rmSync } from 'fs' +import { DaemonPtyProvider } from './daemon-pty-provider' +import { DaemonServer } from './daemon-server' +import type { SubprocessHandle } from './session' + +function createTestDir(): string { + return mkdtempSync(join(tmpdir(), 'daemon-provider-test-')) +} + +function createMockSubprocess(): SubprocessHandle & { + _simulateData: (data: string) => void + _simulateExit: (code: number) => void +} { + let onDataCb: ((data: string) => void) | null = null + let onExitCb: ((code: number) => void) | null = null + return { + pid: 77777, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), + forceKill: vi.fn(), + signal: vi.fn(), + onData(cb) { + onDataCb = cb + }, + onExit(cb) { + onExitCb = cb + }, + _simulateData(data: string) { + onDataCb?.(data) + }, + _simulateExit(code: number) { + onExitCb?.(code) + } + } +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('waitFor timed out') + } + await new Promise((r) => setTimeout(r, 10)) + } +} + +describe('DaemonPtyProvider', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + let provider: DaemonPtyProvider + let lastSubprocess: ReturnType + + beforeEach(async () => { + dir = createTestDir() + socketPath = join(dir, 'test.sock') + tokenPath = join(dir, 'test.token') + + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => { + lastSubprocess = createMockSubprocess() + return lastSubprocess + } + }) + await server.start() + + provider = new DaemonPtyProvider({ socketPath, tokenPath }) + }) + + afterEach(async () => { + await provider?.cleanup() + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + describe('spawn', () => { + it('creates a session in the daemon and returns an id', async () => { + const result = await provider.spawn({ + cols: 80, + rows: 24, + sessionId: 'test-session' + }) + + expect(result.id).toBe('test-session') + }) + + it('spawns with cwd and env', async () => { + const result = await provider.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + env: { MY_VAR: 'hello' }, + sessionId: 'session-with-env' + }) + + expect(result.id).toBe('session-with-env') + }) + }) + + describe('write', () => { + it('sends data to the daemon session', async () => { + await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) + + // Should not throw + provider.write('s1', 'ls\n') + + // Give the fire-and-forget notify time to arrive + await new Promise((r) => setTimeout(r, 50)) + expect(lastSubprocess.write).toHaveBeenCalledWith('ls\n') + }) + }) + + describe('resize', () => { + it('resizes the daemon session', async () => { + await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) + provider.resize('s1', 120, 40) + + await new Promise((r) => setTimeout(r, 50)) + expect(lastSubprocess.resize).toHaveBeenCalledWith(120, 40) + }) + }) + + describe('shutdown (kill session)', () => { + it('kills the session in the daemon', async () => { + await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) + await provider.shutdown('s1', false) + + expect(lastSubprocess.kill).toHaveBeenCalled() + }) + }) + + describe('data events', () => { + it('delivers data events from the daemon to onData listeners', async () => { + const dataPayloads: { id: string; data: string }[] = [] + provider.onData((payload) => dataPayloads.push(payload)) + + await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) + + // Simulate PTY output from subprocess + lastSubprocess._simulateData('hello from shell') + + await waitFor(() => dataPayloads.length > 0) + expect(dataPayloads[0]).toEqual({ id: 's1', data: 'hello from shell' }) + }) + }) + + describe('exit events', () => { + it('delivers exit events from the daemon to onExit listeners', async () => { + const exits: { id: string; code: number }[] = [] + provider.onExit((payload) => exits.push(payload)) + + await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) + + lastSubprocess._simulateExit(42) + + await waitFor(() => exits.length > 0) + expect(exits[0]).toEqual({ id: 's1', code: 42 }) + }) + }) + + describe('cleanup', () => { + it('disconnects from daemon without killing sessions', async () => { + await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) + await provider.cleanup() + + // Session should still be alive in the daemon — verify by connecting a new provider + const provider2 = new DaemonPtyProvider({ socketPath, tokenPath }) + const result = await provider2.spawn({ cols: 80, rows: 24, sessionId: 's1' }) + + // Should reattach (not create new) since the session is alive + // The daemon's createOrAttach returns isNew=false for existing sessions + expect(result.id).toBe('s1') + await provider2.cleanup() + }) + }) + + describe('multiple sessions', () => { + it('handles multiple concurrent sessions', async () => { + const r1 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'a' }) + const r2 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'b' }) + const r3 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'c' }) + + expect(r1.id).toBe('a') + expect(r2.id).toBe('b') + expect(r3.id).toBe('c') + }) + }) +}) diff --git a/src/main/daemon/daemon-pty-provider.ts b/src/main/daemon/daemon-pty-provider.ts new file mode 100644 index 000000000..6380cc27f --- /dev/null +++ b/src/main/daemon/daemon-pty-provider.ts @@ -0,0 +1,117 @@ +import { DaemonClient } from './client' +import type { DaemonEvent, CreateOrAttachResult } from './types' + +export type DaemonPtyProviderOptions = { + socketPath: string + tokenPath: string +} + +export type DaemonSpawnOptions = { + cols: number + rows: number + sessionId: string + cwd?: string + env?: Record + command?: string +} + +export type DaemonSpawnResult = { + id: string + isNew: boolean + pid: number | null +} + +export class DaemonPtyProvider { + private client: DaemonClient + private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] + private removeEventListener: (() => void) | null = null + + constructor(opts: DaemonPtyProviderOptions) { + this.client = new DaemonClient({ + socketPath: opts.socketPath, + tokenPath: opts.tokenPath + }) + } + + async spawn(opts: DaemonSpawnOptions): Promise { + await this.client.ensureConnected() + this.setupEventRouting() + + const result = await this.client.request('createOrAttach', { + sessionId: opts.sessionId, + cols: opts.cols, + rows: opts.rows, + cwd: opts.cwd, + env: opts.env, + command: opts.command + }) + + return { + id: opts.sessionId, + isNew: result.isNew, + pid: result.pid + } + } + + write(id: string, data: string): void { + this.client.notify('write', { sessionId: id, data }) + } + + resize(id: string, cols: number, rows: number): void { + this.client.notify('resize', { sessionId: id, cols, rows }) + } + + async shutdown(id: string, _immediate: boolean): Promise { + await this.client.request('kill', { sessionId: id }) + } + + onData(callback: (payload: { id: string; data: string }) => void): () => void { + this.dataListeners.push(callback) + return () => { + const idx = this.dataListeners.indexOf(callback) + if (idx !== -1) { + this.dataListeners.splice(idx, 1) + } + } + } + + onExit(callback: (payload: { id: string; code: number }) => void): () => void { + this.exitListeners.push(callback) + return () => { + const idx = this.exitListeners.indexOf(callback) + if (idx !== -1) { + this.exitListeners.splice(idx, 1) + } + } + } + + async cleanup(): Promise { + this.removeEventListener?.() + this.removeEventListener = null + this.client.disconnect() + } + + private setupEventRouting(): void { + if (this.removeEventListener) { + return + } + + this.removeEventListener = this.client.onEvent((raw) => { + const event = raw as DaemonEvent + if (event.type !== 'event') { + return + } + + if (event.event === 'data') { + for (const listener of this.dataListeners) { + listener({ id: event.sessionId, data: event.payload.data }) + } + } else if (event.event === 'exit') { + for (const listener of this.exitListeners) { + listener({ id: event.sessionId, code: event.payload.code }) + } + } + }) + } +} diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts new file mode 100644 index 000000000..72bd0926a --- /dev/null +++ b/src/main/daemon/daemon-server.test.ts @@ -0,0 +1,216 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect } from 'net' +import { tmpdir } from 'os' +import { join } from 'path' +import { mkdtempSync, rmSync, readFileSync } from 'fs' +import { DaemonServer } from './daemon-server' +import { DaemonClient } from './client' +import { encodeNdjson } from './ndjson' +import { PROTOCOL_VERSION } from './types' +import type { SubprocessHandle } from './session' + +function createTestDir(): string { + return mkdtempSync(join(tmpdir(), 'daemon-server-test-')) +} + +function createMockSubprocess(): SubprocessHandle { + let onExitCb: ((code: number) => void) | null = null + return { + pid: 55555, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), + forceKill: vi.fn(), + signal: vi.fn(), + onData(_cb) { + /* stored for future tests */ + }, + onExit(cb) { + onExitCb = cb + } + } +} + +describe('DaemonServer', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + let client: DaemonClient + + beforeEach(() => { + dir = createTestDir() + socketPath = join(dir, 'test.sock') + tokenPath = join(dir, 'test.token') + }) + + afterEach(async () => { + client?.disconnect() + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + async function startServer(): Promise { + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + } + + async function connectClient(): Promise { + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + return client + } + + describe('startup', () => { + it('creates token file and starts listening', async () => { + await startServer() + + const token = readFileSync(tokenPath, 'utf-8') + expect(token.length).toBeGreaterThan(0) + }) + + it('accepts client connections', async () => { + await startServer() + const c = await connectClient() + expect(c.isConnected()).toBe(true) + }) + }) + + describe('RPC routing', () => { + it('handles createOrAttach and returns result', async () => { + await startServer() + const c = await connectClient() + + const result = await c.request('createOrAttach', { + sessionId: 'test-session', + cols: 80, + rows: 24 + }) + + expect(result).toMatchObject({ + isNew: true, + pid: 55555 + }) + }) + + it('handles listSessions', async () => { + await startServer() + const c = await connectClient() + + // Create a session first + await c.request('createOrAttach', { + sessionId: 'test-session', + cols: 80, + rows: 24 + }) + + const result = await c.request<{ sessions: unknown[] }>('listSessions', undefined) + expect(result.sessions).toHaveLength(1) + }) + + it('handles write (fire-and-forget)', async () => { + await startServer() + const c = await connectClient() + + await c.request('createOrAttach', { + sessionId: 'test-session', + cols: 80, + rows: 24 + }) + + // Should not throw + c.notify('write', { sessionId: 'test-session', data: 'ls\n' }) + + // Give the server time to process + await new Promise((r) => setTimeout(r, 50)) + }) + + it('handles resize', async () => { + await startServer() + const c = await connectClient() + + await c.request('createOrAttach', { + sessionId: 'test-session', + cols: 80, + rows: 24 + }) + + const result = await c.request('resize', { + sessionId: 'test-session', + cols: 120, + rows: 40 + }) + + expect(result).toBeDefined() + }) + + it('handles getCwd', async () => { + await startServer() + const c = await connectClient() + + await c.request('createOrAttach', { + sessionId: 'test-session', + cols: 80, + rows: 24 + }) + + const result = await c.request<{ cwd: string | null }>('getCwd', { + sessionId: 'test-session' + }) + + // Mock subprocess doesn't emit OSC-7, so cwd is null + expect(result.cwd).toBeNull() + }) + + it('returns error for unknown session operations', async () => { + await startServer() + const c = await connectClient() + + await expect(c.request('write', { sessionId: 'nonexistent', data: 'hi' })).rejects.toThrow( + 'Session not found' + ) + }) + }) + + describe('authentication', () => { + it('rejects connections with wrong token', async () => { + await startServer() + + // Connect with raw socket and send bad token + const socket = connect(socketPath) + await new Promise((resolve) => socket.on('connect', resolve)) + + socket.write( + encodeNdjson({ + type: 'hello', + version: PROTOCOL_VERSION, + token: 'wrong-token', + clientId: 'bad-client', + role: 'control' + }) + ) + + const response = await new Promise((resolve) => { + socket.on('data', (data) => resolve(data.toString())) + }) + + const parsed = JSON.parse(response.trim()) + expect(parsed.ok).toBe(false) + socket.destroy() + }) + }) + + describe('shutdown', () => { + it('stops accepting connections after shutdown', async () => { + await startServer() + await server.shutdown() + + const c = new DaemonClient({ socketPath, tokenPath }) + await expect(c.ensureConnected()).rejects.toThrow() + }) + }) +}) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts new file mode 100644 index 000000000..bbb0febea --- /dev/null +++ b/src/main/daemon/daemon-server.ts @@ -0,0 +1,274 @@ +import { createServer, type Server, type Socket } from 'net' +import { randomUUID } from 'crypto' +import { writeFileSync, chmodSync, unlinkSync } from 'fs' +import { encodeNdjson, createNdjsonParser } from './ndjson' +import { TerminalHost } from './terminal-host' +import type { SubprocessHandle } from './session' +import { PROTOCOL_VERSION, NOTIFY_PREFIX, type HelloMessage, type DaemonRequest } from './types' + +export type DaemonServerOptions = { + socketPath: string + tokenPath: string + spawnSubprocess: (opts: { + sessionId: string + cols: number + rows: number + cwd?: string + env?: Record + command?: string + }) => SubprocessHandle +} + +type ConnectedClient = { + clientId: string + controlSocket: Socket + streamSocket: Socket | null +} + +export class DaemonServer { + private server: Server | null = null + private token: string + private host: TerminalHost + private socketPath: string + private tokenPath: string + + private clients = new Map() + + constructor(opts: DaemonServerOptions) { + this.socketPath = opts.socketPath + this.tokenPath = opts.tokenPath + this.token = randomUUID() + this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess }) + } + + async start(): Promise { + return new Promise((resolve, reject) => { + this.server = createServer((socket) => this.handleConnection(socket)) + + this.server.on('error', (err) => { + reject(err) + }) + + this.server.listen(this.socketPath, () => { + writeFileSync(this.tokenPath, this.token, { mode: 0o600 }) + try { + chmodSync(this.socketPath, 0o600) + } catch { + // Best-effort on platforms that support it + } + resolve() + }) + }) + } + + async shutdown(): Promise { + this.host.dispose() + + for (const [, client] of this.clients) { + client.controlSocket.destroy() + client.streamSocket?.destroy() + } + this.clients.clear() + + return new Promise((resolve) => { + if (this.server) { + this.server.close(() => { + try { + unlinkSync(this.socketPath) + } catch {} + resolve() + }) + this.server = null + } else { + resolve() + } + }) + } + + private handleConnection(socket: Socket): void { + const parser = createNdjsonParser( + (msg) => this.handleFirstMessage(socket, msg, parser), + () => { + socket.destroy() + } + ) + + socket.on('data', (chunk) => parser.feed(chunk.toString())) + socket.on('error', () => socket.destroy()) + } + + private handleFirstMessage( + socket: Socket, + msg: unknown, + _parser: ReturnType + ): void { + const hello = msg as HelloMessage + if (hello.type !== 'hello') { + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' })) + socket.destroy() + return + } + + if (hello.version !== PROTOCOL_VERSION) { + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Protocol version mismatch' })) + socket.destroy() + return + } + + if (hello.token !== this.token) { + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' })) + socket.destroy() + return + } + + socket.write(encodeNdjson({ type: 'hello', ok: true })) + + if (hello.role === 'control') { + const client: ConnectedClient = { + clientId: hello.clientId, + controlSocket: socket, + streamSocket: null + } + this.clients.set(hello.clientId, client) + this.setupControlSocket(socket, hello.clientId) + } else if (hello.role === 'stream') { + const client = this.clients.get(hello.clientId) + if (client) { + client.streamSocket = socket + } + // Stream socket is receive-only from daemon's perspective (for events) + } + } + + private setupControlSocket(socket: Socket, clientId: string): void { + const parser = createNdjsonParser( + (msg) => this.handleRequest(socket, clientId, msg as DaemonRequest), + () => {} // Ignore parse errors + ) + + // Remove the initial data listener and replace with the RPC parser + socket.removeAllListeners('data') + socket.on('data', (chunk) => parser.feed(chunk.toString())) + + socket.on('close', () => { + this.clients.delete(clientId) + }) + } + + private async handleRequest( + socket: Socket, + clientId: string, + request: DaemonRequest + ): Promise { + const isNotify = request.id.startsWith(NOTIFY_PREFIX) + + try { + const result = await this.routeRequest(clientId, request) + if (!isNotify) { + socket.write(encodeNdjson({ id: request.id, ok: true, payload: result })) + } + } catch (err) { + if (!isNotify) { + socket.write( + encodeNdjson({ + id: request.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }) + ) + } + } + } + + private async routeRequest(clientId: string, request: DaemonRequest): Promise { + const client = this.clients.get(clientId) + + switch (request.type) { + case 'createOrAttach': { + const p = request.payload + const result = await this.host.createOrAttach({ + sessionId: p.sessionId, + cols: p.cols, + rows: p.rows, + cwd: p.cwd, + env: p.env, + command: p.command, + shellReadySupported: p.shellReadySupported, + streamClient: { + onData: (data) => { + if (client?.streamSocket) { + client.streamSocket.write( + encodeNdjson({ + type: 'event', + event: 'data', + sessionId: p.sessionId, + payload: { data } + }) + ) + } + }, + onExit: (code) => { + if (client?.streamSocket) { + client.streamSocket.write( + encodeNdjson({ + type: 'event', + event: 'exit', + sessionId: p.sessionId, + payload: { code } + }) + ) + } + } + } + }) + return { + isNew: result.isNew, + snapshot: result.snapshot, + pid: result.pid, + shellState: result.shellState + } + } + + case 'write': + this.host.write(request.payload.sessionId, request.payload.data) + return {} + + case 'resize': + this.host.resize(request.payload.sessionId, request.payload.cols, request.payload.rows) + return {} + + case 'kill': + this.host.kill(request.payload.sessionId) + return {} + + case 'signal': + this.host.signal(request.payload.sessionId, request.payload.signal) + return {} + + case 'detach': + // Note: detach token handling is simplified here — full implementation + // would track tokens per client + return {} + + case 'getCwd': + return { cwd: this.host.getCwd(request.payload.sessionId) } + + case 'clearScrollback': + this.host.clearScrollback(request.payload.sessionId) + return {} + + case 'listSessions': + return { sessions: this.host.listSessions() } + + case 'shutdown': + if (request.payload.killSessions) { + this.host.dispose() + } + process.nextTick(() => this.shutdown()) + return {} + + default: + throw new Error(`Unknown request type: ${(request as { type: string }).type}`) + } + } +} diff --git a/src/main/daemon/daemon-spawner.test.ts b/src/main/daemon/daemon-spawner.test.ts new file mode 100644 index 000000000..3ea44a4c8 --- /dev/null +++ b/src/main/daemon/daemon-spawner.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'os' +import { join } from 'path' +import { mkdtempSync, rmSync } from 'fs' +import { DaemonSpawner, getDaemonSocketPath, getDaemonTokenPath } from './daemon-spawner' +import { startDaemon, type DaemonHandle } from './daemon-main' +import { DaemonClient } from './client' +import type { SubprocessHandle } from './session' +import { PROTOCOL_VERSION } from './types' + +function createTestDir(): string { + return mkdtempSync(join(tmpdir(), 'daemon-spawner-test-')) +} + +function createMockSubprocess(): SubprocessHandle { + let onExitCb: ((code: number) => void) | null = null + return { + pid: 88888, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), + forceKill: vi.fn(), + signal: vi.fn(), + onData(_cb: (data: string) => void) {}, + onExit(cb: (code: number) => void) { + onExitCb = cb + } + } +} + +describe('DaemonSpawner', () => { + let dir: string + let spawner: DaemonSpawner + let activeDaemons: DaemonHandle[] + + beforeEach(() => { + dir = createTestDir() + activeDaemons = [] + }) + + afterEach(async () => { + await spawner?.shutdown() + for (const d of activeDaemons) { + await d.shutdown().catch(() => {}) + } + rmSync(dir, { recursive: true, force: true }) + }) + + function createSpawner(): DaemonSpawner { + spawner = new DaemonSpawner({ + runtimeDir: dir, + launcher: async (socketPath, tokenPath) => { + const handle = await startDaemon({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + activeDaemons.push(handle) + return { shutdown: () => handle.shutdown() } + } + }) + return spawner + } + + describe('ensureRunning', () => { + it('uses protocol-scoped socket and token paths', () => { + const socketPath = getDaemonSocketPath(dir) + const tokenPath = getDaemonTokenPath(dir) + + if (process.platform === 'win32') { + expect(socketPath).toContain(`orca-terminal-host-v${PROTOCOL_VERSION}`) + } else { + expect(socketPath).toBe(join(dir, `daemon-v${PROTOCOL_VERSION}.sock`)) + } + expect(tokenPath).toBe(join(dir, `daemon-v${PROTOCOL_VERSION}.token`)) + }) + + it('starts daemon and returns connection info', async () => { + const s = createSpawner() + const info = await s.ensureRunning() + + expect(info.socketPath).toContain(dir) + expect(info.tokenPath).toContain(dir) + }) + + it('returns same info on subsequent calls', async () => { + const s = createSpawner() + const info1 = await s.ensureRunning() + const info2 = await s.ensureRunning() + + expect(info1.socketPath).toBe(info2.socketPath) + expect(info1.tokenPath).toBe(info2.tokenPath) + }) + + it('daemon is connectable after ensureRunning', async () => { + const s = createSpawner() + const info = await s.ensureRunning() + + const client = new DaemonClient({ + socketPath: info.socketPath, + tokenPath: info.tokenPath + }) + await client.ensureConnected() + expect(client.isConnected()).toBe(true) + client.disconnect() + }) + + it('daemon can create sessions', async () => { + const s = createSpawner() + const info = await s.ensureRunning() + + const client = new DaemonClient({ + socketPath: info.socketPath, + tokenPath: info.tokenPath + }) + await client.ensureConnected() + + const result = await client.request<{ isNew: boolean }>('createOrAttach', { + sessionId: 'test-session', + cols: 80, + rows: 24 + }) + expect(result.isNew).toBe(true) + client.disconnect() + }) + }) + + describe('shutdown', () => { + it('stops the daemon', async () => { + const s = createSpawner() + const info = await s.ensureRunning() + await s.shutdown() + + const client = new DaemonClient({ + socketPath: info.socketPath, + tokenPath: info.tokenPath + }) + await expect(client.ensureConnected()).rejects.toThrow() + }) + + it('can be called when daemon is not running', async () => { + const s = createSpawner() + await expect(s.shutdown()).resolves.toBeUndefined() + }) + + it('allows re-start after shutdown', async () => { + const s = createSpawner() + await s.ensureRunning() + await s.shutdown() + + const info = await s.ensureRunning() + const client = new DaemonClient({ + socketPath: info.socketPath, + tokenPath: info.tokenPath + }) + await client.ensureConnected() + expect(client.isConnected()).toBe(true) + client.disconnect() + }) + }) +}) diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts new file mode 100644 index 000000000..7fe8d39c1 --- /dev/null +++ b/src/main/daemon/daemon-spawner.ts @@ -0,0 +1,68 @@ +import { createHash } from 'crypto' +import { join } from 'path' +import { PROTOCOL_VERSION } from './types' + +export type DaemonConnectionInfo = { + socketPath: string + tokenPath: string +} + +export type DaemonProcessHandle = { + shutdown(): Promise +} + +export type DaemonLauncher = (socketPath: string, tokenPath: string) => Promise + +export type DaemonSpawnerOptions = { + runtimeDir: string + launcher: DaemonLauncher +} + +export class DaemonSpawner { + private runtimeDir: string + private launcher: DaemonLauncher + private handle: DaemonProcessHandle | null = null + private socketPath: string + private tokenPath: string + + constructor(opts: DaemonSpawnerOptions) { + this.runtimeDir = opts.runtimeDir + this.launcher = opts.launcher + this.socketPath = getDaemonSocketPath(this.runtimeDir) + this.tokenPath = getDaemonTokenPath(this.runtimeDir) + } + + async ensureRunning(): Promise { + if (this.handle) { + return { socketPath: this.socketPath, tokenPath: this.tokenPath } + } + + this.handle = await this.launcher(this.socketPath, this.tokenPath) + + return { socketPath: this.socketPath, tokenPath: this.tokenPath } + } + + async shutdown(): Promise { + if (!this.handle) { + return + } + const handle = this.handle + this.handle = null + await handle.shutdown() + } +} + +export function getDaemonSocketPath(runtimeDir: string): string { + // Why: Windows IPC servers use named pipes rather than filesystem socket + // files. Include the protocol version in the endpoint name so a daemon from + // an older build is never reused after a breaking protocol change. + if (process.platform === 'win32') { + const suffix = createHash('sha256').update(runtimeDir).digest('hex').slice(0, 12) + return `\\\\?\\pipe\\orca-terminal-host-v${PROTOCOL_VERSION}-${suffix}` + } + return join(runtimeDir, `daemon-v${PROTOCOL_VERSION}.sock`) +} + +export function getDaemonTokenPath(runtimeDir: string): string { + return join(runtimeDir, `daemon-v${PROTOCOL_VERSION}.token`) +} diff --git a/src/main/daemon/fixtures/ratatui-tui.py b/src/main/daemon/fixtures/ratatui-tui.py new file mode 100644 index 000000000..de8100101 --- /dev/null +++ b/src/main/daemon/fixtures/ratatui-tui.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Simulates a ratatui alt-screen TUI (like Codex) for reattach testing. + +Behavior mimicked: +- Enters alternate screen buffer +- Enables bracketed paste mode +- Renders content with absolute cursor positioning (like ratatui cell-diffing) +- On SIGWINCH: reads terminal size, clears viewport, full repaint +- Emits OSC marker after each render so tests can synchronize +""" +import sys +import os +import signal +import time + + +def get_size(): + try: + cols, rows = os.get_terminal_size() + return cols, rows + except Exception: + return 80, 24 + + +render_count = 0 + + +def render(cols, rows): + global render_count + render_count += 1 + label = "initial" if render_count == 1 else f"render-{render_count}" + + # Synchronized update mode (crossterm uses this) + sys.stdout.write("\x1b[?2026h") + + # ratatui terminal.clear(): MoveTo(viewport_origin) + Clear(FromCursorDown) + # For fullscreen alt-screen, viewport origin is (0, 0) + sys.stdout.write("\x1b[1;1H\x1b[J") + + # ratatui flush() with cell-diffing — absolute positioning per line + sys.stdout.write(f"\x1b[1;1Hgpt-5.4 default \xc2\xb7 ~/test-workspace") + sys.stdout.write(f"\x1b[2;1H") + sys.stdout.write(f"\x1b[3;1Htest123 ({label})") + sys.stdout.write(f"\x1b[4;1H") + sys.stdout.write(f"\x1b[5;1H> {cols}x{rows}") + + # End synchronized update + sys.stdout.write("\x1b[?2026l") + sys.stdout.flush() + + # Marker so the test can detect render completion + sys.stdout.write(f"\x1b]777;render-done-{render_count}\x07") + sys.stdout.flush() + + +def main(): + # Enter alternate screen (like crossterm enable_raw_mode + EnterAlternateScreen) + sys.stdout.write("\x1b[?1049h") + # Enable bracketed paste + sys.stdout.write("\x1b[?2004h") + sys.stdout.flush() + + cols, rows = get_size() + render(cols, rows) + + def handle_sigwinch(_signum, _frame): + c, r = get_size() + render(c, r) + + signal.signal(signal.SIGWINCH, handle_sigwinch) + + # Keep alive + try: + while True: + time.sleep(10) + except KeyboardInterrupt: + pass + finally: + # Exit alternate screen + sys.stdout.write("\x1b[?1049l") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts new file mode 100644 index 000000000..d59a8d798 --- /dev/null +++ b/src/main/daemon/headless-emulator.test.ts @@ -0,0 +1,205 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { HeadlessEmulator } from './headless-emulator' + +describe('HeadlessEmulator', () => { + let emulator: HeadlessEmulator + + afterEach(() => { + emulator?.dispose() + }) + + describe('construction', () => { + it('creates with specified dimensions', () => { + emulator = new HeadlessEmulator({ cols: 120, rows: 40 }) + const snapshot = emulator.getSnapshot() + expect(snapshot.cols).toBe(120) + expect(snapshot.rows).toBe(40) + }) + + it('defaults cwd to null', () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + expect(emulator.getSnapshot().cwd).toBeNull() + }) + }) + + describe('write and snapshot', () => { + it('captures written text in snapshot', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('hello world') + + const snapshot = emulator.getSnapshot() + expect(snapshot.snapshotAnsi).toContain('hello world') + }) + + it('captures colored text', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[31mred text\x1b[0m') + + const snapshot = emulator.getSnapshot() + expect(snapshot.snapshotAnsi).toContain('red text') + }) + }) + + describe('OSC-7 CWD tracking', () => { + it('parses OSC-7 file URI to extract CWD', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b]7;file://localhost/Users/test/project\x07') + + expect(emulator.getSnapshot().cwd).toBe('/Users/test/project') + }) + + it('handles OSC-7 with empty host', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b]7;file:///home/user/work\x07') + + expect(emulator.getSnapshot().cwd).toBe('/home/user/work') + }) + + it('updates CWD when new OSC-7 arrives', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b]7;file:///first\x07') + expect(emulator.getSnapshot().cwd).toBe('/first') + + await emulator.write('\x1b]7;file:///second\x07') + expect(emulator.getSnapshot().cwd).toBe('/second') + }) + + it('decodes percent-encoded paths', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b]7;file:///Users/test/my%20project\x07') + + expect(emulator.getSnapshot().cwd).toBe('/Users/test/my project') + }) + + it('normalizes Windows drive-letter OSC-7 paths', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + + try { + await emulator.write('\x1b]7;file:///C:/Users/test/project\x07') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(emulator.getSnapshot().cwd).toBe('C:/Users/test/project') + }) + + it('preserves Windows UNC OSC-7 paths', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + + try { + await emulator.write('\x1b]7;file://server/share/project\x07') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(emulator.getSnapshot().cwd).toBe('\\\\server\\share\\project') + }) + + it('handles OSC-7 with ST terminator', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b]7;file:///path/here\x1b\\') + + expect(emulator.getSnapshot().cwd).toBe('/path/here') + }) + }) + + describe('resize', () => { + it('updates dimensions', () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + emulator.resize(120, 40) + + const snapshot = emulator.getSnapshot() + expect(snapshot.cols).toBe(120) + expect(snapshot.rows).toBe(40) + }) + }) + + describe('clear scrollback (CSI 3J)', () => { + it('detects CSI 3J and clears scrollback', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + // Write enough lines to push into scrollback + const lines = Array.from({ length: 30 }, (_, i) => `line ${i}\r\n`).join('') + await emulator.write(lines) + + const before = emulator.getSnapshot() + expect(before.scrollbackLines).toBeGreaterThan(0) + + await emulator.write('\x1b[3J') + const after = emulator.getSnapshot() + expect(after.scrollbackLines).toBe(0) + }) + }) + + describe('onData callback', () => { + it('fires onData for terminal query responses', async () => { + const responses: string[] = [] + emulator = new HeadlessEmulator({ + cols: 80, + rows: 24, + onData: (data) => responses.push(data) + }) + + // DA1 query — xterm.js will respond with a device attributes string + await emulator.write('\x1b[c') + + expect(responses.length).toBeGreaterThan(0) + }) + }) + + describe('terminal modes', () => { + it('tracks bracketed paste mode', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + expect(emulator.getSnapshot().modes.bracketedPaste).toBe(false) + + await emulator.write('\x1b[?2004h') + expect(emulator.getSnapshot().modes.bracketedPaste).toBe(true) + + await emulator.write('\x1b[?2004l') + expect(emulator.getSnapshot().modes.bracketedPaste).toBe(false) + }) + + it('tracks alternate screen mode', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + expect(emulator.getSnapshot().modes.alternateScreen).toBe(false) + + await emulator.write('\x1b[?1049h') + expect(emulator.getSnapshot().modes.alternateScreen).toBe(true) + + await emulator.write('\x1b[?1049l') + expect(emulator.getSnapshot().modes.alternateScreen).toBe(false) + }) + }) + + describe('rehydration sequences', () => { + it('generates rehydration for non-default modes', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[?2004h') + + const snapshot = emulator.getSnapshot() + expect(snapshot.rehydrateSequences).toContain('\x1b[?2004h') + }) + + it('generates empty rehydration when all modes are default', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('just plain text') + + const snapshot = emulator.getSnapshot() + expect(snapshot.rehydrateSequences).toBe('') + }) + }) + + describe('dispose', () => { + it('can be disposed without error', () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + expect(() => emulator.dispose()).not.toThrow() + }) + }) +}) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts new file mode 100644 index 000000000..101cb471d --- /dev/null +++ b/src/main/daemon/headless-emulator.ts @@ -0,0 +1,156 @@ +import './xterm-env-polyfill' +import { Terminal } from '@xterm/headless' +import { SerializeAddon } from '@xterm/addon-serialize' +import type { TerminalSnapshot, TerminalModes } from './types' + +export type HeadlessEmulatorOptions = { + cols: number + rows: number + scrollback?: number + onData?: (data: string) => void +} + +const DEFAULT_SCROLLBACK = 5000 + +function parseFileUriPath(uri: string): string | null { + try { + const url = new URL(uri) + if (url.protocol !== 'file:') { + return null + } + + const decodedPath = decodeURIComponent(url.pathname) + if (process.platform !== 'win32') { + return decodedPath + } + + // Why: Windows OSC-7 cwd updates can describe both drive-letter paths + // (`file:///C:/repo`) and UNC shares (`file://server/share/repo`). Use the + // hostname when present so live cwd tracking, snapshots, and restore all + // round-trip to a native Windows path instead of dropping the server name. + if (url.hostname) { + return `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}` + } + if (/^\/[A-Za-z]:/.test(decodedPath)) { + return decodedPath.slice(1) + } + return decodedPath.replace(/\//g, '\\') + } catch { + return null + } +} + +export class HeadlessEmulator { + private terminal: Terminal + private serializer: SerializeAddon + private cwd: string | null = null + private disposed = false + + constructor(opts: HeadlessEmulatorOptions) { + this.terminal = new Terminal({ + cols: opts.cols, + rows: opts.rows, + scrollback: opts.scrollback ?? DEFAULT_SCROLLBACK, + allowProposedApi: true + }) + + this.serializer = new SerializeAddon() + this.terminal.loadAddon(this.serializer) + + if (opts.onData) { + this.terminal.onData(opts.onData) + } + } + + write(data: string): Promise { + if (this.disposed) { + return Promise.resolve() + } + + this.scanOsc7(data) + return new Promise((resolve) => { + this.terminal.write(data, resolve) + }) + } + + resize(cols: number, rows: number): void { + if (this.disposed) { + return + } + this.terminal.resize(cols, rows) + } + + getSnapshot(): TerminalSnapshot { + const modes = this.getModes() + return { + snapshotAnsi: this.serializer.serialize(), + scrollbackAnsi: '', + rehydrateSequences: this.buildRehydrateSequences(modes), + cwd: this.cwd, + modes, + cols: this.terminal.cols, + rows: this.terminal.rows, + scrollbackLines: this.terminal.buffer.normal.length - this.terminal.rows + } + } + + get isAlternateScreen(): boolean { + return this.terminal.buffer.active.type === 'alternate' + } + + getCwd(): string | null { + return this.cwd + } + + clearScrollback(): void { + this.terminal.clear() + } + + dispose(): void { + this.disposed = true + this.terminal.dispose() + } + + private scanOsc7(data: string): void { + // OSC-7 format: ESC ] 7 ; BEL or ESC ] 7 ; ST + // BEL = \x07, ST = ESC \ + // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars + const osc7Re = /\x1b\]7;([^\x07\x1b]*?)(?:\x07|\x1b\\)/g + let match: RegExpExecArray | null + while ((match = osc7Re.exec(data)) !== null) { + this.parseOsc7Uri(match[1]) + } + } + + private parseOsc7Uri(uri: string): void { + const parsed = parseFileUriPath(uri) + if (parsed) { + this.cwd = parsed + } + } + + private getModes(): TerminalModes { + const buffer = this.terminal.buffer.active + return { + bracketedPaste: this.terminal.modes.bracketedPasteMode, + mouseTracking: this.terminal.modes.mouseTrackingMode !== 'none', + applicationCursor: + buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false, + alternateScreen: buffer.type === 'alternate' + } + } + + private buildRehydrateSequences(modes: TerminalModes): string { + const seqs: string[] = [] + if (modes.bracketedPaste) { + seqs.push('\x1b[?2004h') + } + if (modes.applicationCursor) { + seqs.push('\x1b[?1h') + } + if (modes.alternateScreen) { + seqs.push('\x1b[?1049h') + } + return seqs.join('') + } +} diff --git a/src/main/daemon/history-manager.test.ts b/src/main/daemon/history-manager.test.ts new file mode 100644 index 000000000..69cce6146 --- /dev/null +++ b/src/main/daemon/history-manager.test.ts @@ -0,0 +1,376 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { tmpdir } from 'os' +import { join } from 'path' +import { mkdtempSync, rmSync, readFileSync, existsSync, chmodSync } from 'fs' +import { HistoryManager } from './history-manager' +import { getHistorySessionDirName } from './history-paths' + +function createTestDir(): string { + return mkdtempSync(join(tmpdir(), 'history-mgr-test-')) +} + +function sessionPath(baseDir: string, sessionId: string, file: string): string { + return join(baseDir, getHistorySessionDirName(sessionId), file) +} + +describe('HistoryManager', () => { + let dir: string + let mgr: HistoryManager + + beforeEach(() => { + dir = createTestDir() + mgr = new HistoryManager(dir) + }) + + afterEach(async () => { + await mgr.dispose() + rmSync(dir, { recursive: true, force: true }) + }) + + describe('openSession', () => { + it('creates meta.json with session metadata', async () => { + await mgr.openSession('sess-1', { cwd: '/home/user', cols: 80, rows: 24 }) + + const metaPath = sessionPath(dir, 'sess-1', 'meta.json') + expect(existsSync(metaPath)).toBe(true) + + const meta = JSON.parse(readFileSync(metaPath, 'utf-8')) + expect(meta.cwd).toBe('/home/user') + expect(meta.cols).toBe(80) + expect(meta.rows).toBe(24) + expect(meta.startedAt).toBeDefined() + expect(meta.endedAt).toBeNull() + expect(meta.exitCode).toBeNull() + }) + + it('creates scrollback.bin file', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 120, rows: 40 }) + + const scrollbackPath = sessionPath(dir, 'sess-1', 'scrollback.bin') + expect(existsSync(scrollbackPath)).toBe(true) + }) + + it('seeds scrollback with initial snapshot', async () => { + await mgr.openSession('sess-1', { + cwd: '/tmp', + cols: 80, + rows: 24, + initialScrollback: 'previous output\r\n' + }) + + const data = readFileSync(sessionPath(dir, 'sess-1', 'scrollback.bin'), 'utf-8') + expect(data).toBe('previous output\r\n') + }) + }) + + describe('appendData', () => { + it('appends PTY output to scrollback.bin', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + + await mgr.appendData('sess-1', 'hello ') + await mgr.appendData('sess-1', 'world\r\n') + + const data = readFileSync(sessionPath(dir, 'sess-1', 'scrollback.bin'), 'utf-8') + expect(data).toBe('hello world\r\n') + }) + + it('ignores data for unknown sessions', async () => { + // Should not throw + await mgr.appendData('nonexistent', 'data') + }) + + it('persists the latest cwd from OSC-7 updates', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp/original', cols: 80, rows: 24 }) + + await mgr.appendData('sess-1', '\x1b]7;file:///tmp/updated%20cwd\x07prompt$ ') + + const meta = mgr.readMeta('sess-1') + expect(meta?.cwd).toBe('/tmp/updated cwd') + }) + + it('persists Windows UNC cwd updates from OSC-7', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + + try { + await mgr.openSession('sess-1', { cwd: 'C:\\start', cols: 80, rows: 24 }) + await mgr.appendData('sess-1', '\x1b]7;file://server/share/project\x07') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const meta = mgr.readMeta('sess-1') + expect(meta?.cwd).toBe('\\\\server\\share\\project') + }) + }) + + describe('closeSession', () => { + it('writes endedAt and exitCode to meta.json', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + await mgr.closeSession('sess-1', 0) + + const meta = JSON.parse(readFileSync(sessionPath(dir, 'sess-1', 'meta.json'), 'utf-8')) + expect(meta.endedAt).toBeDefined() + expect(meta.exitCode).toBe(0) + }) + + it('flushes pending data before closing', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + await mgr.appendData('sess-1', 'final output') + await mgr.closeSession('sess-1', 1) + + const data = readFileSync(sessionPath(dir, 'sess-1', 'scrollback.bin'), 'utf-8') + expect(data).toBe('final output') + }) + + it('flushes buffered partial escape on close', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + // Send data ending with a partial CSI 3J prefix — gets held in buffer + await mgr.appendData('sess-1', 'prompt$ \x1b[') + await mgr.closeSession('sess-1', 0) + + const data = readFileSync(sessionPath(dir, 'sess-1', 'scrollback.bin'), 'utf-8') + expect(data).toBe('prompt$ \x1b[') + }) + + it('ignores close for unknown sessions', async () => { + // Should not throw + await mgr.closeSession('nonexistent', 0) + }) + }) + + describe('clear-scrollback detection (CSI 3J)', () => { + it('resets scrollback.bin when CSI 3J is detected', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + + await mgr.appendData('sess-1', 'old output\r\n') + // CSI 3J = \x1b[3J (erase scrollback) + await mgr.appendData('sess-1', '\x1b[3J') + await mgr.appendData('sess-1', 'new output\r\n') + + const data = readFileSync(sessionPath(dir, 'sess-1', 'scrollback.bin'), 'utf-8') + expect(data).not.toContain('old output') + expect(data).toContain('new output') + }) + + it('handles multiple CSI 3J in one chunk — resets to content after the last', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + + await mgr.appendData('sess-1', 'old\x1b[3Jmiddle\x1b[3Jfresh\r\n') + + const data = readFileSync(sessionPath(dir, 'sess-1', 'scrollback.bin'), 'utf-8') + expect(data).not.toContain('old') + expect(data).not.toContain('middle') + expect(data).toBe('fresh\r\n') + }) + + it('handles CSI 3J split across chunks', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + + await mgr.appendData('sess-1', 'old stuff\r\n') + await mgr.appendData('sess-1', '\x1b[3') + await mgr.appendData('sess-1', 'J') + await mgr.appendData('sess-1', 'fresh\r\n') + + const data = readFileSync(sessionPath(dir, 'sess-1', 'scrollback.bin'), 'utf-8') + expect(data).not.toContain('old stuff') + expect(data).toContain('fresh') + }) + + it('buffers trailing partial CSI 3J in afterClear content', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + + // CSI 3J followed by content that ends with a partial CSI 3J prefix + await mgr.appendData('sess-1', 'old\x1b[3Jnew-data\x1b[') + // Complete the sequence — should trigger a second reset + await mgr.appendData('sess-1', '3Jfinal') + + const data = readFileSync(sessionPath(dir, 'sess-1', 'scrollback.bin'), 'utf-8') + expect(data).not.toContain('old') + expect(data).not.toContain('new-data') + expect(data).toBe('final') + }) + + it('resets scrollback on CSI 3J even after 5MB cap is hit', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + + const fiveMB = 'x'.repeat(5 * 1024 * 1024) + await mgr.appendData('sess-1', fiveMB) + // Cap is hit — normal writes are blocked + await mgr.appendData('sess-1', 'blocked') + let data = readFileSync(sessionPath(dir, 'sess-1', 'scrollback.bin'), 'utf-8') + expect(data).not.toContain('blocked') + + // CSI 3J should still reset, allowing new writes + await mgr.appendData('sess-1', '\x1b[3Jafter-clear') + data = readFileSync(sessionPath(dir, 'sess-1', 'scrollback.bin'), 'utf-8') + expect(data).toBe('after-clear') + }) + }) + + describe('5MB size cap', () => { + it('stops appending after 5MB', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + + // Write 5MB + some extra + const chunk = 'x'.repeat(1024 * 1024) // 1MB + for (let i = 0; i < 6; i++) { + await mgr.appendData('sess-1', chunk) + } + + const stats = readFileSync(sessionPath(dir, 'sess-1', 'scrollback.bin')) + expect(stats.length).toBeLessThanOrEqual(5 * 1024 * 1024 + 1024) // some tolerance + }) + }) + + describe('multiple sessions', () => { + it('manages independent sessions', async () => { + await mgr.openSession('a', { cwd: '/a', cols: 80, rows: 24 }) + await mgr.openSession('b', { cwd: '/b', cols: 120, rows: 40 }) + + await mgr.appendData('a', 'session-a') + await mgr.appendData('b', 'session-b') + + const dataA = readFileSync(sessionPath(dir, 'a', 'scrollback.bin'), 'utf-8') + const dataB = readFileSync(sessionPath(dir, 'b', 'scrollback.bin'), 'utf-8') + + expect(dataA).toBe('session-a') + expect(dataB).toBe('session-b') + }) + }) + + describe('dispose', () => { + it('writes endedAt for open sessions to prevent false cold-restore', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + await mgr.appendData('sess-1', 'data') + await mgr.dispose() + + const data = readFileSync(sessionPath(dir, 'sess-1', 'scrollback.bin'), 'utf-8') + expect(data).toBe('data') + + const meta = JSON.parse(readFileSync(sessionPath(dir, 'sess-1', 'meta.json'), 'utf-8')) + expect(meta.endedAt).not.toBeNull() + expect(meta.exitCode).toBeNull() + }) + + it('flushes buffered partial escape sequences', async () => { + await mgr.openSession('partial', { cwd: '/tmp', cols: 80, rows: 24 }) + // Send data ending with a partial CSI 3J prefix + await mgr.appendData('partial', 'hello\x1b') + await mgr.dispose() + + const data = readFileSync(sessionPath(dir, 'partial', 'scrollback.bin'), 'utf-8') + expect(data).toBe('hello\x1b') + }) + }) + + describe('removeSession', () => { + it('deletes session directory from disk', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + await mgr.appendData('sess-1', 'data') + await mgr.closeSession('sess-1', 0) + + await mgr.removeSession('sess-1') + expect(existsSync(join(dir, getHistorySessionDirName('sess-1')))).toBe(false) + }) + }) + + describe('hasHistory', () => { + it('returns true for sessions with meta.json on disk', async () => { + await mgr.openSession('sess-1', { cwd: '/tmp', cols: 80, rows: 24 }) + await mgr.closeSession('sess-1', 0) + + expect(mgr.hasHistory('sess-1')).toBe(true) + }) + + it('returns false for unknown sessions', () => { + expect(mgr.hasHistory('nonexistent')).toBe(false) + }) + }) + + describe('readMeta', () => { + it('reads meta.json for a session', async () => { + await mgr.openSession('sess-1', { cwd: '/projects', cols: 100, rows: 30 }) + await mgr.closeSession('sess-1', 42) + + const meta = mgr.readMeta('sess-1') + expect(meta).not.toBeNull() + expect(meta!.cwd).toBe('/projects') + expect(meta!.exitCode).toBe(42) + }) + + it('returns null for missing sessions', () => { + expect(mgr.readMeta('nonexistent')).toBeNull() + }) + }) + + describe('disk-full handling', () => { + it('disables writes after fs error and does not throw', async () => { + await mgr.openSession('disk-full', { cwd: '/tmp', cols: 80, rows: 24 }) + await mgr.appendData('disk-full', 'before-error') + + // Make scrollback file read-only to trigger write error + const scrollbackPath = sessionPath(dir, 'disk-full', 'scrollback.bin') + chmodSync(scrollbackPath, 0o444) + + // Should not throw — error is caught and session is disabled + await mgr.appendData('disk-full', 'failing-write') + + // Restore permissions so we can read and clean up + chmodSync(scrollbackPath, 0o644) + + // Subsequent writes were skipped because session was disabled + const content = readFileSync(scrollbackPath, 'utf-8') + expect(content).toBe('before-error') + }) + + it('disables writes after fs error on openSession', async () => { + // Make base dir read-only so mkdirSync fails + chmodSync(dir, 0o555) + + // Should not throw + await mgr.openSession('disk-full-open', { cwd: '/tmp', cols: 80, rows: 24 }) + + // Restore permissions for cleanup + chmodSync(dir, 0o755) + + // Session writer was never registered, so appendData is a no-op + await mgr.appendData('disk-full-open', 'data-after-failed-open') + }) + + it('does not throw on closeSession disk error (prevents false cold-restore)', async () => { + await mgr.openSession('close-err', { cwd: '/tmp', cols: 80, rows: 24 }) + + // Make meta.json read-only so updateMeta's writeFileSync fails + const metaPath = sessionPath(dir, 'close-err', 'meta.json') + chmodSync(metaPath, 0o444) + + // Should not throw + await mgr.closeSession('close-err', 0) + + chmodSync(metaPath, 0o644) + }) + + it('reports write errors via onWriteError callback', async () => { + const errors: { sessionId: string; error: Error }[] = [] + mgr = new HistoryManager(dir, { + onWriteError: (sessionId, error) => errors.push({ sessionId, error }) + }) + + await mgr.openSession('err-cb', { cwd: '/tmp', cols: 80, rows: 24 }) + await mgr.appendData('err-cb', 'before') + + const scrollbackPath = sessionPath(dir, 'err-cb', 'scrollback.bin') + chmodSync(scrollbackPath, 0o444) + + await mgr.appendData('err-cb', 'trigger-error') + + chmodSync(scrollbackPath, 0o644) + + expect(errors).toHaveLength(1) + expect(errors[0].sessionId).toBe('err-cb') + }) + }) +}) diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts new file mode 100644 index 000000000..4426e1194 --- /dev/null +++ b/src/main/daemon/history-manager.ts @@ -0,0 +1,325 @@ +import { join } from 'path' +import { mkdirSync, writeFileSync, appendFileSync, readFileSync, existsSync, rmSync } from 'fs' +import { getHistorySessionDirName } from './history-paths' + +export type SessionMeta = { + cwd: string + cols: number + rows: number + startedAt: string + endedAt: string | null + exitCode: number | null +} + +export type OpenSessionOptions = { + cwd: string + cols: number + rows: number + initialScrollback?: string +} + +const MAX_SCROLLBACK_BYTES = 5 * 1024 * 1024 + +function parseFileUriPath(uri: string): string | null { + try { + const url = new URL(uri) + if (url.protocol !== 'file:') { + return null + } + + const decodedPath = decodeURIComponent(url.pathname) + if (process.platform !== 'win32') { + return decodedPath + } + + // Why: daemon-side cwd persistence must preserve the full Windows target + // path from OSC-7, including UNC hosts, or cold restore can reopen in a + // different directory than the live shell had reached before the crash. + if (url.hostname) { + return `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}` + } + if (/^\/[A-Za-z]:/.test(decodedPath)) { + return decodedPath.slice(1) + } + return decodedPath.replace(/\//g, '\\') + } catch { + return null + } +} + +type SessionWriter = { + dir: string + scrollbackPath: string + bytesWritten: number + cwd: string + // Why: CSI 3J (erase scrollback) can arrive split across data chunks. + // Buffer the trailing bytes that look like a partial CSI 3J prefix + // so the next chunk can complete the match. + partialEscape: string +} + +export type HistoryManagerOptions = { + onWriteError?: (sessionId: string, error: Error) => void +} + +export class HistoryManager { + private basePath: string + private writers = new Map() + private disabledSessions = new Set() + private onWriteError?: (sessionId: string, error: Error) => void + + constructor(basePath: string, opts?: HistoryManagerOptions) { + this.basePath = basePath + this.onWriteError = opts?.onWriteError + } + + async openSession(sessionId: string, opts: OpenSessionOptions): Promise { + try { + this.disabledSessions.delete(sessionId) + const dir = join(this.basePath, getHistorySessionDirName(sessionId)) + mkdirSync(dir, { recursive: true }) + + const meta: SessionMeta = { + cwd: opts.cwd, + cols: opts.cols, + rows: opts.rows, + startedAt: new Date().toISOString(), + endedAt: null, + exitCode: null + } + writeFileSync(join(dir, 'meta.json'), JSON.stringify(meta, null, 2)) + + const scrollbackPath = join(dir, 'scrollback.bin') + let bytesWritten = 0 + + if (opts.initialScrollback) { + writeFileSync(scrollbackPath, opts.initialScrollback) + bytesWritten = Buffer.byteLength(opts.initialScrollback) + } else { + writeFileSync(scrollbackPath, '') + } + + this.writers.set(sessionId, { + dir, + scrollbackPath, + bytesWritten, + cwd: opts.cwd, + partialEscape: '' + }) + } catch (err) { + this.handleWriteError(sessionId, err) + } + } + + async appendData(sessionId: string, data: string): Promise { + if (this.disabledSessions.has(sessionId)) { + return + } + const writer = this.writers.get(sessionId) + if (!writer) { + return + } + + try { + const combined = writer.partialEscape + data + writer.partialEscape = '' + const nextCwd = this.extractLatestCwd(combined) + if (nextCwd && nextCwd !== writer.cwd) { + writer.cwd = nextCwd + this.updateMeta(writer.dir, { cwd: nextCwd }) + } + + // Why: use lastIndexOf so that multiple CSI 3J sequences in one chunk + // reset to the content after the *last* clear, not the first. + const clearIdx = combined.lastIndexOf('\x1b[3J') + if (clearIdx !== -1) { + const afterClear = combined.slice(clearIdx + 4) + // Why: CSI 3J resets the byte counter — a clear after the 5MB cap + // must still take effect, otherwise the on-disk history is stale. + this.resetScrollback(writer) + // Why: afterClear may itself end with a partial CSI 3J prefix + // (e.g., the chunk was `...\x1b[3Jdata\x1b[`). Buffer it the + // same way the main path does, otherwise the next chunk's + // completion of the sequence won't be detected as a clear. + const partial = this.trailingPartialCsi3J(afterClear) + if (partial) { + writer.partialEscape = partial + const safe = afterClear.slice(0, afterClear.length - partial.length) + if (safe.length > 0) { + this.writeChunk(writer, safe) + } + } else if (afterClear.length > 0) { + this.writeChunk(writer, afterClear) + } + return + } + + // Why: CSI 3J detection (above) must run even when the cap is hit, + // because a clear resets the byte counter. All other writes are + // blocked once the cap is reached. + if (writer.bytesWritten >= MAX_SCROLLBACK_BYTES) { + return + } + + const partial = this.trailingPartialCsi3J(combined) + if (partial) { + writer.partialEscape = partial + const safe = combined.slice(0, combined.length - partial.length) + if (safe.length > 0) { + this.writeChunk(writer, safe) + } + return + } + + this.writeChunk(writer, combined) + } catch (err) { + this.handleWriteError(sessionId, err) + } + } + + async closeSession(sessionId: string, exitCode: number): Promise { + const writer = this.writers.get(sessionId) + if (!writer) { + return + } + + this.writers.delete(sessionId) + // Why: partialEscape may hold buffered bytes from a chunk boundary that + // looked like a CSI 3J prefix. Flush them before closing so they aren't + // silently dropped. (dispose() does the same flush.) + if (writer.partialEscape) { + try { + this.writeChunk(writer, writer.partialEscape) + } catch { + // Best-effort flush — don't block session close + } + writer.partialEscape = '' + } + try { + this.updateMeta(writer.dir, { endedAt: new Date().toISOString(), exitCode }) + } catch (err) { + // Why: if endedAt can't be written, the session looks like an unclean + // shutdown and triggers a false cold restore on next launch. Disable + // further writes and report, but don't crash the app. + this.handleWriteError(sessionId, err) + } + } + + async removeSession(sessionId: string): Promise { + this.writers.delete(sessionId) + this.disabledSessions.delete(sessionId) + rmSync(join(this.basePath, getHistorySessionDirName(sessionId)), { + recursive: true, + force: true + }) + } + + hasHistory(sessionId: string): boolean { + return existsSync(join(this.basePath, getHistorySessionDirName(sessionId), 'meta.json')) + } + + readMeta(sessionId: string): SessionMeta | null { + const metaPath = join(this.basePath, getHistorySessionDirName(sessionId), 'meta.json') + if (!existsSync(metaPath)) { + return null + } + try { + return JSON.parse(readFileSync(metaPath, 'utf-8')) + } catch { + return null + } + } + + async dispose(): Promise { + // Why: mark all open sessions as cleanly ended so they don't trigger + // false cold-restores on next launch. Flush any buffered partial escape + // data before closing. + for (const [sessionId, writer] of this.writers) { + if (writer.partialEscape) { + try { + this.writeChunk(writer, writer.partialEscape) + } catch { + // Best-effort flush — don't block shutdown + } + writer.partialEscape = '' + } + try { + this.updateMeta(writer.dir, { endedAt: new Date().toISOString(), exitCode: null }) + } catch { + // Best-effort — don't block shutdown + this.disabledSessions.add(sessionId) + } + } + this.writers.clear() + } + + private writeChunk(writer: SessionWriter, data: string): void { + const buf = Buffer.from(data) + const remaining = MAX_SCROLLBACK_BYTES - writer.bytesWritten + if (remaining <= 0) { + return + } + + if (buf.length > remaining) { + appendFileSync(writer.scrollbackPath, buf.subarray(0, remaining)) + writer.bytesWritten = MAX_SCROLLBACK_BYTES + } else { + appendFileSync(writer.scrollbackPath, buf) + writer.bytesWritten += buf.length + } + } + + private resetScrollback(writer: SessionWriter): void { + writeFileSync(writer.scrollbackPath, '') + writer.bytesWritten = 0 + } + + // Why: CSI 3J is 4 bytes (\x1b [ 3 J). If the chunk ends with a prefix + // of this sequence, we must buffer it and check the next chunk. + private trailingPartialCsi3J(data: string): string | null { + const suffixes = ['\x1b[3', '\x1b[', '\x1b'] + for (const suffix of suffixes) { + if (data.endsWith(suffix)) { + return suffix + } + } + return null + } + + private extractLatestCwd(data: string): string | null { + // OSC-7 format: ESC ] 7 ; BEL or ESC ] 7 ; ST + // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars + const osc7Re = /\x1b\]7;([^\x07\x1b]*?)(?:\x07|\x1b\\)/g + let match: RegExpExecArray | null + let latest: string | null = null + while ((match = osc7Re.exec(data)) !== null) { + latest = this.parseOsc7Uri(match[1]) + } + return latest + } + + private parseOsc7Uri(uri: string): string | null { + return parseFileUriPath(uri) + } + + // Why: history is best-effort — any error should disable the session + // rather than crash the app. Callers use fire-and-forget `void` promises, + // so a re-thrown error would become an unhandled rejection. + private handleWriteError(sessionId: string, err: unknown): void { + this.disabledSessions.add(sessionId) + this.onWriteError?.(sessionId, err as Error) + } + + private updateMeta(dir: string, updates: Partial): void { + const metaPath = join(dir, 'meta.json') + let meta: SessionMeta + try { + meta = JSON.parse(readFileSync(metaPath, 'utf-8')) + } catch { + // meta.json missing or corrupt — nothing to update + return + } + Object.assign(meta, updates) + writeFileSync(metaPath, JSON.stringify(meta, null, 2)) + } +} diff --git a/src/main/daemon/history-paths.ts b/src/main/daemon/history-paths.ts new file mode 100644 index 000000000..780edb8f1 --- /dev/null +++ b/src/main/daemon/history-paths.ts @@ -0,0 +1,7 @@ +export function getHistorySessionDirName(sessionId: string): string { + // Why: real session IDs embed worktree identity and can contain characters + // such as `:` and `/` that are invalid in a Windows path segment. Persist + // history under an encoded directory name so crash recovery works cross- + // platform without changing the user-visible session ID. + return encodeURIComponent(sessionId) +} diff --git a/src/main/daemon/history-reader.test.ts b/src/main/daemon/history-reader.test.ts new file mode 100644 index 000000000..a4e37d0e3 --- /dev/null +++ b/src/main/daemon/history-reader.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { tmpdir } from 'os' +import { join } from 'path' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs' +import { HistoryReader } from './history-reader' +import { getHistorySessionDirName } from './history-paths' +import type { SessionMeta } from './history-manager' + +function createTestDir(): string { + return mkdtempSync(join(tmpdir(), 'history-reader-test-')) +} + +function writeSessionFiles( + basePath: string, + sessionId: string, + meta: SessionMeta, + scrollback: string +): void { + const dir = join(basePath, getHistorySessionDirName(sessionId)) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'meta.json'), JSON.stringify(meta)) + writeFileSync(join(dir, 'scrollback.bin'), scrollback) +} + +function makeMeta(overrides: Partial = {}): SessionMeta { + return { + cwd: '/home/user/project', + cols: 80, + rows: 24, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null, + ...overrides + } +} + +describe('HistoryReader', () => { + let dir: string + let reader: HistoryReader + + beforeEach(() => { + dir = createTestDir() + reader = new HistoryReader(dir) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + describe('detectColdRestore', () => { + it('returns restore info for unclean shutdown (endedAt is null)', () => { + writeSessionFiles(dir, 'sess-1', makeMeta(), 'hello world\r\n$ ls\r\n') + + const info = reader.detectColdRestore('sess-1') + expect(info).not.toBeNull() + expect(info!.cwd).toBe('/home/user/project') + expect(info!.cols).toBe(80) + expect(info!.rows).toBe(24) + expect(info!.scrollback).toContain('hello world') + }) + + it('returns null for clean shutdown (endedAt is set)', () => { + writeSessionFiles( + dir, + 'sess-1', + makeMeta({ endedAt: '2026-04-15T12:00:00Z', exitCode: 0 }), + 'old output' + ) + + expect(reader.detectColdRestore('sess-1')).toBeNull() + }) + + it('returns null for nonexistent session', () => { + expect(reader.detectColdRestore('nonexistent')).toBeNull() + }) + + it('returns null for corrupt meta.json', () => { + const sessionDir = join(dir, getHistorySessionDirName('corrupt')) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync(join(sessionDir, 'meta.json'), 'not json') + writeFileSync(join(sessionDir, 'scrollback.bin'), 'data') + + expect(reader.detectColdRestore('corrupt')).toBeNull() + }) + + it('returns empty scrollback when scrollback.bin is missing', () => { + const sessionDir = join(dir, getHistorySessionDirName('no-scrollback')) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync(join(sessionDir, 'meta.json'), JSON.stringify(makeMeta())) + + const info = reader.detectColdRestore('no-scrollback') + expect(info).not.toBeNull() + expect(info!.scrollback).toBe('') + }) + }) + + describe('TUI truncation', () => { + it('truncates before last unmatched alternate-screen-on', () => { + const scrollback = [ + 'normal output\r\n', + '\x1b[?1049h', // alt screen on (vim started) + 'vim content here' + // No matching \x1b[?1049l — vim was running when daemon died + ].join('') + + writeSessionFiles(dir, 'tui-sess', makeMeta(), scrollback) + + const info = reader.detectColdRestore('tui-sess') + expect(info).not.toBeNull() + expect(info!.scrollback).toContain('normal output') + expect(info!.scrollback).not.toContain('vim content') + }) + + it('preserves content when alt-screen is properly closed', () => { + const scrollback = [ + 'before vim\r\n', + '\x1b[?1049h', // alt screen on + 'vim stuff', + '\x1b[?1049l', // alt screen off + 'after vim\r\n' + ].join('') + + writeSessionFiles(dir, 'closed-tui', makeMeta(), scrollback) + + const info = reader.detectColdRestore('closed-tui') + expect(info).not.toBeNull() + expect(info!.scrollback).toContain('before vim') + expect(info!.scrollback).toContain('after vim') + }) + + it('handles multiple alt-screen cycles with last one unclosed', () => { + const scrollback = [ + 'line1\r\n', + '\x1b[?1049h', + 'vim1', + '\x1b[?1049l', + 'line2\r\n', + '\x1b[?1049h', + 'vim2-still-running' + // No close — daemon crashed while vim2 was open + ].join('') + + writeSessionFiles(dir, 'multi-tui', makeMeta(), scrollback) + + const info = reader.detectColdRestore('multi-tui') + expect(info).not.toBeNull() + expect(info!.scrollback).toContain('line1') + expect(info!.scrollback).toContain('line2') + expect(info!.scrollback).not.toContain('vim2-still-running') + }) + + it('truncates at outermost unmatched alt-screen-on for nested sessions', () => { + const scrollback = [ + 'normal output\r\n', + '\x1b[?1049h', // outer alt screen (e.g., tmux) + 'tmux content', + '\x1b[?1049h', // inner alt screen (e.g., vim inside tmux) + 'vim inside tmux' + // Neither closed — daemon crashed + ].join('') + + writeSessionFiles(dir, 'nested-tui', makeMeta(), scrollback) + + const info = reader.detectColdRestore('nested-tui') + expect(info).not.toBeNull() + expect(info!.scrollback).toContain('normal output') + expect(info!.scrollback).not.toContain('tmux content') + expect(info!.scrollback).not.toContain('vim inside tmux') + }) + + it('returns full content when no alt-screen sequences', () => { + writeSessionFiles(dir, 'plain', makeMeta(), 'just normal shell output\r\n') + + const info = reader.detectColdRestore('plain') + expect(info!.scrollback).toBe('just normal shell output\r\n') + }) + }) + + describe('listRestorable', () => { + it('lists sessions with unclean shutdown', () => { + writeSessionFiles(dir, 'alive', makeMeta(), 'data') + writeSessionFiles(dir, 'dead', makeMeta({ endedAt: '2026-04-15T12:00:00Z' }), 'data') + + const restorable = reader.listRestorable() + expect(restorable).toEqual(['alive']) + }) + + it('returns empty array when no sessions exist', () => { + expect(reader.listRestorable()).toEqual([]) + }) + + it('returns decoded session ids for encoded on-disk directories', () => { + const sessionId = 'repo-1::C:/Users/dev/feature' + writeSessionFiles(dir, sessionId, makeMeta(), 'data') + + expect(reader.listRestorable()).toEqual([sessionId]) + }) + }) +}) diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts new file mode 100644 index 000000000..c98d5fad8 --- /dev/null +++ b/src/main/daemon/history-reader.ts @@ -0,0 +1,136 @@ +import { join } from 'path' +import { readFileSync, existsSync, readdirSync } from 'fs' +import type { SessionMeta } from './history-manager' +import { getHistorySessionDirName } from './history-paths' + +export type ColdRestoreInfo = { + scrollback: string + cwd: string + cols: number + rows: number +} + +const ALT_SCREEN_ON = '\x1b[?1049h' +const ALT_SCREEN_OFF = '\x1b[?1049l' + +export class HistoryReader { + private basePath: string + + constructor(basePath: string) { + this.basePath = basePath + } + + detectColdRestore(sessionId: string): ColdRestoreInfo | null { + const meta = this.readMeta(sessionId) + if (!meta) { + return null + } + if (meta.endedAt !== null) { + return null + } + + const scrollback = this.readScrollback(sessionId) + return { + scrollback: this.truncateAltScreen(scrollback), + cwd: meta.cwd, + cols: meta.cols, + rows: meta.rows + } + } + + listRestorable(): string[] { + if (!existsSync(this.basePath)) { + return [] + } + + let entries: { isDirectory(): boolean; name: string }[] + try { + entries = readdirSync(this.basePath, { withFileTypes: true }) + } catch { + return [] + } + const restorable: string[] = [] + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue + } + const sessionId = decodeURIComponent(entry.name) + const meta = this.readMeta(sessionId) + if (meta && meta.endedAt === null) { + restorable.push(sessionId) + } + } + + return restorable + } + + private readMeta(sessionId: string): SessionMeta | null { + const metaPath = join(this.basePath, getHistorySessionDirName(sessionId), 'meta.json') + if (!existsSync(metaPath)) { + return null + } + try { + return JSON.parse(readFileSync(metaPath, 'utf-8')) + } catch { + return null + } + } + + private readScrollback(sessionId: string): string { + const scrollbackPath = join( + this.basePath, + getHistorySessionDirName(sessionId), + 'scrollback.bin' + ) + if (!existsSync(scrollbackPath)) { + return '' + } + try { + return readFileSync(scrollbackPath, 'utf-8') + } catch { + return '' + } + } + + // Why: raw scrollback from TUI sessions (vim, less, htop) contains + // alternate-screen switches that produce garbled output when replayed. + // Truncate before the outermost unmatched alt-screen-on so only normal + // terminal output is restored. + private truncateAltScreen(data: string): string { + let depth = 0 + let outermostUnmatchedOnIdx = -1 + + let searchFrom = 0 + while (searchFrom < data.length) { + const onIdx = data.indexOf(ALT_SCREEN_ON, searchFrom) + const offIdx = data.indexOf(ALT_SCREEN_OFF, searchFrom) + + if (onIdx === -1 && offIdx === -1) { + break + } + + if (onIdx !== -1 && (offIdx === -1 || onIdx < offIdx)) { + // Why: track where depth first goes above 0 — that's the outermost + // unmatched ON. Nested ONs (depth > 1) are inside the same alt-screen + // block, so we truncate at the outermost boundary. + if (depth === 0) { + outermostUnmatchedOnIdx = onIdx + } + depth++ + searchFrom = onIdx + ALT_SCREEN_ON.length + } else { + if (depth > 0) { + depth-- + } + searchFrom = offIdx + ALT_SCREEN_OFF.length + } + } + + if (depth > 0 && outermostUnmatchedOnIdx !== -1) { + return data.slice(0, outermostUnmatchedOnIdx) + } + + return data + } +} diff --git a/src/main/daemon/ndjson.test.ts b/src/main/daemon/ndjson.test.ts new file mode 100644 index 000000000..698a43f0a --- /dev/null +++ b/src/main/daemon/ndjson.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest' +import { encodeNdjson, createNdjsonParser } from './ndjson' + +describe('encodeNdjson', () => { + it('encodes an object as a JSON line ending with newline', () => { + const result = encodeNdjson({ type: 'hello', version: 1 }) + expect(result).toBe('{"type":"hello","version":1}\n') + }) + + it('encodes nested objects', () => { + const msg = { id: 'req-1', type: 'write', payload: { sessionId: 'abc', data: 'ls\n' } } + const result = encodeNdjson(msg) + expect(result.endsWith('\n')).toBe(true) + expect(JSON.parse(result.trim())).toEqual(msg) + }) +}) + +describe('createNdjsonParser', () => { + it('parses a single complete message', () => { + const onMessage = vi.fn() + const onError = vi.fn() + const parser = createNdjsonParser(onMessage, onError) + + parser.feed('{"type":"hello"}\n') + + expect(onMessage).toHaveBeenCalledOnce() + expect(onMessage).toHaveBeenCalledWith({ type: 'hello' }) + expect(onError).not.toHaveBeenCalled() + }) + + it('parses multiple messages in a single chunk', () => { + const onMessage = vi.fn() + const parser = createNdjsonParser(onMessage) + + parser.feed('{"a":1}\n{"b":2}\n{"c":3}\n') + + expect(onMessage).toHaveBeenCalledTimes(3) + expect(onMessage).toHaveBeenNthCalledWith(1, { a: 1 }) + expect(onMessage).toHaveBeenNthCalledWith(2, { b: 2 }) + expect(onMessage).toHaveBeenNthCalledWith(3, { c: 3 }) + }) + + it('handles messages split across multiple chunks', () => { + const onMessage = vi.fn() + const parser = createNdjsonParser(onMessage) + + parser.feed('{"type":"hel') + expect(onMessage).not.toHaveBeenCalled() + + parser.feed('lo","version":1}\n') + expect(onMessage).toHaveBeenCalledOnce() + expect(onMessage).toHaveBeenCalledWith({ type: 'hello', version: 1 }) + }) + + it('handles a chunk that ends mid-line followed by more data', () => { + const onMessage = vi.fn() + const parser = createNdjsonParser(onMessage) + + parser.feed('{"id":"1"}\n{"id":') + expect(onMessage).toHaveBeenCalledOnce() + + parser.feed('"2"}\n') + expect(onMessage).toHaveBeenCalledTimes(2) + expect(onMessage).toHaveBeenNthCalledWith(2, { id: '2' }) + }) + + it('ignores empty lines', () => { + const onMessage = vi.fn() + const parser = createNdjsonParser(onMessage) + + parser.feed('\n\n{"ok":true}\n\n') + + expect(onMessage).toHaveBeenCalledOnce() + expect(onMessage).toHaveBeenCalledWith({ ok: true }) + }) + + it('calls onError for malformed JSON', () => { + const onMessage = vi.fn() + const onError = vi.fn() + const parser = createNdjsonParser(onMessage, onError) + + parser.feed('not json\n') + + expect(onMessage).not.toHaveBeenCalled() + expect(onError).toHaveBeenCalledOnce() + expect(onError.mock.calls[0][0]).toBeInstanceOf(Error) + }) + + it('recovers after malformed JSON and parses next line', () => { + const onMessage = vi.fn() + const onError = vi.fn() + const parser = createNdjsonParser(onMessage, onError) + + parser.feed('bad\n{"good":true}\n') + + expect(onError).toHaveBeenCalledOnce() + expect(onMessage).toHaveBeenCalledOnce() + expect(onMessage).toHaveBeenCalledWith({ good: true }) + }) + + it('handles messages with embedded newlines in strings', () => { + const onMessage = vi.fn() + const parser = createNdjsonParser(onMessage) + + // JSON.stringify escapes newlines as \n (two chars), so the actual + // newline delimiter is still unambiguous. + const msg = { data: 'line1\nline2' } + parser.feed(`${JSON.stringify(msg)}\n`) + + expect(onMessage).toHaveBeenCalledWith(msg) + }) + + it('resets buffer state on reset()', () => { + const onMessage = vi.fn() + const parser = createNdjsonParser(onMessage) + + parser.feed('{"partial":') + parser.reset() + parser.feed('{"fresh":true}\n') + + expect(onMessage).toHaveBeenCalledOnce() + expect(onMessage).toHaveBeenCalledWith({ fresh: true }) + }) +}) diff --git a/src/main/daemon/ndjson.ts b/src/main/daemon/ndjson.ts new file mode 100644 index 000000000..7d10b2330 --- /dev/null +++ b/src/main/daemon/ndjson.ts @@ -0,0 +1,41 @@ +export function encodeNdjson(msg: unknown): string { + return `${JSON.stringify(msg)}\n` +} + +export type NdjsonParser = { + feed(chunk: string): void + reset(): void +} + +export function createNdjsonParser( + onMessage: (msg: unknown) => void, + onError?: (err: Error) => void +): NdjsonParser { + let buffer = '' + + return { + feed(chunk: string): void { + buffer += chunk + + let newlineIndex: number + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex) + buffer = buffer.slice(newlineIndex + 1) + + if (line.length === 0) { + continue + } + + try { + onMessage(JSON.parse(line)) + } catch (err) { + onError?.(err instanceof Error ? err : new Error(String(err))) + } + } + }, + + reset(): void { + buffer = '' + } + } +} diff --git a/src/main/daemon/priority-semaphore.test.ts b/src/main/daemon/priority-semaphore.test.ts new file mode 100644 index 000000000..035e3ca92 --- /dev/null +++ b/src/main/daemon/priority-semaphore.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import { PrioritySemaphore } from './priority-semaphore' + +describe('PrioritySemaphore', () => { + it('allows up to N concurrent acquires', async () => { + const sem = new PrioritySemaphore(2) + const r1 = await sem.acquire(0) + const r2 = await sem.acquire(0) + + // Both acquired immediately + expect(typeof r1).toBe('function') + expect(typeof r2).toBe('function') + + r1() + r2() + }) + + it('blocks when concurrency limit is reached', async () => { + const sem = new PrioritySemaphore(1) + const r1 = await sem.acquire(0) + + let acquired = false + const p2 = sem.acquire(0).then((r) => { + acquired = true + return r + }) + + // Give microtasks a chance to flush + await new Promise((r) => setTimeout(r, 10)) + expect(acquired).toBe(false) + + r1() + const r2 = await p2 + expect(acquired).toBe(true) + r2() + }) + + it('serves higher priority (lower number) first', async () => { + const sem = new PrioritySemaphore(1) + const r1 = await sem.acquire(0) + + const order: string[] = [] + + // Queue a low-priority and then a high-priority waiter + const pLow = sem.acquire(1).then((r) => { + order.push('low') + return r + }) + const pHigh = sem.acquire(0).then((r) => { + order.push('high') + return r + }) + + // Release — high priority should go first + r1() + + const rHigh = await pHigh + rHigh() + + const rLow = await pLow + rLow() + + expect(order).toEqual(['high', 'low']) + }) + + it('handles FIFO within same priority', async () => { + const sem = new PrioritySemaphore(1) + const r1 = await sem.acquire(0) + + const order: number[] = [] + + const p1 = sem.acquire(1).then((r) => { + order.push(1) + return r + }) + const p2 = sem.acquire(1).then((r) => { + order.push(2) + return r + }) + const p3 = sem.acquire(1).then((r) => { + order.push(3) + return r + }) + + r1() + + const r2 = await p1 + r2() + const r3 = await p2 + r3() + const r4 = await p3 + r4() + + expect(order).toEqual([1, 2, 3]) + }) + + it('supports concurrency > 1 with priority ordering', async () => { + const sem = new PrioritySemaphore(2) + const r1 = await sem.acquire(0) + const r2 = await sem.acquire(0) + + const order: string[] = [] + + const pA = sem.acquire(1).then((r) => { + order.push('A-low') + return r + }) + const pB = sem.acquire(0).then((r) => { + order.push('B-high') + return r + }) + const pC = sem.acquire(1).then((r) => { + order.push('C-low') + return r + }) + + // Release both slots + r1() + r2() + + // High priority B should get a slot before low-priority A and C + const rB = await pB + rB() + const rA = await pA + rA() + const rC = await pC + rC() + + expect(order[0]).toBe('B-high') + }) + + it('works with zero waiters', async () => { + const sem = new PrioritySemaphore(3) + const r1 = await sem.acquire(0) + r1() + // No crash, no hanging + }) + + it('release is idempotent', async () => { + const sem = new PrioritySemaphore(1) + const r1 = await sem.acquire(0) + r1() + r1() // double release should not throw or corrupt state + + const r2 = await sem.acquire(0) + r2() + }) +}) diff --git a/src/main/daemon/priority-semaphore.ts b/src/main/daemon/priority-semaphore.ts new file mode 100644 index 000000000..4d3172693 --- /dev/null +++ b/src/main/daemon/priority-semaphore.ts @@ -0,0 +1,57 @@ +type Waiter = { + priority: number + resolve: (release: () => void) => void +} + +export class PrioritySemaphore { + private available: number + private waiters: Waiter[] = [] + + constructor(concurrency: number) { + this.available = concurrency + } + + acquire(priority: number): Promise<() => void> { + if (this.available > 0) { + this.available-- + let released = false + return Promise.resolve(() => { + if (released) { + return + } + released = true + this.release() + }) + } + + return new Promise<() => void>((resolve) => { + this.waiters.push({ priority, resolve }) + }) + } + + private release(): void { + if (this.waiters.length === 0) { + this.available++ + return + } + + // Find the highest-priority (lowest number) waiter. + // Among equal priorities, take the first (FIFO). + let bestIdx = 0 + for (let i = 1; i < this.waiters.length; i++) { + if (this.waiters[i].priority < this.waiters[bestIdx].priority) { + bestIdx = i + } + } + + const waiter = this.waiters.splice(bestIdx, 1)[0] + let released = false + waiter.resolve(() => { + if (released) { + return + } + released = true + this.release() + }) + } +} diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts new file mode 100644 index 000000000..53475f6a7 --- /dev/null +++ b/src/main/daemon/production-launcher.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'os' +import { join } from 'path' +import { mkdtempSync, rmSync } from 'fs' +import { createProductionLauncher } from './production-launcher' +import { startDaemon, type DaemonHandle } from './daemon-main' +import { DaemonClient } from './client' +import type { SubprocessHandle } from './session' + +function createTestDir(): string { + return mkdtempSync(join(tmpdir(), 'prod-launcher-test-')) +} + +function createMockSubprocess(): SubprocessHandle { + let onExitCb: ((code: number) => void) | null = null + return { + pid: 44444, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), + forceKill: vi.fn(), + signal: vi.fn(), + onData(_cb: (data: string) => void) {}, + onExit(cb: (code: number) => void) { + onExitCb = cb + } + } +} + +describe('createProductionLauncher', () => { + let dir: string + let handles: DaemonHandle[] + + beforeEach(() => { + dir = createTestDir() + handles = [] + }) + + afterEach(async () => { + for (const h of handles) { + await h.shutdown().catch(() => {}) + } + rmSync(dir, { recursive: true, force: true }) + }) + + it('returns a launcher function', () => { + const launcher = createProductionLauncher({ + getDaemonEntryPath: () => '/fake/path.js' + }) + expect(typeof launcher).toBe('function') + }) + + it('can be used with DaemonSpawner (in-process fallback)', async () => { + // Use in-process launcher for testing (same as DaemonSpawner tests) + const launcher = async (socketPath: string, tokenPath: string) => { + const handle = await startDaemon({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + handles.push(handle) + return { shutdown: () => handle.shutdown() } + } + + const socketPath = join(dir, 'test.sock') + const tokenPath = join(dir, 'test.token') + const handle = await launcher(socketPath, tokenPath) + + const client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + expect(client.isConnected()).toBe(true) + client.disconnect() + + await handle.shutdown() + handles.pop() + }) +}) diff --git a/src/main/daemon/production-launcher.ts b/src/main/daemon/production-launcher.ts new file mode 100644 index 000000000..6ce852bb0 --- /dev/null +++ b/src/main/daemon/production-launcher.ts @@ -0,0 +1,78 @@ +import { fork, type ChildProcess } from 'child_process' +import type { DaemonLauncher, DaemonProcessHandle } from './daemon-spawner' + +const READY_TIMEOUT_MS = 10_000 + +export type ProductionLauncherOptions = { + getDaemonEntryPath: () => string +} + +export function createProductionLauncher(opts: ProductionLauncherOptions): DaemonLauncher { + return async (socketPath: string, tokenPath: string): Promise => { + const entryPath = opts.getDaemonEntryPath() + + const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], { + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + detached: true, + env: { ...process.env }, + ...(process.platform === 'win32' ? { windowsHide: true } : {}) + }) + + await waitForReady(child) + + // Unref so the Electron process can exit without waiting for the daemon + child.unref() + child.disconnect() + + return { + shutdown: () => shutdownChild(child) + } + } +} + +function waitForReady(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill('SIGTERM') + reject(new Error('Daemon failed to signal readiness within timeout')) + }, READY_TIMEOUT_MS) + + child.on('message', (msg: unknown) => { + if (msg && typeof msg === 'object' && (msg as Record).type === 'ready') { + clearTimeout(timeout) + resolve() + } + }) + + child.on('error', (err) => { + clearTimeout(timeout) + reject(new Error(`Daemon process error: ${err.message}`)) + }) + + child.on('exit', (code) => { + clearTimeout(timeout) + reject(new Error(`Daemon process exited prematurely with code ${code}`)) + }) + }) +} + +function shutdownChild(child: ChildProcess): Promise { + return new Promise((resolve) => { + if (child.killed) { + resolve() + return + } + + const timeout = setTimeout(() => { + child.kill('SIGKILL') + resolve() + }, 5000) + + child.once('exit', () => { + clearTimeout(timeout) + resolve() + }) + + child.kill('SIGTERM') + }) +} diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts new file mode 100644 index 000000000..82b8a28b8 --- /dev/null +++ b/src/main/daemon/pty-subprocess.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it, vi } from 'vitest' + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn() +})) + +vi.mock('node-pty', () => ({ + spawn: spawnMock +})) + +import { createPtySubprocess } from './pty-subprocess' + +function mockPtyProcess(pid = 12345) { + const onDataListeners: ((data: string) => void)[] = [] + const onExitListeners: ((e: { exitCode: number }) => void)[] = [] + return { + pid, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + process: 'zsh', + onData: vi.fn((cb: (data: string) => void) => { + onDataListeners.push(cb) + return { dispose: vi.fn() } + }), + onExit: vi.fn((cb: (e: { exitCode: number }) => void) => { + onExitListeners.push(cb) + return { dispose: vi.fn() } + }), + _simulateData: (data: string) => onDataListeners.forEach((cb) => cb(data)), + _simulateExit: (code: number) => onExitListeners.forEach((cb) => cb({ exitCode: code })) + } +} + +describe('createPtySubprocess', () => { + it('spawns node-pty with correct options', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: '/home/user', + env: { SHELL: '/bin/bash', FOO: 'bar' } + }) + + expect(spawnMock).toHaveBeenCalledWith( + '/bin/bash', + expect.any(Array), + expect.objectContaining({ + cols: 80, + rows: 24, + cwd: '/home/user', + name: 'xterm-256color' + }) + ) + }) + + it('returns a SubprocessHandle with correct pid', () => { + const proc = mockPtyProcess(42) + spawnMock.mockReturnValue(proc) + + const handle = createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24 + }) + + expect(handle.pid).toBe(42) + }) + + it('forwards write calls', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + handle.write('ls\n') + + expect(proc.write).toHaveBeenCalledWith('ls\n') + }) + + it('forwards resize calls', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + handle.resize(120, 40) + + expect(proc.resize).toHaveBeenCalledWith(120, 40) + }) + + it('forwards kill calls', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + handle.kill() + + expect(proc.kill).toHaveBeenCalled() + }) + + it('forceKill sends SIGKILL to the child pid', () => { + const proc = mockPtyProcess(77) + spawnMock.mockReturnValue(proc) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + handle.forceKill() + + expect(killSpy).toHaveBeenCalledWith(77, 'SIGKILL') + killSpy.mockRestore() + }) + + it('routes onData events', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + const data: string[] = [] + handle.onData((d) => data.push(d)) + + proc._simulateData('hello') + expect(data).toEqual(['hello']) + }) + + it('routes onExit events', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + const codes: number[] = [] + handle.onExit((code) => codes.push(code)) + + proc._simulateExit(42) + expect(codes).toEqual([42]) + }) + + it('sends signal via process.kill', () => { + const proc = mockPtyProcess(99) + spawnMock.mockReturnValue(proc) + + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + handle.signal('SIGINT') + + expect(killSpy).toHaveBeenCalledWith(99, 'SIGINT') + killSpy.mockRestore() + }) + + it('uses SHELL env or defaults to /bin/zsh on non-Windows', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + + const shellArg = spawnMock.mock.calls[0][0] + expect(typeof shellArg).toBe('string') + expect(shellArg.length).toBeGreaterThan(0) + }) + + it('passes custom env to spawned process', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: { MY_VAR: 'test-value' } + }) + + const lastCall = spawnMock.mock.calls.at(-1)! + const spawnEnv = lastCall[2].env + expect(spawnEnv.MY_VAR).toBe('test-value') + }) + + it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const originalUserProfile = process.env.USERPROFILE + const originalHomeDrive = process.env.HOMEDRIVE + const originalHomePath = process.env.HOMEPATH + + Object.defineProperty(process, 'platform', { value: 'win32' }) + delete process.env.USERPROFILE + process.env.HOMEDRIVE = 'D:' + process.env.HOMEPATH = '\\Users\\orca' + + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = originalUserProfile + } + if (originalHomeDrive === undefined) { + delete process.env.HOMEDRIVE + } else { + process.env.HOMEDRIVE = originalHomeDrive + } + if (originalHomePath === undefined) { + delete process.env.HOMEPATH + } else { + process.env.HOMEPATH = originalHomePath + } + } + + expect(spawnMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ cwd: 'D:\\Users\\orca' }) + ) + }) +}) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts new file mode 100644 index 000000000..b56f1fa08 --- /dev/null +++ b/src/main/daemon/pty-subprocess.ts @@ -0,0 +1,99 @@ +import * as pty from 'node-pty' +import type { SubprocessHandle } from './session' +import { getShellReadyLaunchConfig, resolvePtyShellPath } from './shell-ready' + +export type PtySubprocessOptions = { + sessionId: string + cols: number + rows: number + cwd?: string + env?: Record + command?: string +} + +function getDefaultCwd(): string { + if (process.platform !== 'win32') { + return process.env.HOME || '/' + } + + // Why: HOMEPATH alone is drive-relative (`\\Users\\name`). Pair it with + // HOMEDRIVE when USERPROFILE is unavailable so daemon-spawned Windows PTYs + // still start in a valid absolute home directory. + if (process.env.USERPROFILE) { + return process.env.USERPROFILE + } + if (process.env.HOMEDRIVE && process.env.HOMEPATH) { + return `${process.env.HOMEDRIVE}${process.env.HOMEPATH}` + } + return 'C:\\' +} + +export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandle { + const env: Record = { + ...process.env, + ...opts.env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'Orca' + } as Record + + env.LANG ??= 'en_US.UTF-8' + + const shellPath = resolvePtyShellPath(env) + let shellArgs: string[] + + if (process.platform === 'win32') { + shellArgs = [] + } else { + const shellReadyLaunch = opts.command ? getShellReadyLaunchConfig(shellPath) : null + if (shellReadyLaunch) { + Object.assign(env, shellReadyLaunch.env) + } + shellArgs = shellReadyLaunch?.args ?? ['-l'] + } + + const proc = pty.spawn(shellPath, shellArgs, { + name: 'xterm-256color', + cols: opts.cols, + rows: opts.rows, + cwd: opts.cwd || getDefaultCwd(), + env + }) + + let onDataCb: ((data: string) => void) | null = null + let onExitCb: ((code: number) => void) | null = null + + proc.onData((data) => onDataCb?.(data)) + proc.onExit(({ exitCode }) => onExitCb?.(exitCode)) + + return { + pid: proc.pid, + write: (data) => proc.write(data), + resize: (cols, rows) => proc.resize(cols, rows), + kill: () => proc.kill(), + forceKill: () => { + try { + process.kill(proc.pid, 'SIGKILL') + } catch { + try { + proc.kill() + } catch { + // Process may already be dead + } + } + }, + signal: (sig) => { + try { + process.kill(proc.pid, sig) + } catch { + // Process may already be dead + } + }, + onData: (cb) => { + onDataCb = cb + }, + onExit: (cb) => { + onExitCb = cb + } + } +} diff --git a/src/main/daemon/reattach-snapshot.test.ts b/src/main/daemon/reattach-snapshot.test.ts new file mode 100644 index 000000000..e9f5813e1 --- /dev/null +++ b/src/main/daemon/reattach-snapshot.test.ts @@ -0,0 +1,553 @@ +/* oxlint-disable max-lines */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalHost } from './terminal-host' +import { HeadlessEmulator } from './headless-emulator' +import type { SubprocessHandle } from './session' + +function createMockSubprocess(): SubprocessHandle & { + simulateData: (data: string) => void + simulateExit: (code: number) => void +} { + let onDataCb: ((data: string) => void) | null = null + let onExitCb: ((code: number) => void) | null = null + return { + pid: 42, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), + forceKill: vi.fn(), + signal: vi.fn(), + onData(cb) { + onDataCb = cb + }, + onExit(cb) { + onExitCb = cb + }, + simulateData(data: string) { + onDataCb?.(data) + }, + simulateExit(code: number) { + onExitCb?.(code) + } + } +} + +// Why: simulates what daemon-pty-adapter.ts does when building the PtySpawnResult +// for a reattach. Alt-screen sessions include the full ANSI snapshot because +function buildReattachPayload(snapshot: ReturnType) { + const isAltScreen = snapshot.modes.alternateScreen + const fullPayload = snapshot.rehydrateSequences + snapshot.snapshotAnsi + return { + rehydrateSequences: snapshot.rehydrateSequences, + snapshotAnsi: snapshot.snapshotAnsi, + fullPayload, + isAlternateScreen: isAltScreen, + cols: snapshot.cols, + rows: snapshot.rows + } +} + +// Why: replays the reattach payload into a fresh headless emulator (simulating +// what pty-connection.ts does when writing snapshot data to xterm.js) and then +// feeds the SIGWINCH repaint output to verify the final state is clean. +async function simulateReattachToFreshTerminal( + reattachPayload: string, + sigwinchRepaintData: string, + cols: number, + rows: number +): Promise<{ content: string; cols: number; rows: number }> { + const fresh = new HeadlessEmulator({ cols, rows }) + try { + // Step 1: write reattach payload (what pty-connection writes to xterm.js) + await fresh.write(reattachPayload) + // Step 2: write SIGWINCH repaint data (what the TUI sends after receiving SIGWINCH) + await fresh.write(sigwinchRepaintData) + const result = fresh.getSnapshot() + return { content: result.snapshotAnsi, cols: result.cols, rows: result.rows } + } finally { + fresh.dispose() + } +} + +describe('reattach snapshot flow', () => { + let host: TerminalHost + let lastSub: ReturnType + + afterEach(() => { + host?.dispose() + }) + + function createHost() { + host = new TerminalHost({ + spawnSubprocess: () => { + lastSub = createMockSubprocess() + return lastSub + } + }) + return host + } + + describe('normal-screen TUI (Claude Code style)', () => { + it('snapshot captures normal screen content and modes', async () => { + const h = createHost() + const onData = vi.fn() + await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 24, + streamClient: { onData, onExit: vi.fn() } + }) + + // Simulate Claude Code TUI: bracketed paste + normal screen content + lastSub.simulateData('\x1b[?2004h') // enable bracketed paste + lastSub.simulateData('Claude Code > hello world\r\n') + lastSub.simulateData('Response text here\r\n') + + // Wait for headless emulator to process + await new Promise((r) => setTimeout(r, 50)) + + // Reattach — get snapshot + const result = await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(result.isNew).toBe(false) + expect(result.snapshot).toBeDefined() + expect(result.snapshot!.modes.alternateScreen).toBe(false) + expect(result.snapshot!.modes.bracketedPaste).toBe(true) + expect(result.snapshot!.snapshotAnsi).toContain('hello world') + expect(result.snapshot!.cols).toBe(80) + expect(result.snapshot!.rows).toBe(24) + }) + + it('reattach payload includes snapshotAnsi for normal screen', async () => { + const h = createHost() + await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + lastSub.simulateData('\x1b[?2004h') + lastSub.simulateData('prompt> ') + await new Promise((r) => setTimeout(r, 50)) + + const result = await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const payload = buildReattachPayload(result.snapshot!) + expect(payload.rehydrateSequences).toContain('\x1b[?2004h') + expect(payload.snapshotAnsi).toContain('prompt>') + expect(payload.fullPayload).toContain('prompt>') + expect(payload.isAlternateScreen).toBe(false) + }) + + it('normal-screen payload restores modes and content', async () => { + const h = createHost() + await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 10, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + lastSub.simulateData('\x1b[?2004h') + lastSub.simulateData('line 1\r\nline 2\r\nline 3\r\n') + await new Promise((r) => setTimeout(r, 50)) + + const result = await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 10, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const payload = buildReattachPayload(result.snapshot!) + const fresh = new HeadlessEmulator({ cols: 80, rows: 10 }) + await fresh.write(payload.fullPayload) + const freshSnapshot = fresh.getSnapshot() + fresh.dispose() + + expect(freshSnapshot.modes.bracketedPaste).toBe(true) + expect(freshSnapshot.snapshotAnsi).toContain('line 1') + }) + + it('SIGWINCH repaint after snapshot produces clean state', async () => { + const h = createHost() + await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 10, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + lastSub.simulateData('original content\r\n') + await new Promise((r) => setTimeout(r, 50)) + + const result = await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 10, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const payload = buildReattachPayload(result.snapshot!) + // Simulate SIGWINCH repaint: clear screen + redraw + const repaintData = '\x1b[2J\x1b[3J\x1b[Hrepainted content\r\n' + const { content } = await simulateReattachToFreshTerminal( + payload.fullPayload, + repaintData, + 80, + 10 + ) + + expect(content).toContain('repainted content') + // Original content should be cleared by the repaint + expect(content).not.toContain('original content') + }) + }) + + describe('alternate-screen TUI (Codex style)', () => { + it('snapshot detects alternate screen mode', async () => { + const h = createHost() + await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + // Simulate Codex entering alternate screen + lastSub.simulateData('\x1b[?1049h') + lastSub.simulateData('\x1b[?2004h') + lastSub.simulateData('\x1b[H\x1b[2JCodex TUI content\r\n> input prompt') + await new Promise((r) => setTimeout(r, 50)) + + const result = await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(result.snapshot!.modes.alternateScreen).toBe(true) + }) + + it('reattach payload includes snapshotAnsi for alternate screen', async () => { + const h = createHost() + await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + lastSub.simulateData('\x1b[?1049h') + lastSub.simulateData('\x1b[?2004h') + lastSub.simulateData('\x1b[H\x1b[2JCodex TUI content') + await new Promise((r) => setTimeout(r, 50)) + + const result = await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const payload = buildReattachPayload(result.snapshot!) + // Why: alt-screen sessions include the full content snapshot because + // POSIX signal coalescing may prevent the SIGWINCH repaint from arriving. + // The snapshot keeps the TUI visible; any repaint overwrites it via + // absolute cursor positioning. + expect(payload.rehydrateSequences).toContain('\x1b[?1049h') + expect(payload.rehydrateSequences).toContain('\x1b[?2004h') + expect(payload.snapshotAnsi).toContain('Codex TUI content') + expect(payload.fullPayload).toContain('Codex TUI content') + }) + + it('SIGWINCH repaint after rehydrate produces clean single render', async () => { + const h = createHost() + await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 10, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + lastSub.simulateData('\x1b[?1049h\x1b[?2004h') + lastSub.simulateData('\x1b[H\x1b[2Jold TUI content\r\n> old prompt') + await new Promise((r) => setTimeout(r, 50)) + + const result = await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 10, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const payload = buildReattachPayload(result.snapshot!) + // Simulate single SIGWINCH repaint (what Codex sends) + const repaintData = '\x1b[H\x1b[2Jnew TUI content\r\n> new prompt' + const { content } = await simulateReattachToFreshTerminal( + payload.fullPayload, + repaintData, + 80, + 10 + ) + + expect(content).toContain('new TUI content') + expect(content).toContain('new prompt') + // Old content should not appear + expect(content).not.toContain('old TUI content') + expect(content).not.toContain('old prompt') + }) + + it('double SIGWINCH repaint at same dims does not duplicate content', async () => { + const repaint = '\x1b[H\x1b[2Jcodex content\r\n> prompt' + // Simulate receiving two identical repaints (e.g. from resize + explicit SIGWINCH) + const fresh = new HeadlessEmulator({ cols: 80, rows: 10 }) + await fresh.write('\x1b[?1049h') // enter alternate screen + await fresh.write(repaint) + await fresh.write(repaint) // second identical repaint + + const snapshot = fresh.getSnapshot() + fresh.dispose() + + // Content should appear exactly once, not duplicated + const matches = snapshot.snapshotAnsi.match(/codex content/g) + expect(matches).toHaveLength(1) + }) + }) + + describe('Ink-style cursor-relative repaint (Codex)', () => { + it('snapshot + Ink-style repaint overwrites correctly without clear', async () => { + // Simulates the reattach flow for an Ink-based TUI (Codex). + // Ink repaints by: cursor-up-N → erase-to-end → write new content. + // The snapshot positions xterm.js cursor where Ink expects it, + // so the repaint overwrites the snapshot correctly. + const cols = 80 + const rows = 10 + const fresh = new HeadlessEmulator({ cols, rows }) + + // Step 1: write snapshot (simulating pty-connection) + // Ink rendered 3 lines, cursor ends at the end of line 3 + await fresh.write('old TUI header\r\n> old input\r\nstatus bar') + + // Step 2: NO clear — cursor stays where snapshot left it (end of line 3) + + // Step 3: Ink-style SIGWINCH repaint: move cursor up 3 lines, + // clear to end, write new content + await fresh.write('\x1b[3A\x1b[J') + await fresh.write('new TUI header\r\n> new input\r\nnew status') + + const snapshot = fresh.getSnapshot() + fresh.dispose() + + expect(snapshot.snapshotAnsi).toContain('new TUI header') + expect(snapshot.snapshotAnsi).toContain('new input') + expect(snapshot.snapshotAnsi).not.toContain('old TUI header') + expect(snapshot.snapshotAnsi).not.toContain('old input') + }) + + it('clear before Ink repaint breaks cursor positioning', async () => { + // Proves the problem: clearing resets cursor to (1,1), but Ink's + // cursor-up-N expects cursor at end of previous render. The mismatch + // causes content to render at wrong positions. + const cols = 80 + const rows = 10 + const fresh = new HeadlessEmulator({ cols, rows }) + + // Step 1: write snapshot (3 lines, cursor at end of line 3) + await fresh.write('old TUI header\r\n> old input\r\nstatus bar') + + // Step 2: clear — cursor moves to (1,1) + await fresh.write('\x1b[2J\x1b[3J\x1b[H') + + // Step 3: Ink-style repaint: cursor-up-3 from (1,1) → clamped to (1,1) + // Ink clears and writes from row 1 — happens to work in this case + // because cursor-up is clamped, but the cursor column is wrong + await fresh.write('\x1b[3A\x1b[J') + await fresh.write('new TUI header\r\n> new input\r\nnew status') + + const snapshot = fresh.getSnapshot() + fresh.dispose() + + // In this simple case it still works because cursor-up clamping + // happens to land at the right row. But for more complex TUI + // layouts (e.g., cursor not at column 1 of last line), the clear + // would cause column misalignment. + expect(snapshot.snapshotAnsi).toContain('new TUI header') + }) + + it('Ink repaint with absolute row positioning breaks after clear', async () => { + // Some Ink versions use absolute cursor positioning. + // Without clear: cursor is where snapshot left it, absolute pos works. + // With clear: absolute pos still works (not affected by cursor). + // But: if Ink uses CSR (scroll regions) or relative moves based on + // stored line count, the clear creates a mismatch. + const cols = 80 + const rows = 10 + const fresh = new HeadlessEmulator({ cols, rows }) + + // Snapshot places content starting at row 3 (2 blank rows above) + await fresh.write('\x1b[3;1Hheader line\r\n> prompt\r\nfooter') + + // No clear — Ink rewrites from row 3 + await fresh.write('\x1b[3;1H\x1b[Jnew header\r\n> new prompt\r\nnew footer') + + const snapshot = fresh.getSnapshot() + fresh.dispose() + + expect(snapshot.snapshotAnsi).toContain('new header') + expect(snapshot.snapshotAnsi).not.toContain('header line') + }) + }) + + describe('dimension handling', () => { + it('snapshot dimensions match daemon session dimensions', async () => { + const h = createHost() + await h.createOrAttach({ + sessionId: 's1', + cols: 120, + rows: 30, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + lastSub.simulateData('content\r\n') + await new Promise((r) => setTimeout(r, 50)) + + // Reattach with different dims (simulating eager spawn at 80x24) + const result = await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + // Snapshot should be at the SESSION's dimensions, not the request's + expect(result.snapshot!.cols).toBe(120) + expect(result.snapshot!.rows).toBe(30) + }) + + it('resize before reattach updates snapshot dimensions', async () => { + const h = createHost() + await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + // Resize session (simulating a previous resize before app quit) + h.resize('s1', 120, 30) + + lastSub.simulateData('content\r\n') + await new Promise((r) => setTimeout(r, 50)) + + const result = await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(result.snapshot!.cols).toBe(120) + expect(result.snapshot!.rows).toBe(30) + }) + }) + + describe('inline-viewport (Codex/ratatui) reattach', () => { + it('fullPayload includes inline-viewport content for normal screen', async () => { + const h = createHost() + await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 10, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + lastSub.simulateData('task output line 1\r\n') + lastSub.simulateData('task output line 2\r\n') + lastSub.simulateData('task output line 3\r\n') + lastSub.simulateData('╭─ Codex ──────────╮\r\n') + lastSub.simulateData('│ Working... │\r\n') + lastSub.simulateData('╰──────────────────╯\r\n') + await new Promise((r) => setTimeout(r, 50)) + + const result = await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 10, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(result.snapshot!.modes.alternateScreen).toBe(false) + const payload = buildReattachPayload(result.snapshot!) + + expect(payload.snapshotAnsi).toContain('task output line 1') + expect(payload.snapshotAnsi).toContain('Codex') + expect(payload.fullPayload).toContain('Codex') + expect(payload.isAlternateScreen).toBe(false) + }) + + it('full payload + SIGWINCH repaint produces clean viewport', async () => { + const h = createHost() + await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 10, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + lastSub.simulateData('history line 1\r\n') + lastSub.simulateData('history line 2\r\n') + lastSub.simulateData('viewport content\r\n') + await new Promise((r) => setTimeout(r, 50)) + + const result = await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 10, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const payload = buildReattachPayload(result.snapshot!) + + // Simulate what the renderer does: clear screen, write full + // snapshot, then the TUI repaints its viewport on SIGWINCH. + const fresh = new HeadlessEmulator({ cols: 80, rows: 10 }) + await fresh.write('\x1b[2J\x1b[3J\x1b[H') + await fresh.write(payload.fullPayload) + // SIGWINCH repaint — TUI redraws its viewport area + await fresh.write('\x1b[4;1H\x1b[Jnew viewport content\r\n') + + const finalSnapshot = fresh.getSnapshot() + fresh.dispose() + + expect(finalSnapshot.snapshotAnsi).toContain('new viewport content') + }) + }) + + describe('signal support', () => { + it('host.signal sends signal to subprocess', async () => { + const h = createHost() + await h.createOrAttach({ + sessionId: 's1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + h.signal('s1', 'SIGWINCH') + expect(lastSub.signal).toHaveBeenCalledWith('SIGWINCH') + }) + }) +}) diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts new file mode 100644 index 000000000..119c2cf13 --- /dev/null +++ b/src/main/daemon/session.test.ts @@ -0,0 +1,342 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Session } from './session' +import type { SessionState, ShellReadyState } from './types' + +// Stub the subprocess — Session talks to it via an interface, not child_process directly. +function createMockSubprocess() { + const written: string[] = [] + const signals: string[] = [] + let onData: ((data: string) => void) | null = null + let onExit: ((code: number) => void) | null = null + let killed = false + let pid = 12345 + + return { + written, + signals, + get killed() { + return killed + }, + get pid() { + return pid + }, + write(data: string) { + written.push(data) + }, + resize(_cols: number, _rows: number) {}, + kill() { + killed = true + // Simulate async exit + setTimeout(() => onExit?.(0), 5) + }, + forceKill() { + killed = true + }, + signal(sig: string) { + signals.push(sig) + }, + onData(cb: (data: string) => void) { + onData = cb + }, + onExit(cb: (code: number) => void) { + onExit = cb + }, + // Helpers for tests to simulate subprocess events + simulateData(data: string) { + onData?.(data) + }, + simulateExit(code: number) { + onExit?.(code) + } + } +} + +type MockSubprocess = ReturnType + +describe('Session', () => { + let session: Session + let subprocess: MockSubprocess + + beforeEach(() => { + vi.useFakeTimers() + subprocess = createMockSubprocess() + }) + + afterEach(() => { + session?.dispose() + vi.useRealTimers() + }) + + function createSession(opts?: { + shellReadySupported?: boolean + cols?: number + rows?: number + }): Session { + session = new Session({ + sessionId: 'test-session', + cols: opts?.cols ?? 80, + rows: opts?.rows ?? 24, + subprocess, + shellReadySupported: opts?.shellReadySupported ?? false + }) + return session + } + + describe('state machine', () => { + it('starts in running state when shell readiness is not supported', () => { + createSession({ shellReadySupported: false }) + expect(session.state).toBe('running' satisfies SessionState) + expect(session.shellState).toBe('unsupported' satisfies ShellReadyState) + }) + + it('starts in running state with pending shell when readiness is supported', () => { + createSession({ shellReadySupported: true }) + expect(session.state).toBe('running') + expect(session.shellState).toBe('pending' satisfies ShellReadyState) + }) + + it('transitions to exited when subprocess exits', () => { + createSession() + subprocess.simulateExit(0) + expect(session.state).toBe('exited' satisfies SessionState) + expect(session.isAlive).toBe(false) + }) + + it('tracks exit code', () => { + createSession() + subprocess.simulateExit(42) + expect(session.exitCode).toBe(42) + }) + }) + + describe('data flow', () => { + it('forwards subprocess data to attached clients', () => { + createSession() + const received: string[] = [] + session.attachClient({ + onData: (data) => received.push(data), + onExit: () => {} + }) + + subprocess.simulateData('hello') + expect(received).toEqual(['hello']) + }) + + it('does not deliver data to detached clients', () => { + createSession() + const received: string[] = [] + const token = session.attachClient({ + onData: (data) => received.push(data), + onExit: () => {} + }) + + session.detachClient(token) + subprocess.simulateData('should not arrive') + expect(received).toEqual([]) + }) + + it('supports multiple attached clients', () => { + createSession() + const received1: string[] = [] + const received2: string[] = [] + session.attachClient({ onData: (d) => received1.push(d), onExit: () => {} }) + session.attachClient({ onData: (d) => received2.push(d), onExit: () => {} }) + + subprocess.simulateData('broadcast') + expect(received1).toEqual(['broadcast']) + expect(received2).toEqual(['broadcast']) + }) + }) + + describe('write', () => { + it('forwards writes to subprocess when running', () => { + createSession({ shellReadySupported: false }) + session.write('ls\n') + expect(subprocess.written).toEqual(['ls\n']) + }) + }) + + describe('shell readiness gating', () => { + it('buffers writes during pending state', () => { + createSession({ shellReadySupported: true }) + expect(session.shellState).toBe('pending') + + session.write('buffered input') + expect(subprocess.written).toEqual([]) + }) + + it('flushes buffered writes when shell marker is detected', () => { + createSession({ shellReadySupported: true }) + + session.write('pre-ready input') + expect(subprocess.written).toEqual([]) + + // Simulate the shell marker arriving in PTY output + subprocess.simulateData('\x1b]777;orca-shell-ready\x07') + expect(session.shellState).toBe('ready' satisfies ShellReadyState) + expect(subprocess.written).toEqual(['pre-ready input']) + }) + + it('transitions to timed_out after 15 seconds', () => { + createSession({ shellReadySupported: true }) + session.write('waiting input') + + vi.advanceTimersByTime(15_000) + + expect(session.shellState).toBe('timed_out' satisfies ShellReadyState) + expect(subprocess.written).toEqual(['waiting input']) + }) + + it('detects marker split across data chunks', () => { + createSession({ shellReadySupported: true }) + + subprocess.simulateData('\x1b]777;orca-sh') + expect(session.shellState).toBe('pending') + + subprocess.simulateData('ell-ready\x07') + expect(session.shellState).toBe('ready') + }) + }) + + describe('kill', () => { + it('kills the subprocess', () => { + createSession() + session.kill() + expect(subprocess.killed).toBe(true) + expect(session.isTerminating).toBe(true) + }) + + it('notifies attached clients on exit after kill', async () => { + vi.useRealTimers() + createSession() + const exitCodes: number[] = [] + session.attachClient({ + onData: () => {}, + onExit: (code) => exitCodes.push(code) + }) + + session.kill() + + // Wait for the simulated async exit + await new Promise((r) => setTimeout(r, 20)) + expect(exitCodes).toEqual([0]) + }) + + it('force-disposes after 5s if subprocess does not exit', () => { + createSession() + // Override kill to NOT trigger exit + subprocess.kill = () => {} + const forceKillSpy = vi.spyOn(subprocess, 'forceKill') + + session.kill() + expect(session.state).not.toBe('exited') + + vi.advanceTimersByTime(5_000) + expect(session.state).toBe('exited') + expect(forceKillSpy).toHaveBeenCalled() + }) + + it('ignores late data and exit after force-dispose', () => { + createSession() + subprocess.kill = () => {} + const onData = vi.fn() + const onExit = vi.fn() + session.attachClient({ onData, onExit }) + + session.kill() + vi.advanceTimersByTime(5_000) + + subprocess.simulateData('late output') + subprocess.simulateExit(23) + + expect(onData).not.toHaveBeenCalled() + expect(onExit).toHaveBeenCalledTimes(1) + expect(onExit).toHaveBeenCalledWith(-1) + expect(session.exitCode).toBe(-1) + }) + }) + + describe('signal', () => { + it('forwards signal to subprocess without entering terminating state', () => { + createSession() + session.signal('SIGINT') + expect(subprocess.signals).toEqual(['SIGINT']) + expect(session.isTerminating).toBe(false) + }) + }) + + describe('snapshot', () => { + it('returns a terminal snapshot', async () => { + createSession() + subprocess.simulateData('$ hello\r\n') + // Give emulator time to process + await vi.advanceTimersByTimeAsync(10) + + const snapshot = session.getSnapshot() + expect(snapshot).toBeDefined() + expect(snapshot!.cols).toBe(80) + expect(snapshot!.rows).toBe(24) + }) + + it('returns null after session is disposed', () => { + createSession() + session.dispose() + expect(session.getSnapshot()).toBeNull() + }) + }) + + describe('resize', () => { + it('resizes the emulator and subprocess', () => { + createSession() + const resizeSpy = vi.spyOn(subprocess, 'resize') + session.resize(120, 40) + expect(resizeSpy).toHaveBeenCalledWith(120, 40) + }) + + it('same-dim resize passes through without tricks', () => { + createSession({ cols: 80, rows: 24 }) + const resizeSpy = vi.spyOn(subprocess, 'resize') + session.resize(80, 24) + expect(resizeSpy).toHaveBeenCalledTimes(1) + expect(resizeSpy).toHaveBeenCalledWith(80, 24) + }) + }) + + describe('detach token guard', () => { + it('ignores stale detach with wrong token', () => { + createSession() + const received: string[] = [] + const token1 = session.attachClient({ + onData: (d) => received.push(d), + onExit: () => {} + }) + + // Attach a second client (same conceptual slot but new token) + session.attachClient({ + onData: (d) => received.push(d), + onExit: () => {} + }) + + // Try detaching with the old token — should only remove token1's client + session.detachClient(token1) + received.length = 0 + + subprocess.simulateData('after detach') + // token2's client should still receive data + expect(received).toEqual(['after detach']) + }) + }) + + describe('dispose', () => { + it('cleans up without throwing', () => { + createSession() + expect(() => session.dispose()).not.toThrow() + }) + + it('marks session as exited', () => { + createSession() + session.dispose() + expect(session.state).toBe('exited') + }) + }) +}) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts new file mode 100644 index 000000000..e36e5cebd --- /dev/null +++ b/src/main/daemon/session.ts @@ -0,0 +1,309 @@ +import { HeadlessEmulator } from './headless-emulator' +import type { SessionState, ShellReadyState, TerminalSnapshot } from './types' + +const SHELL_READY_TIMEOUT_MS = 15_000 +const KILL_TIMEOUT_MS = 5_000 +const SHELL_READY_MARKER = '\x1b]777;orca-shell-ready\x07' + +export type SubprocessHandle = { + pid: number + write(data: string): void + resize(cols: number, rows: number): void + kill(): void + forceKill(): void + signal(sig: string): void + onData(cb: (data: string) => void): void + onExit(cb: (code: number) => void): void +} + +export type SessionOptions = { + sessionId: string + cols: number + rows: number + subprocess: SubprocessHandle + shellReadySupported: boolean + scrollback?: number +} + +type AttachedClient = { + token: symbol + onData: (data: string) => void + onExit: (code: number) => void +} + +export class Session { + readonly sessionId: string + private _state: SessionState = 'running' + private _shellState: ShellReadyState + private _exitCode: number | null = null + private _isTerminating = false + private _disposed = false + private emulator: HeadlessEmulator + private subprocess: SubprocessHandle + private attachedClients: AttachedClient[] = [] + private preReadyStdinQueue: string[] = [] + private markerBuffer = '' + private shellReadyTimer: ReturnType | null = null + private killTimer: ReturnType | null = null + + constructor(opts: SessionOptions) { + this.sessionId = opts.sessionId + this.subprocess = opts.subprocess + this.emulator = new HeadlessEmulator({ + cols: opts.cols, + rows: opts.rows, + scrollback: opts.scrollback, + onData: (data) => { + // Forward xterm.js query responses (DA1 etc.) to subprocess + opts.subprocess.write(data) + } + }) + + if (opts.shellReadySupported) { + this._shellState = 'pending' + this.shellReadyTimer = setTimeout(() => { + this.onShellReadyTimeout() + }, SHELL_READY_TIMEOUT_MS) + } else { + this._shellState = 'unsupported' + } + + this.subprocess.onData((data) => this.handleSubprocessData(data)) + this.subprocess.onExit((code) => this.handleSubprocessExit(code)) + } + + get state(): SessionState { + return this._state + } + + get shellState(): ShellReadyState { + return this._shellState + } + + get exitCode(): number | null { + return this._exitCode + } + + get isAlive(): boolean { + return this._state !== 'exited' + } + + get isTerminating(): boolean { + return this._isTerminating + } + + get pid(): number { + return this.subprocess.pid + } + + write(data: string): void { + if (this._state === 'exited' || this._disposed) { + return + } + + if (this._shellState === 'pending') { + this.preReadyStdinQueue.push(data) + return + } + + this.subprocess.write(data) + } + + resize(cols: number, rows: number): void { + if (this._state === 'exited' || this._disposed) { + return + } + this.emulator.resize(cols, rows) + this.subprocess.resize(cols, rows) + } + + kill(): void { + if (this._state === 'exited' || this._isTerminating) { + return + } + this._isTerminating = true + + this.subprocess.kill() + + this.killTimer = setTimeout(() => { + if (this._state !== 'exited') { + this.forceDispose() + } + }, KILL_TIMEOUT_MS) + } + + signal(sig: string): void { + if (this._state === 'exited') { + return + } + this.subprocess.signal(sig) + } + + attachClient(client: { onData: (data: string) => void; onExit: (code: number) => void }): symbol { + const token = Symbol('attach') + this.attachedClients.push({ token, ...client }) + return token + } + + detachClient(token: symbol): void { + const idx = this.attachedClients.findIndex((c) => c.token === token) + if (idx !== -1) { + this.attachedClients.splice(idx, 1) + } + } + + detachAllClients(): void { + this.attachedClients.length = 0 + } + + getSnapshot(): TerminalSnapshot | null { + if (this._disposed) { + return null + } + return this.emulator.getSnapshot() + } + + getCwd(): string | null { + return this.emulator.getCwd() + } + + clearScrollback(): void { + if (this._disposed) { + return + } + this.emulator.clearScrollback() + } + + dispose(): void { + if (this._disposed) { + return + } + this._disposed = true + this._state = 'exited' + + if (this.shellReadyTimer) { + clearTimeout(this.shellReadyTimer) + this.shellReadyTimer = null + } + if (this.killTimer) { + clearTimeout(this.killTimer) + this.killTimer = null + } + + this.attachedClients = [] + this.preReadyStdinQueue = [] + this.emulator.dispose() + } + + private handleSubprocessData(data: string): void { + if (this._disposed) { + return + } + + // Feed data to headless emulator for state tracking + this.emulator.write(data) + + if (this._shellState === 'pending') { + this.scanForShellMarker(data) + } + + // Broadcast to attached clients + for (const client of this.attachedClients) { + client.onData(data) + } + } + + private handleSubprocessExit(code: number): void { + if (this._disposed) { + return + } + + this._exitCode = code + this._state = 'exited' + + if (this.killTimer) { + clearTimeout(this.killTimer) + this.killTimer = null + } + if (this.shellReadyTimer) { + clearTimeout(this.shellReadyTimer) + this.shellReadyTimer = null + } + + for (const client of this.attachedClients) { + client.onExit(code) + } + } + + private scanForShellMarker(data: string): void { + this.markerBuffer += data + + const markerIdx = this.markerBuffer.indexOf(SHELL_READY_MARKER) + if (markerIdx !== -1) { + this.markerBuffer = '' + this.transitionToReady() + return + } + + // Keep only the tail that could be the start of a partial marker match + const maxPartial = SHELL_READY_MARKER.length - 1 + if (this.markerBuffer.length > maxPartial) { + this.markerBuffer = this.markerBuffer.slice(-maxPartial) + } + } + + private transitionToReady(): void { + this._shellState = 'ready' + if (this.shellReadyTimer) { + clearTimeout(this.shellReadyTimer) + this.shellReadyTimer = null + } + this.flushPreReadyQueue() + } + + private onShellReadyTimeout(): void { + this.shellReadyTimer = null + if (this._shellState !== 'pending') { + return + } + this._shellState = 'timed_out' + this.flushPreReadyQueue() + } + + private flushPreReadyQueue(): void { + const queued = this.preReadyStdinQueue + this.preReadyStdinQueue = [] + for (const data of queued) { + this.subprocess.write(data) + } + } + + private forceDispose(): void { + if (this._state === 'exited') { + return + } + this.subprocess.forceKill() + this._disposed = true + this._exitCode = -1 + this._state = 'exited' + this._isTerminating = false + + if (this.shellReadyTimer) { + clearTimeout(this.shellReadyTimer) + this.shellReadyTimer = null + } + if (this.killTimer) { + clearTimeout(this.killTimer) + this.killTimer = null + } + + const clients = this.attachedClients + this.attachedClients = [] + this.preReadyStdinQueue = [] + this.emulator.dispose() + + for (const client of clients) { + client.onExit(-1) + } + } +} diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts new file mode 100644 index 000000000..5afc55b3c --- /dev/null +++ b/src/main/daemon/shell-ready.ts @@ -0,0 +1,135 @@ +import { tmpdir } from 'os' +import { basename, join } from 'path' +import { chmodSync, mkdirSync, writeFileSync } from 'fs' + +const SHELL_READY_WRAPPER_ROOT = join(tmpdir(), 'orca-shell-ready') +const SHELL_READY_MARKER = '\\033]777;orca-shell-ready\\007' + +let didEnsureShellReadyWrappers = false + +function quotePosixSingle(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function ensureShellReadyWrappers(): void { + if (didEnsureShellReadyWrappers || process.platform === 'win32') { + return + } + didEnsureShellReadyWrappers = true + + const zshDir = join(SHELL_READY_WRAPPER_ROOT, 'zsh') + const bashDir = join(SHELL_READY_WRAPPER_ROOT, 'bash') + + const zshEnv = `# Orca daemon zsh shell-ready wrapper +export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}" +[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv" +export ZDOTDIR=${quotePosixSingle(zshDir)} +` + const zshProfile = `# Orca daemon zsh shell-ready wrapper +_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}" +[[ -f "$_orca_home/.zprofile" ]] && source "$_orca_home/.zprofile" +` + const zshRc = `# Orca daemon zsh shell-ready wrapper +_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}" +if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then + source "$_orca_home/.zshrc" +fi +` + const zshLogin = `# Orca daemon zsh shell-ready wrapper +_orca_home="\${ORCA_ORIG_ZDOTDIR:-$HOME}" +if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then + source "$_orca_home/.zlogin" +fi +__orca_prompt_mark() { + printf "${SHELL_READY_MARKER}" +} +precmd_functions=(\${precmd_functions[@]} __orca_prompt_mark) +` + const bashRc = `# Orca daemon bash shell-ready wrapper +[[ -f /etc/profile ]] && source /etc/profile +if [[ -f "$HOME/.bash_profile" ]]; then + source "$HOME/.bash_profile" +elif [[ -f "$HOME/.bash_login" ]]; then + source "$HOME/.bash_login" +elif [[ -f "$HOME/.profile" ]]; then + source "$HOME/.profile" +fi +__orca_prompt_mark() { + printf "${SHELL_READY_MARKER}" +} +if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then + PROMPT_COMMAND=("\${PROMPT_COMMAND[@]}" "__orca_prompt_mark") +else + _orca_prev_prompt_command="\${PROMPT_COMMAND}" + if [[ -n "\${_orca_prev_prompt_command}" ]]; then + PROMPT_COMMAND="\${_orca_prev_prompt_command};__orca_prompt_mark" + else + PROMPT_COMMAND="__orca_prompt_mark" + fi +fi +` + + const files = [ + [join(zshDir, '.zshenv'), zshEnv], + [join(zshDir, '.zprofile'), zshProfile], + [join(zshDir, '.zshrc'), zshRc], + [join(zshDir, '.zlogin'), zshLogin], + [join(bashDir, 'rcfile'), bashRc] + ] as const + + for (const [path, content] of files) { + mkdirSync(path.slice(0, path.lastIndexOf('/')), { recursive: true }) + writeFileSync(path, content, 'utf8') + chmodSync(path, 0o644) + } +} + +export function resolvePtyShellPath(env: Record): string { + if (process.platform === 'win32') { + return env.COMSPEC || 'powershell.exe' + } + return env.SHELL || process.env.SHELL || '/bin/zsh' +} + +export function supportsPtyStartupBarrier(env: Record): boolean { + if (process.platform === 'win32') { + return false + } + const shellName = basename(resolvePtyShellPath(env)).toLowerCase() + return shellName === 'zsh' || shellName === 'bash' +} + +export function getShellReadyLaunchConfig(shellPath: string): { + args: string[] | null + env: Record + supportsReadyMarker: boolean +} { + const shellName = basename(shellPath).toLowerCase() + + if (shellName === 'zsh') { + ensureShellReadyWrappers() + return { + args: ['-l'], + env: { + ORCA_ORIG_ZDOTDIR: process.env.ZDOTDIR || process.env.HOME || '', + ZDOTDIR: join(SHELL_READY_WRAPPER_ROOT, 'zsh') + }, + supportsReadyMarker: true + } + } + + if (shellName === 'bash') { + ensureShellReadyWrappers() + return { + args: ['--rcfile', join(SHELL_READY_WRAPPER_ROOT, 'bash', 'rcfile')], + env: {}, + supportsReadyMarker: true + } + } + + return { + args: null, + env: {}, + supportsReadyMarker: false + } +} diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts new file mode 100644 index 000000000..3dfdf7e94 --- /dev/null +++ b/src/main/daemon/terminal-host.test.ts @@ -0,0 +1,307 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalHost } from './terminal-host' +import type { SubprocessHandle } from './session' + +function createMockSubprocess(): SubprocessHandle { + let onDataCb: ((data: string) => void) | null = null + let onExitCb: ((code: number) => void) | null = null + return { + pid: 99999, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => { + setTimeout(() => onExitCb?.(0), 5) + }), + forceKill: vi.fn(), + signal: vi.fn(), + onData(cb) { + onDataCb = cb + }, + onExit(cb) { + onExitCb = cb + }, + // Test helpers + get _onDataCb() { + return onDataCb + }, + get _onExitCb() { + return onExitCb + } + } as SubprocessHandle & { _onDataCb: typeof onDataCb; _onExitCb: typeof onExitCb } +} + +type MockSpawnFn = (opts: { + sessionId: string + cols: number + rows: number + cwd?: string + env?: Record + command?: string +}) => SubprocessHandle + +describe('TerminalHost', () => { + let host: TerminalHost + let spawnFn: MockSpawnFn + let lastSubprocess: ReturnType & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + + beforeEach(() => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess() as ReturnType & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + }) + + afterEach(() => { + host.dispose() + }) + + describe('createOrAttach', () => { + it('creates a new session when none exists', async () => { + const result = await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(result.isNew).toBe(true) + expect(result.pid).toBe(99999) + expect(spawnFn).toHaveBeenCalledOnce() + }) + + it('attaches to existing session', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const result = await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(result.isNew).toBe(false) + // Should not spawn a second subprocess + expect(spawnFn).toHaveBeenCalledOnce() + }) + + it('returns snapshot when attaching to existing session', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const result = await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(result.snapshot).toBeDefined() + expect(result.snapshot?.cols).toBe(80) + }) + + it('passes cwd and env to spawn', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + cwd: '/home/user', + env: { FOO: 'bar' }, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(spawnFn).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-1', + cwd: '/home/user', + env: { FOO: 'bar' } + }) + ) + }) + + it('queues startup commands through the session shell-ready barrier', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'echo hello', + shellReadySupported: true, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(lastSubprocess.write).not.toHaveBeenCalled() + + lastSubprocess._onDataCb?.('\x1b]777;orca-shell-ready\x07') + expect(lastSubprocess.write).toHaveBeenCalledWith('echo hello\n') + }) + }) + + describe('write', () => { + it('forwards write to the session', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + host.write('session-1', 'hello') + expect(lastSubprocess.write).toHaveBeenCalledWith('hello') + }) + + it('throws for non-existent session', () => { + expect(() => host.write('missing', 'data')).toThrow('Session not found') + }) + }) + + describe('resize', () => { + it('forwards resize to the session', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + host.resize('session-1', 120, 40) + expect(lastSubprocess.resize).toHaveBeenCalledWith(120, 40) + }) + }) + + describe('kill', () => { + it('kills the session and tombstones it', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + host.kill('session-1') + expect(lastSubprocess.kill).toHaveBeenCalled() + expect(host.isKilled('session-1')).toBe(true) + }) + + it('throws for non-existent session', () => { + expect(() => host.kill('missing')).toThrow('Session not found') + }) + }) + + describe('signal', () => { + it('sends signal without entering kill state', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + host.signal('session-1', 'SIGINT') + expect(lastSubprocess.signal).toHaveBeenCalledWith('SIGINT') + expect(host.isKilled('session-1')).toBe(false) + }) + }) + + describe('listSessions', () => { + it('returns empty list initially', () => { + expect(host.listSessions()).toEqual([]) + }) + + it('lists created sessions', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + await host.createOrAttach({ + sessionId: 'session-2', + cols: 120, + rows: 40, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const sessions = host.listSessions() + expect(sessions).toHaveLength(2) + expect(sessions.map((s) => s.sessionId).sort()).toEqual(['session-1', 'session-2']) + }) + }) + + describe('detach', () => { + it('detaches a client from a session', async () => { + const onData = vi.fn() + const result = await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData, onExit: vi.fn() } + }) + + host.detach('session-1', result.attachToken) + + // Data after detach should not be received + lastSubprocess._onDataCb?.('after detach') + expect(onData).not.toHaveBeenCalled() + }) + }) + + describe('tombstones', () => { + it('caps tombstones at limit', async () => { + for (let i = 0; i < 1005; i++) { + await host.createOrAttach({ + sessionId: `session-${i}`, + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + host.kill(`session-${i}`) + } + + // Oldest tombstones should be evicted + expect(host.isKilled('session-0')).toBe(false) + expect(host.isKilled('session-1004')).toBe(true) + }) + }) + + describe('dispose', () => { + it('kills live subprocesses before disposing sessions', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + host.dispose() + expect(lastSubprocess.kill).toHaveBeenCalled() + }) + + it('does not list exited sessions', async () => { + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + lastSubprocess._onExitCb?.(0) + expect(host.listSessions()).toEqual([]) + }) + }) +}) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts new file mode 100644 index 000000000..b1e7c72aa --- /dev/null +++ b/src/main/daemon/terminal-host.ts @@ -0,0 +1,194 @@ +import { Session, type SubprocessHandle } from './session' +import type { SessionInfo, TerminalSnapshot, ShellReadyState } from './types' +import { SessionNotFoundError } from './types' + +const MAX_TOMBSTONES = 1000 + +export type CreateOrAttachOptions = { + sessionId: string + cols: number + rows: number + cwd?: string + env?: Record + command?: string + shellReadySupported?: boolean + streamClient: { onData: (data: string) => void; onExit: (code: number) => void } +} + +export type CreateOrAttachResult = { + isNew: boolean + snapshot: TerminalSnapshot | null + pid: number | null + shellState: ShellReadyState + attachToken: symbol +} + +export type TerminalHostOptions = { + spawnSubprocess: (opts: { + sessionId: string + cols: number + rows: number + cwd?: string + env?: Record + command?: string + }) => SubprocessHandle +} + +export class TerminalHost { + private sessions = new Map() + private killedTombstones = new Map() + private spawnSubprocess: TerminalHostOptions['spawnSubprocess'] + + constructor(opts: TerminalHostOptions) { + this.spawnSubprocess = opts.spawnSubprocess + } + + async createOrAttach(opts: CreateOrAttachOptions): Promise { + const existing = this.sessions.get(opts.sessionId) + + if (existing && existing.isAlive) { + const snapshot = existing.getSnapshot() + existing.detachAllClients() + const token = existing.attachClient(opts.streamClient) + return { + isNew: false, + snapshot, + pid: existing.pid, + shellState: existing.shellState, + attachToken: token + } + } + + // Clean up dead session if present + if (existing) { + existing.dispose() + this.sessions.delete(opts.sessionId) + } + + // Clear tombstone if re-creating a killed session + this.killedTombstones.delete(opts.sessionId) + + const subprocess = this.spawnSubprocess({ + sessionId: opts.sessionId, + cols: opts.cols, + rows: opts.rows, + cwd: opts.cwd, + env: opts.env, + command: opts.command + }) + + const session = new Session({ + sessionId: opts.sessionId, + cols: opts.cols, + rows: opts.rows, + subprocess, + shellReadySupported: opts.shellReadySupported ?? false + }) + + this.sessions.set(opts.sessionId, session) + + const token = session.attachClient(opts.streamClient) + + if (opts.command) { + // Why: startup commands must run inside the long-lived interactive shell + // the daemon keeps for the pane. Session.write() handles the shell-ready + // barrier for supported shells and falls back to an immediate write for + // unsupported ones. + session.write(opts.command.endsWith('\n') ? opts.command : `${opts.command}\n`) + } + + return { + isNew: true, + snapshot: null, + pid: subprocess.pid, + shellState: session.shellState, + attachToken: token + } + } + + write(sessionId: string, data: string): void { + this.getAliveSession(sessionId).write(data) + } + + resize(sessionId: string, cols: number, rows: number): void { + this.getAliveSession(sessionId).resize(cols, rows) + } + + kill(sessionId: string): void { + const session = this.getAliveSession(sessionId) + this.recordTombstone(sessionId) + session.kill() + } + + signal(sessionId: string, sig: string): void { + this.getAliveSession(sessionId).signal(sig) + } + + detach(sessionId: string, token: symbol): void { + const session = this.sessions.get(sessionId) + session?.detachClient(token) + } + + getCwd(sessionId: string): string | null { + return this.getAliveSession(sessionId).getCwd() + } + + clearScrollback(sessionId: string): void { + this.getAliveSession(sessionId).clearScrollback() + } + + isKilled(sessionId: string): boolean { + return this.killedTombstones.has(sessionId) + } + + listSessions(): SessionInfo[] { + const result: SessionInfo[] = [] + for (const [, session] of this.sessions) { + if (!session.isAlive) { + continue + } + const snapshot = session.getSnapshot() + result.push({ + sessionId: session.sessionId, + state: session.state, + shellState: session.shellState, + isAlive: true, + pid: session.pid, + cwd: session.getCwd(), + cols: snapshot?.cols ?? 0, + rows: snapshot?.rows ?? 0, + createdAt: 0 + }) + } + return result + } + + dispose(): void { + for (const [, session] of this.sessions) { + session.detachAllClients() + session.kill() + } + this.sessions.clear() + this.killedTombstones.clear() + } + + private getAliveSession(sessionId: string): Session { + const session = this.sessions.get(sessionId) + if (!session || !session.isAlive) { + throw new SessionNotFoundError(sessionId) + } + return session + } + + private recordTombstone(sessionId: string): void { + this.killedTombstones.delete(sessionId) + this.killedTombstones.set(sessionId, Date.now()) + + if (this.killedTombstones.size > MAX_TOMBSTONES) { + const oldest = this.killedTombstones.keys().next().value + if (oldest) { + this.killedTombstones.delete(oldest) + } + } + } +} diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts new file mode 100644 index 000000000..dfa5b72b7 --- /dev/null +++ b/src/main/daemon/types.ts @@ -0,0 +1,263 @@ +// ─── Protocol Version ──────────────────────────────────────────────── +export const PROTOCOL_VERSION = 1 + +// ─── Session State Machine ────────────────────────────────────────── +export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited' + +export type ShellReadyState = 'pending' | 'ready' | 'timed_out' | 'unsupported' + +// ─── Terminal Snapshot ────────────────────────────────────────────── +export type TerminalSnapshot = { + snapshotAnsi: string + /** Scrollback portion only (rows above the visible viewport). Write this + * to preserve history without interfering with TUI repaints. */ + scrollbackAnsi: string + rehydrateSequences: string + cwd: string | null + modes: TerminalModes + cols: number + rows: number + scrollbackLines: number +} + +export type TerminalModes = { + bracketedPaste: boolean + mouseTracking: boolean + applicationCursor: boolean + alternateScreen: boolean +} + +// ─── NDJSON Protocol Messages ─────────────────────────────────────── + +// Hello handshake (first message on each socket) +export type HelloMessage = { + type: 'hello' + version: number + token: string + clientId: string + role: 'control' | 'stream' +} + +export type HelloResponse = { + type: 'hello' + ok: boolean + error?: string +} + +// ─── RPC Requests (Client → Daemon, on control socket) ───────────── + +export type CreateOrAttachRequest = { + id: string + type: 'createOrAttach' + payload: { + sessionId: string + cols: number + rows: number + cwd?: string + env?: Record + command?: string + shellReadySupported?: boolean + } +} + +export type CancelCreateOrAttachRequest = { + id: string + type: 'cancelCreateOrAttach' + payload: { + sessionId: string + } +} + +export type WriteRequest = { + id: string + type: 'write' + payload: { + sessionId: string + data: string + } +} + +export type ResizeRequest = { + id: string + type: 'resize' + payload: { + sessionId: string + cols: number + rows: number + } +} + +export type KillRequest = { + id: string + type: 'kill' + payload: { + sessionId: string + } +} + +export type SignalRequest = { + id: string + type: 'signal' + payload: { + sessionId: string + signal: string + } +} + +export type ListSessionsRequest = { + id: string + type: 'listSessions' +} + +export type DetachRequest = { + id: string + type: 'detach' + payload: { + sessionId: string + } +} + +export type GetCwdRequest = { + id: string + type: 'getCwd' + payload: { + sessionId: string + } +} + +export type ClearScrollbackRequest = { + id: string + type: 'clearScrollback' + payload: { + sessionId: string + } +} + +export type ShutdownRequest = { + id: string + type: 'shutdown' + payload: { + killSessions: boolean + } +} + +export type DaemonRequest = + | CreateOrAttachRequest + | CancelCreateOrAttachRequest + | WriteRequest + | ResizeRequest + | KillRequest + | SignalRequest + | ListSessionsRequest + | DetachRequest + | GetCwdRequest + | ClearScrollbackRequest + | ShutdownRequest + +// ─── RPC Responses (Daemon → Client, on control socket) ──────────── + +export type RpcResponseOk = { + id: string + ok: true + payload: T +} + +export type RpcResponseError = { + id: string + ok: false + error: string +} + +export type RpcResponse = RpcResponseOk | RpcResponseError + +export type CreateOrAttachResult = { + isNew: boolean + snapshot: TerminalSnapshot | null + pid: number | null + shellState: ShellReadyState +} + +export type ListSessionsResult = { + sessions: SessionInfo[] +} + +export type SessionInfo = { + sessionId: string + state: SessionState + shellState: ShellReadyState + isAlive: boolean + pid: number | null + cwd: string | null + cols: number + rows: number + createdAt: number +} + +// ─── Events (Daemon → Client, on stream socket) ──────────────────── + +export type DataEvent = { + type: 'event' + event: 'data' + sessionId: string + payload: { data: string } +} + +export type ExitEvent = { + type: 'event' + event: 'exit' + sessionId: string + payload: { code: number } +} + +export type TerminalErrorEvent = { + type: 'event' + event: 'terminalError' + sessionId: string + payload: { message: string } +} + +export type DaemonEvent = DataEvent | ExitEvent | TerminalErrorEvent + +// ─── Binary Frame Protocol (Daemon ↔ PTY Subprocess) ──────────────── +// +// 5-byte header: [type:1][length:4 big-endian] +// Followed by `length` bytes of payload. + +export const enum FrameType { + Data = 0x01, + Resize = 0x02, + Exit = 0x03, + Error = 0x04, + Kill = 0x05, + Signal = 0x06 +} + +export const FRAME_HEADER_SIZE = 5 +export const FRAME_MAX_PAYLOAD = 1024 * 1024 // 1MB + +// ─── Notify prefix ────────────────────────────────────────────────── +// Requests with IDs starting with this prefix are fire-and-forget: +// the daemon processes them but does not send a response. +export const NOTIFY_PREFIX = 'notify_' + +// ─── Error types ──────────────────────────────────────────────────── +export class TerminalAttachCanceledError extends Error { + constructor(sessionId: string) { + super(`Attach canceled for session ${sessionId}`) + this.name = 'TerminalAttachCanceledError' + } +} + +export class DaemonProtocolError extends Error { + constructor(message: string) { + super(message) + this.name = 'DaemonProtocolError' + } +} + +export class SessionNotFoundError extends Error { + constructor(sessionId: string) { + super(`Session not found: ${sessionId}`) + this.name = 'SessionNotFoundError' + } +} diff --git a/src/main/daemon/xterm-env-polyfill.ts b/src/main/daemon/xterm-env-polyfill.ts new file mode 100644 index 000000000..9ab126272 --- /dev/null +++ b/src/main/daemon/xterm-env-polyfill.ts @@ -0,0 +1,6 @@ +// @xterm/headless checks for `window` to detect browser vs node environment. +// In ELECTRON_RUN_AS_NODE mode, `window` is undefined. This polyfill must be +// imported before any @xterm/headless import. +if (typeof globalThis.window === 'undefined') { + ;(globalThis as Record).window = globalThis +} diff --git a/src/main/index.ts b/src/main/index.ts index 1a08625aa..b246bcc4d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,6 +6,7 @@ import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' import { killAllPty } from './ipc/pty' +import { initDaemonPtyProvider, disconnectDaemon } from './daemon/daemon-init' import { closeAllWatchers } from './ipc/filesystem-watcher' import { registerCoreHandlers } from './ipc/register-core-handlers' import { triggerStartupNotificationRegistration } from './ipc/notifications' @@ -151,6 +152,11 @@ app.whenReady().then(async () => { userDataPath: app.getPath('userData') }) + // Why: daemon must start before openMainWindow because registerPtyHandlers + // (called inside) relies on the provider already being set. Starting it + // alongside the other parallel servers keeps cold-start latency flat. + await initDaemonPtyProvider() + // Why: both server binds are independent and neither blocks window creation. // Parallelizing them with the window open shaves ~100-200ms off cold start. const [win] = await Promise.all([ @@ -200,6 +206,12 @@ app.on('will-quit', () => { openCodeHookService.stop() stats?.flush() killAllPty() + // Why: in daemon mode, killAllPty is a no-op (daemon sessions survive app + // quit) but the client connection must be closed so sockets are released. + // disconnectDaemon only tears down the client transport — it does NOT kill + // the daemon process or mark its history as cleanly ended, preserving both + // warm reattach and crash recovery on next launch. + disconnectDaemon() void closeAllWatchers() if (runtimeRpc) { void runtimeRpc.stop().catch((error) => { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index b15ee8bef..5bf883ca1 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -80,7 +80,7 @@ vi.mock('../pi/titlebar-extension-service', () => ({ clearPty: piClearPtyMock } })) -import { registerPtyHandlers } from './pty' +import { registerPtyHandlers, registerSshPtyProvider, unregisterSshPtyProvider } from './pty' function makeDisposable() { return { dispose: vi.fn() } @@ -147,6 +147,10 @@ describe('registerPtyHandlers', () => { }) }) + afterEach(() => { + unregisterSshPtyProvider('ssh-1') + }) + function createMockProc() { let dataHandler: ((data: string) => void) | null = null let exitHandler: ((event: { exitCode: number }) => void) | null = null @@ -288,6 +292,50 @@ describe('registerPtyHandlers', () => { }) }) + it('lists sessions from both local and SSH providers', async () => { + registerPtyHandlers(mainWindow as never) + const sshListProcesses = vi.fn(async () => [ + { id: 'remote-pty', cwd: '/remote', title: 'ssh-shell' } + ]) + const sshShutdown = vi.fn(async () => undefined) + registerSshPtyProvider('ssh-1', { + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: sshShutdown, + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + onData: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: sshListProcesses, + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + const sessions = (await handlers.get('pty:listSessions')!(null, undefined)) as { + id: string + cwd: string + title: string + }[] + + expect(sshListProcesses).toHaveBeenCalled() + expect(sessions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ cwd: '/remote', id: 'remote-pty', title: 'ssh-shell' }) + ]) + ) + + await handlers.get('pty:kill')!(null, { id: 'remote-pty' }) + expect(sshShutdown).toHaveBeenCalledWith('remote-pty', true) + }) + describe('Windows UTF-8 code page', () => { let originalPlatform: string let originalComspec: string | undefined diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index e1faa55d9..78aa842f5 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -16,7 +16,7 @@ import type { IPtyProvider } from '../providers/types' // Routes PTY operations by connectionId. null = local provider. // SSH providers will be registered here in Phase 1. -const localProvider = new LocalPtyProvider() +let localProvider: IPtyProvider = new LocalPtyProvider() const sshProviders = new Map() // 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 @@ -60,7 +60,17 @@ export function getSshPtyProvider(connectionId: string): IPtyProvider | undefine /** Get the local PTY provider (for direct access in tests/runtime). */ export function getLocalPtyProvider(): LocalPtyProvider { - return localProvider + // Why: callers that need LocalPtyProvider-specific methods (killOrphanedPtys, + // advanceGeneration, getPtyProcess) can only work with the local provider. + // When daemon mode is active, this returns the underlying LocalPtyProvider + // would not be available — callers should check for null or use getProvider(). + return localProvider as LocalPtyProvider +} + +/** Replace the local PTY provider with a daemon-backed one. + * Call before registerPtyHandlers so the IPC layer routes through the daemon. */ +export function setLocalPtyProvider(provider: IPtyProvider): void { + localProvider = provider } /** Get all PTY IDs owned by a given connectionId (for reconnection reattach). */ @@ -122,51 +132,59 @@ export function registerPtyHandlers( // (e.g. when macOS re-activates the app and creates a new window). ipcMain.removeHandler('pty:spawn') ipcMain.removeHandler('pty:kill') + ipcMain.removeHandler('pty:listSessions') ipcMain.removeHandler('pty:hasChildProcesses') ipcMain.removeHandler('pty:getForegroundProcess') ipcMain.removeAllListeners('pty:write') + ipcMain.removeAllListeners('pty:ackColdRestore') - // Configure the local provider with app-specific hooks - localProvider.configure({ - isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true, - buildSpawnEnv: (id, baseEnv) => { - const selectedCodexHomePath = getSelectedCodexHomePath?.() ?? null + // Configure the local provider with app-specific hooks. + // Why: only LocalPtyProvider has the configure() method — daemon-backed + // providers handle subprocess spawning internally and don't need main-process + // hook injection. The hooks (buildSpawnEnv, onSpawned, etc.) only make sense + // when the PTY lives in the Electron main process. + if (localProvider instanceof LocalPtyProvider) { + localProvider.configure({ + isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true, + buildSpawnEnv: (id, baseEnv) => { + const selectedCodexHomePath = getSelectedCodexHomePath?.() ?? null - const openCodeHookEnv = openCodeHookService.buildPtyEnv(id) - if (baseEnv.OPENCODE_CONFIG_DIR) { - // Why: OPENCODE_CONFIG_DIR is a singular extra config root. Replacing a - // user-provided directory would silently hide their custom OpenCode - // config, so preserve it and fall back to title-only detection there. - delete openCodeHookEnv.OPENCODE_CONFIG_DIR - } - Object.assign(baseEnv, openCodeHookEnv) - // Why: PI_CODING_AGENT_DIR owns Pi's full config/session root. Build a - // PTY-scoped overlay from the caller's chosen root so Pi sessions keep - // their user state without sharing a mutable overlay across terminals. - Object.assign( - baseEnv, - piTitlebarExtensionService.buildPtyEnv(id, baseEnv.PI_CODING_AGENT_DIR) - ) + const openCodeHookEnv = openCodeHookService.buildPtyEnv(id) + if (baseEnv.OPENCODE_CONFIG_DIR) { + // Why: OPENCODE_CONFIG_DIR is a singular extra config root. Replacing a + // user-provided directory would silently hide their custom OpenCode + // config, so preserve it and fall back to title-only detection there. + delete openCodeHookEnv.OPENCODE_CONFIG_DIR + } + Object.assign(baseEnv, openCodeHookEnv) + // Why: PI_CODING_AGENT_DIR owns Pi's full config/session root. Build a + // PTY-scoped overlay from the caller's chosen root so Pi sessions keep + // their user state without sharing a mutable overlay across terminals. + Object.assign( + baseEnv, + piTitlebarExtensionService.buildPtyEnv(id, baseEnv.PI_CODING_AGENT_DIR) + ) - // Why: the selected Codex account should affect Codex launched inside - // Orca terminals too, not just Orca's background quota fetches. Inject - // the managed CODEX_HOME only into this PTY environment so the override - // stays scoped to Orca terminals instead of mutating the app process or - // the user's external shells. - if (selectedCodexHomePath) { - baseEnv.CODEX_HOME = selectedCodexHomePath - } + // Why: the selected Codex account should affect Codex launched inside + // Orca terminals too, not just Orca's background quota fetches. Inject + // the managed CODEX_HOME only into this PTY environment so the override + // stays scoped to Orca terminals instead of mutating the app process or + // the user's external shells. + if (selectedCodexHomePath) { + baseEnv.CODEX_HOME = selectedCodexHomePath + } - return baseEnv - }, - onSpawned: (id) => runtime?.onPtySpawned(id), - onExit: (id, code) => { - clearProviderPtyState(id) - ptyOwnership.delete(id) - runtime?.onPtyExit(id, code) - }, - onData: (id, data, timestamp) => runtime?.onPtyData(id, data, timestamp) - }) + return baseEnv + }, + onSpawned: (id) => runtime?.onPtySpawned(id), + onExit: (id, code) => { + clearProviderPtyState(id) + ptyOwnership.delete(id) + runtime?.onPtyExit(id, code) + }, + onData: (id, data, timestamp) => runtime?.onPtyData(id, data, timestamp) + }) + } // Wire up provider events → renderer IPC localDataUnsub?.() @@ -193,6 +211,14 @@ export function registerPtyHandlers( localDataUnsub = localProvider.onData((payload) => { if (mainWindow.isDestroyed()) { + // Why: clear the pending flush timer so it doesn't fire after the window + // is gone. Without this, macOS app re-activation leaks orphaned timers + // from the previous window's registration. + if (flushTimer) { + clearTimeout(flushTimer) + flushTimer = null + } + pendingData.clear() return } const existing = pendingData.get(payload.id) @@ -216,20 +242,24 @@ export function registerPtyHandlers( }) // Kill orphaned PTY processes from previous page loads when the renderer reloads. - // Why: store the handler reference so we can remove it on re-registration, - // preventing duplicate handlers after macOS app re-activation. - if (didFinishLoadHandler) { - mainWindow.webContents.removeListener('did-finish-load', didFinishLoadHandler) - } - didFinishLoadHandler = () => { - const killed = localProvider.killOrphanedPtys(localProvider.advanceGeneration() - 1) - for (const { id } of killed) { - clearProviderPtyState(id) - ptyOwnership.delete(id) - runtime?.onPtyExit(id, -1) + // Why: only applies to LocalPtyProvider where PTYs live in the Electron main + // process and can become orphaned on page reload. Daemon-backed sessions + // survive renderer restarts by design — orphan cleanup would kill them. + if (localProvider instanceof LocalPtyProvider) { + const lp = localProvider + if (didFinishLoadHandler) { + mainWindow.webContents.removeListener('did-finish-load', didFinishLoadHandler) } + didFinishLoadHandler = () => { + const killed = lp.killOrphanedPtys(lp.advanceGeneration() - 1) + for (const { id } of killed) { + clearProviderPtyState(id) + ptyOwnership.delete(id) + runtime?.onPtyExit(id, -1) + } + } + mainWindow.webContents.on('did-finish-load', didFinishLoadHandler) } - mainWindow.webContents.on('did-finish-load', didFinishLoadHandler) // Why: the runtime controller must route through getProviderForPty() so that // CLI commands (terminal.send, terminal.stop) work for both local and remote PTYs. @@ -270,6 +300,7 @@ export function registerPtyHandlers( command?: string connectionId?: string | null worktreeId?: string + sessionId?: string } ) => { const provider = getProvider(args.connectionId) @@ -279,7 +310,8 @@ export function registerPtyHandlers( cwd: args.cwd, env: args.env, command: args.command, - worktreeId: args.worktreeId + worktreeId: args.worktreeId, + sessionId: args.sessionId }) ptyOwnership.set(result.id, args.connectionId ?? null) return result @@ -298,17 +330,63 @@ export function registerPtyHandlers( getProviderForPty(args.id).resize(args.id, args.cols, args.rows) }) + // Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold restore + // cache after the renderer has consumed the data. No-op for non-daemon providers. + ipcMain.on('pty:ackColdRestore', (_event, args: { id: string }) => { + const provider = getProviderForPty(args.id) + if ('ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') { + provider.ackColdRestore(args.id) + } + }) + + ipcMain.removeAllListeners('pty:signal') + ipcMain.on('pty:signal', (_event, args: { id: string; signal: string }) => { + getProviderForPty(args.id) + .sendSignal(args.id, args.signal) + .catch(() => {}) + }) + ipcMain.handle('pty:kill', async (_event, args: { id: string }) => { // Why: try/finally ensures ptyOwnership is cleaned up even if shutdown - // throws (e.g. SSH connection already gone). Without this, the stale - // entry routes future lookups to a dead provider. + // throws (e.g. SSH connection already gone or daemon session already + // reaped). Swallowing the error prevents noisy renderer-side rejections + // when killing orphaned sessions that the daemon has already discarded. try { await getProviderForPty(args.id).shutdown(args.id, true) + } catch { + /* session already dead — cleanup below handles the rest */ } finally { ptyOwnership.delete(args.id) } }) + ipcMain.handle( + 'pty:listSessions', + async (): Promise<{ id: string; cwd: string; title: string }[]> => { + const providerSessions = await Promise.all([ + Promise.resolve({ + connectionId: null as string | null, + sessions: await localProvider.listProcesses() + }), + ...Array.from(sshProviders.entries(), async ([connectionId, provider]) => ({ + connectionId, + sessions: await provider.listProcesses().catch(() => []) + })) + ]) + const deduped = new Map() + for (const { connectionId, sessions } of providerSessions) { + for (const session of sessions) { + // Why: SessionsStatusSegment kill actions only send the PTY id back + // through IPC. Rebuild ownership while listing so remote sessions + // discovered after reconnect still route to their original provider. + ptyOwnership.set(session.id, connectionId) + deduped.set(session.id, session) + } + } + return Array.from(deduped.values()) + } + ) + ipcMain.handle( 'pty:hasChildProcesses', async (_event, args: { id: string }): Promise => { @@ -328,5 +406,7 @@ export function registerPtyHandlers( * Kill all PTY processes. Call on app quit. */ export function killAllPty(): void { - localProvider.killAll() + if (localProvider instanceof LocalPtyProvider) { + localProvider.killAll() + } } diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index b902f7965..6166d84be 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -119,6 +119,47 @@ describe('LocalPtyProvider', () => { const spawnCall = spawnMock.mock.calls.at(-1)! expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value') }) + + it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const originalUserProfile = process.env.USERPROFILE + const originalHomeDrive = process.env.HOMEDRIVE + const originalHomePath = process.env.HOMEPATH + + Object.defineProperty(process, 'platform', { value: 'win32' }) + delete process.env.USERPROFILE + process.env.HOMEDRIVE = 'D:' + process.env.HOMEPATH = '\\Users\\orca' + + try { + await provider.spawn({ cols: 80, rows: 24 }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = originalUserProfile + } + if (originalHomeDrive === undefined) { + delete process.env.HOMEDRIVE + } else { + process.env.HOMEDRIVE = originalHomeDrive + } + if (originalHomePath === undefined) { + delete process.env.HOMEPATH + } else { + process.env.HOMEPATH = originalHomePath + } + } + + expect(spawnMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ cwd: 'D:\\Users\\orca' }) + ) + }) }) describe('write', () => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index b49258d25..7de4b68d6 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -43,6 +43,23 @@ type ExitCallback = (payload: { id: string; code: number }) => void const dataListeners = new Set() const exitListeners = new Set() +function getDefaultCwd(): string { + if (process.platform !== 'win32') { + return process.env.HOME || '/' + } + + // Why: USERPROFILE is not guaranteed in all Windows launch contexts. + // Falling back to bare HOMEPATH yields a drive-relative path, so combine + // HOMEDRIVE + HOMEPATH to keep spawned PTYs anchored to the intended home. + if (process.env.USERPROFILE) { + return process.env.USERPROFILE + } + if (process.env.HOMEDRIVE && process.env.HOMEPATH) { + return `${process.env.HOMEDRIVE}${process.env.HOMEPATH}` + } + return 'C:\\' +} + function disposePtyListeners(id: string): void { const disposables = ptyDisposables.get(id) if (disposables) { @@ -95,11 +112,7 @@ export class LocalPtyProvider implements IPtyProvider { async spawn(args: PtySpawnOptions): Promise { const id = String(++ptyCounter) - const defaultCwd = - process.platform === 'win32' - ? process.env.USERPROFILE || process.env.HOMEPATH || 'C:\\' - : process.env.HOME || '/' - + const defaultCwd = getDefaultCwd() const cwd = args.cwd || defaultCwd const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null @@ -112,7 +125,7 @@ export class LocalPtyProvider implements IPtyProvider { const escapedCwd = wslInfo.linuxPath.replace(/'/g, "'\\''") shellPath = 'wsl.exe' shellArgs = ['-d', wslInfo.distro, '--', 'bash', '-c', `cd '${escapedCwd}' && exec bash -l`] - effectiveCwd = process.env.USERPROFILE || process.env.HOMEPATH || 'C:\\' + effectiveCwd = getDefaultCwd() validationCwd = cwd } else if (process.platform === 'win32') { shellPath = process.env.COMSPEC || 'powershell.exe' @@ -317,13 +330,17 @@ export class LocalPtyProvider implements IPtyProvider { // Why: disposePtyListeners removes the onExit callback, so the natural // exit cleanup path from node-pty won't fire. Cleanup and notification // must happen unconditionally after the try/catch. + // Note: clearPtyState calls disposePtyListeners internally, so we only + // need to call it once via clearPtyState after killing the process. disposePtyListeners(id) try { proc.kill() } catch { /* Process may already be dead */ } - clearPtyState(id) + ptyProcesses.delete(id) + ptyShellName.delete(id) + ptyLoadGeneration.delete(id) this.opts.onExit?.(id, -1) for (const cb of exitListeners) { cb({ id, code: -1 }) diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 52a77de9e..388d23ba8 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -88,8 +88,7 @@ fi # ~/.bashrc from ~/.bash_profile; forcing ~/.bashrc again here would duplicate # PATH edits, hooks, and prompt init in Orca startup-command shells. # Why: append the marker through PROMPT_COMMAND so it fires after the login -# startup files have rebuilt the prompt, matching Superset's "shell ready" -# contract without re-running user rc files. +# startup files have rebuilt the prompt, without re-running user rc files. __orca_prompt_mark() { printf "\\033]133;A\\007" } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index ae1097090..a977cd398 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -21,10 +21,32 @@ export type PtySpawnOptions = { /** Orca worktree identity. When present, the local provider scopes shell * history to this worktree so ArrowUp only surfaces local commands. */ worktreeId?: string + /** Daemon session ID for reattach. When provided, the daemon reconnects + * to an existing session instead of creating a new one. */ + sessionId?: string } export type PtySpawnResult = { id: string + /** ANSI snapshot of the terminal screen, present when reattaching to an + * existing daemon session. Write this to xterm.js to restore visual state. */ + snapshot?: string + /** Dimensions the snapshot was captured at. Resize xterm.js to these before + * writing the snapshot so ANSI cursor positions land correctly. */ + snapshotCols?: number + snapshotRows?: number + /** True when the spawn reattached to an existing daemon session. */ + isReattach?: boolean + /** True when the reattached session uses the alternate screen buffer + * (e.g., Codex CLI, vim). Normal-screen TUIs like Claude Code are false. */ + isAlternateScreen?: boolean + /** Present when cold-restoring from disk history after a daemon crash. + * Contains the saved scrollback and CWD. The new shell spawns in the + * saved CWD; the scrollback is written to xterm.js as read-only history. */ + coldRestore?: { + scrollback: string + cwd: string + } } export type IPtyProvider = { diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 0a2c5d5f2..23a4f8bab 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -265,12 +265,24 @@ export type PreloadApi = { command?: string connectionId?: string | null worktreeId?: string - }) => Promise<{ id: string }> + sessionId?: string + }) => Promise<{ + id: string + snapshot?: string + snapshotCols?: number + snapshotRows?: number + isReattach?: boolean + isAlternateScreen?: boolean + coldRestore?: { scrollback: string; cwd: string } + }> write: (id: string, data: string) => void resize: (id: string, cols: number, rows: number) => void + signal: (id: string, signal: string) => void kill: (id: string) => Promise + ackColdRestore: (id: string) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise + listSessions: () => Promise<{ id: string; cwd: string; title: string }[]> onData: (callback: (data: { id: string; data: string }) => void) => () => void onExit: (callback: (data: { id: string; code: number }) => void) => () => void onOpenCodeStatus: (callback: (event: OpenCodeStatusEvent) => void) => () => void diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 9500574a7..b01d8cf05 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -61,11 +61,24 @@ type PtyApi = { command?: string connectionId?: string | null worktreeId?: string - }) => Promise<{ id: string }> + sessionId?: string + }) => Promise<{ + id: string + snapshot?: string + snapshotCols?: number + snapshotRows?: number + isReattach?: boolean + isAlternateScreen?: boolean + coldRestore?: { scrollback: string; cwd: string } + }> write: (id: string, data: string) => void resize: (id: string, cols: number, rows: number) => void + signal: (id: string, signal: string) => void kill: (id: string) => Promise + ackColdRestore: (id: string) => void hasChildProcesses: (id: string) => Promise + getForegroundProcess: (id: string) => Promise + listSessions: () => Promise<{ id: string; cwd: string; title: string }[]> onData: (callback: (data: { id: string; data: string }) => void) => () => void onExit: (callback: (data: { id: string; code: number }) => void) => () => void onOpenCodeStatus: (callback: (event: OpenCodeStatusEvent) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index d7dd86cdf..04219207c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -254,7 +254,16 @@ const api = { command?: string connectionId?: string | null worktreeId?: string - }): Promise<{ id: string }> => ipcRenderer.invoke('pty:spawn', opts), + sessionId?: string + }): Promise<{ + id: string + snapshot?: string + snapshotCols?: number + snapshotRows?: number + isReattach?: boolean + isAlternateScreen?: boolean + coldRestore?: { scrollback: string; cwd: string } + }> => ipcRenderer.invoke('pty:spawn', opts), write: (id: string, data: string): void => { ipcRenderer.send('pty:write', { id, data }) @@ -264,8 +273,19 @@ const api = { ipcRenderer.send('pty:resize', { id, cols, rows }) }, + signal: (id: string, signal: string): void => { + ipcRenderer.send('pty:signal', { id, signal }) + }, + + ackColdRestore: (id: string): void => { + ipcRenderer.send('pty:ackColdRestore', { id }) + }, + kill: (id: string): Promise => ipcRenderer.invoke('pty:kill', { id }), + listSessions: (): Promise<{ id: string; cwd: string; title: string }[]> => + ipcRenderer.invoke('pty:listSessions'), + /** Check if a PTY's shell has child processes (e.g. a running command). * Returns false for an idle shell prompt. */ hasChildProcesses: (id: string): Promise => diff --git a/src/renderer/src/components/status-bar/SessionsStatusSegment.tsx b/src/renderer/src/components/status-bar/SessionsStatusSegment.tsx new file mode 100644 index 000000000..87d2702c4 --- /dev/null +++ b/src/renderer/src/components/status-bar/SessionsStatusSegment.tsx @@ -0,0 +1,251 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { Terminal, Trash2 } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { useAppStore } from '../../store' + +type DaemonSession = { id: string; cwd: string; title: string } + +function shortCwd(cwd: string): string { + if (!cwd) { + return 'unknown' + } + const separator = cwd.includes('\\') ? '\\' : '/' + const parts = cwd.split(/[\\/]+/).filter(Boolean) + return parts.length > 2 ? parts.slice(-2).join(separator) : cwd +} + +function sessionLabel(session: DaemonSession): string { + if (session.cwd) { + return shortCwd(session.cwd) + } + // Why: daemon session IDs use the format `${worktreeId}@@${shortUuid}`. + // When the shell hasn't emitted OSC 7 cwd updates (e.g. Claude Code agents), + // fall back to showing the worktreeId portion so the user can identify which + // worktree the session belongs to. + const sep = session.id.lastIndexOf('@@') + if (sep !== -1) { + const worktreeId = session.id.slice(0, sep) + return shortCwd(worktreeId) + } + return 'unknown' +} + +function SessionRow({ + session, + isBound, + tabId, + onKill, + onNavigate +}: { + session: DaemonSession + isBound: boolean + tabId: string | null + onKill: (id: string) => void + onNavigate: (tabId: string) => void +}): React.JSX.Element { + return ( +
onNavigate(tabId) : undefined} + > + +
+
{sessionLabel(session)}
+
+ {!isBound && ( + + )} +
+ ) +} + +export function SessionsStatusSegment({ + compact: _compact, + iconOnly +}: { + compact: boolean + iconOnly: boolean +}): React.JSX.Element { + const [sessions, setSessions] = useState([]) + const [open, setOpen] = useState(false) + const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) + const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) + const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) + const setActiveTab = useAppStore((s) => s.setActiveTab) + const setActiveView = useAppStore((s) => s.setActiveView) + + const boundPtyIds = useMemo( + () => new Set(Object.values(ptyIdsByTabId).flat().filter(Boolean)), + [ptyIdsByTabId] + ) + + // Why: ptyIdsByTabId tracks all ptyIds a tab has ever been associated with + // (including split panes). Build a reverse map so we can navigate from a + // daemon session ID back to the tab that owns it. + const ptyIdToTabId = useMemo(() => { + const map = new Map() + for (const [tabId, ptyIds] of Object.entries(ptyIdsByTabId)) { + for (const ptyId of ptyIds) { + map.set(ptyId, tabId) + } + } + return map + }, [ptyIdsByTabId]) + + const tabIdToWorktreeId = useMemo(() => { + const map = new Map() + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + for (const tab of tabs) { + map.set(tab.id, worktreeId) + } + } + return map + }, [tabsByWorktree]) + + const refresh = useCallback(async () => { + try { + const result = await window.api.pty.listSessions() + setSessions(result) + } catch { + setSessions([]) + } + }, []) + + useEffect(() => { + if (open) { + void refresh() + } + }, [open, refresh]) + + useEffect(() => { + const interval = setInterval(() => void refresh(), 10_000) + void refresh() + return () => clearInterval(interval) + }, [refresh]) + + const orphanCount = workspaceSessionReady + ? sessions.filter((s) => !boundPtyIds.has(s.id)).length + : 0 + + const handleKill = useCallback( + async (id: string) => { + try { + await window.api.pty.kill(id) + } catch { + /* already dead */ + } + await refresh() + }, + [refresh] + ) + + const handleKillOrphans = useCallback(async () => { + if (!workspaceSessionReady) { + return + } + const orphans = sessions.filter((s) => !boundPtyIds.has(s.id)) + await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id))) + await refresh() + }, [sessions, boundPtyIds, refresh, workspaceSessionReady]) + + const handleNavigate = useCallback( + (tabId: string) => { + const worktreeId = tabIdToWorktreeId.get(tabId) + if (worktreeId) { + setActiveWorktree(worktreeId) + } + setActiveView('terminal') + setActiveTab(tabId) + }, + [tabIdToWorktreeId, setActiveWorktree, setActiveView, setActiveTab] + ) + + return ( + + + + + +
+ Terminal Sessions ({sessions.length}) +
+ {sessions.length === 0 ? ( +
+ No active sessions +
+ ) : ( +
+ {[...sessions] + .sort((a, b) => { + const aBound = workspaceSessionReady && boundPtyIds.has(a.id) ? 0 : 1 + const bBound = workspaceSessionReady && boundPtyIds.has(b.id) ? 0 : 1 + return aBound - bBound + }) + .map((s) => { + const tabId = ptyIdToTabId.get(s.id) ?? null + return ( + + ) + })} +
+ )} + {orphanCount > 0 && ( + <> + +
+ +
+ + )} +
+
+ ) +} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index c6e3f01e7..992385304 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -24,6 +24,7 @@ import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rat import { ProviderIcon, ProviderPanel } from './tooltip' import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' import { SshStatusSegment } from './SshStatusSegment' +import { SessionsStatusSegment } from './SessionsStatusSegment' function getCodexAccountLabel( state: CodexRateLimitAccountsState, @@ -453,6 +454,7 @@ function StatusBarInner(): React.JSX.Element | null { const showClaude = claude && statusBarItems.includes('claude') const showCodex = codex && statusBarItems.includes('codex') const showSsh = statusBarItems.includes('ssh') + const showSessions = statusBarItems.includes('sessions') const anyVisible = showClaude || showCodex const anyFetching = claude?.status === 'fetching' || codex?.status === 'fetching' @@ -500,7 +502,10 @@ function StatusBarInner(): React.JSX.Element | null {
- {showSsh && } +
+ {showSessions && } + {showSsh && } +
@@ -523,6 +528,12 @@ function StatusBarInner(): React.JSX.Element | null { > SSH Status + toggleStatusBarItem('sessions')} + > + Terminal Sessions + ) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 1116339fd..71ab0c263 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -6,6 +6,8 @@ type StoreState = { repos: { id: string; connectionId?: string | null }[] cacheTimerByKey: Record settings: { promptCacheTimerEnabled?: boolean } | null + consumePendingColdRestore: ReturnType + consumePendingSnapshot: ReturnType } type ConnectCallbacks = { @@ -70,9 +72,13 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport attach: vi.fn(({ existingPtyId }: { existingPtyId: string }) => { ptyId = existingPtyId }), - connect: vi.fn( - async (_opts: { callbacks?: ConnectCallbacks } & Record) => ptyId - ), + connect: vi.fn().mockImplementation(async (opts: { sessionId?: string }) => { + if (opts.sessionId) { + ptyId = opts.sessionId + return { id: opts.sessionId } + } + return ptyId + }), sendInput: vi.fn(() => true), resize: vi.fn(() => true), getPtyId: vi.fn(() => ptyId) @@ -149,8 +155,10 @@ describe('connectPanePty', () => { }, repos: [{ id: 'repo1', connectionId: null }], cacheTimerByKey: {}, - settings: { promptCacheTimerEnabled: true } - } + settings: { promptCacheTimerEnabled: true }, + consumePendingColdRestore: vi.fn(() => null), + consumePendingSnapshot: vi.fn(() => null) + } as StoreState globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { callback(0) return 1 @@ -277,10 +285,14 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) - expect(transport.attach).toHaveBeenCalledWith( - expect.objectContaining({ existingPtyId: 'leaf-pty-2' }) + // Why: Option 2 deferred reattach uses connect({ sessionId }) instead of + // attach({ existingPtyId }) so the daemon's createOrAttach runs at the + // pane's real fitAddon dimensions. + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'leaf-pty-2' }) ) - expect(transport.connect).not.toHaveBeenCalled() + expect(transport.attach).not.toHaveBeenCalled() + await Promise.resolve() expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'leaf-pty-2') }) @@ -327,9 +339,11 @@ describe('connectPanePty', () => { connectPanePty(remountPane as never, remountManager as never, remountDeps as never) - expect(remountTransport.attach).toHaveBeenCalledWith( - expect.objectContaining({ existingPtyId: 'pty-restarted' }) + expect(remountTransport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'pty-restarted' }) ) + expect(remountTransport.attach).not.toHaveBeenCalled() + await Promise.resolve() expect(remountDeps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'pty-restarted') }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 7d3731c7f..e1f439d90 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1,8 +1,10 @@ +/* oxlint-disable max-lines */ import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' import type { IDisposable } from '@xterm/xterm' import { isGeminiTerminalTitle, isClaudeAgent } from '@/lib/agent-status' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' +import type { PtyConnectResult } from './pty-transport' import { createIpcPtyTransport } from './pty-transport' import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' import type { PtyConnectionDeps } from './pty-connection-types' @@ -275,10 +277,9 @@ export function connectPanePty( } } - // Why: re-read ptyId inside the rAF instead of capturing it before. - // The eagerly-spawned PTY could exit during the one-frame gap (e.g., - // broken .bashrc), clearing the tab's ptyId. Reading it stale would - // cause attach() on a dead process, leaving the pane frozen. + // Why: re-read session IDs inside the rAF instead of capturing before. + // The session could be cleaned up during the one-frame gap, and + // reading stale IDs would cause a reattach to a dead session. const restoredPtyId = deps.restoredLeafId && deps.restoredPtyIdByLeafId ? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null) @@ -287,42 +288,72 @@ export function connectPanePty( .getState() .tabsByWorktree[deps.worktreeId]?.find((t) => t.id === deps.tabId)?.ptyId - // Why: remounting a multi-pane terminal tab (for example after closing or - // moving a split group) must preserve each pane's own live PTY. The saved - // leaf→PTY mapping takes precedence over the tab-level PTY owner. - if (restoredPtyId) { + // Why: deferred reattach (Option 2). Instead of eagerly spawning PTYs at + // default 80×24 during reconnectPersistedTerminals (which fills eager + // buffers with content at wrong dimensions), we defer the daemon's + // createOrAttach to this point where fitAddon provides real dimensions. + // The daemon returns snapshot/coldRestore data in the spawn result. + const reattachSessionId = + restoredPtyId ?? (existingPtyId && !hasExistingPaneTransport ? existingPtyId : null) + if (reattachSessionId) { allowInitialIdleCacheSeed = true - deps.syncPanePtyLayoutBinding(pane.id, restoredPtyId) - transport.attach({ - existingPtyId: restoredPtyId, - cols, - rows, - callbacks: { - onData: dataCallback, - onError: reportError - } - }) - } else if (existingPtyId && !hasExistingPaneTransport) { - // Why: only the first pane in a tab may reattach to the tab-level PTY. - // Additional panes created by in-tab splits need their own fresh PTYs; if - // they attach to the tab's existing ptyId, both panes end up sharing one - // session and the last-attached pane steals the live transport handlers. - // Group moves/remounts still reattach correctly because they recreate the - // whole TerminalPane with no surviving pane transports yet. - allowInitialIdleCacheSeed = true - deps.syncPanePtyLayoutBinding(pane.id, existingPtyId) - // Why: this tab already owns a PTY. Attach to it instead of spawning a - // duplicate. Startup commands are intentionally skipped — the PTY was - // already spawned with a fresh shell. - transport.attach({ - existingPtyId, + + const reattachPromise = transport.connect({ + url: '', cols, rows, + sessionId: reattachSessionId, callbacks: { onData: dataCallback, onError: reportError } }) + + void Promise.resolve(reattachPromise) + .then((result) => { + if (disposed) { + return + } + const connectResult = + result && typeof result === 'object' && 'id' in result + ? (result as PtyConnectResult) + : null + + const ptyId = + connectResult?.id ?? (typeof result === 'string' ? result : transport.getPtyId()) + if (ptyId) { + deps.syncPanePtyLayoutBinding(pane.id, ptyId) + deps.updateTabPtyId(deps.tabId, ptyId) + } + + if (connectResult?.coldRestore) { + pane.terminal.write(connectResult.coldRestore.scrollback) + pane.terminal.write('\r\n\x1b[2m--- session restored ---\x1b[0m\r\n\r\n') + window.api.pty.ackColdRestore(ptyId!) + } else if (connectResult?.snapshot) { + if (!connectResult.isAlternateScreen) { + pane.terminal.write('\x1b[2J\x1b[3J\x1b[H') + } + pane.terminal.write(connectResult.snapshot) + } + + if (ptyId) { + transport.resize(cols, rows) + // Why: POSIX only delivers SIGWINCH when terminal dimensions + // actually change. If the pane dimensions match the daemon + // session's stored dimensions (common for split panes across + // restarts), the resize above is a no-op and inline-viewport + // TUIs (Claude Code/Ink) never redraw. Sending SIGWINCH + // explicitly guarantees the TUI repaints at the correct cursor + // position, correcting any snapshot-vs-PTY cursor divergence. + window.api.pty.signal(ptyId, 'SIGWINCH') + } + + scheduleRuntimeGraphSync() + }) + .catch((err) => { + reportError(err instanceof Error ? err.message : String(err)) + }) } else { allowInitialIdleCacheSeed = false const pendingSpawn = hasExistingPaneTransport diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 8dc45ef28..4e1210bf6 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -129,11 +129,23 @@ export function registerEagerPtyBuffer( // ── PtyTransport interface ─────────────────────────────────────────── // Why: lives here so pty-transport.ts stays under the 300-line limit. +export type PtyConnectResult = { + id: string + snapshot?: string + snapshotCols?: number + snapshotRows?: number + isAlternateScreen?: boolean + coldRestore?: { scrollback: string; cwd: string } +} + export type PtyTransport = { connect: (options: { url: string cols?: number rows?: number + /** Daemon session ID for reattach. When provided, the daemon reconnects + * to an existing session instead of creating a new one. */ + sessionId?: string callbacks: { onConnect?: () => void onDisconnect?: () => void @@ -142,13 +154,17 @@ export type PtyTransport = { onError?: (message: string, errors?: string[]) => void onExit?: (code: number) => void } - }) => void | Promise + }) => void | Promise /** Attach to an existing PTY that was eagerly spawned during startup. * Skips pty:spawn — registers handlers and replays buffered data instead. */ attach: (options: { existingPtyId: string cols?: number rows?: number + /** When true, the session uses the alternate screen buffer (e.g., Codex). + * Skips the delayed double-resize since a single resize already triggers + * a full TUI repaint without content loss. */ + isAlternateScreen?: boolean callbacks: { onConnect?: () => void onDisconnect?: () => void diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index ad441e7a8..67737b9bf 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -1,3 +1,4 @@ +/* oxlint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('createIpcPtyTransport', () => { @@ -178,6 +179,64 @@ describe('createIpcPtyTransport', () => { expect(writeMock).not.toHaveBeenCalled() }) + it('preserves snapshot dimensions when reattaching', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawnMock = vi.fn().mockResolvedValue({ + id: 'pty-reattach', + isReattach: true, + snapshot: 'snapshot data', + snapshotCols: 132, + snapshotRows: 43 + }) + + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + spawn: spawnMock, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => { + onData = callback + return () => {} + }), + onExit: vi.fn((callback: (payload: { id: string; code: number }) => void) => { + onExit = callback + return () => {} + }), + onOpenCodeStatus: vi.fn( + ( + callback: (payload: { + ptyId: string + status: 'working' | 'idle' | 'permission' + }) => void + ) => { + onOpenCodeStatus = callback + return () => {} + } + ) + } + } + } as unknown as typeof window + + const transport = createIpcPtyTransport() + const result = await transport.connect({ + url: '', + sessionId: 'pty-reattach', + callbacks: {} + }) + + expect(result).toEqual({ + id: 'pty-reattach', + snapshot: 'snapshot data', + isAlternateScreen: undefined, + coldRestore: undefined + }) + }) + it('kills a PTY that finishes spawning after the transport was destroyed', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawnControls: { resolve: ((value: { id: string }) => void) | null } = { resolve: null } diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 5a7117dd3..27d45d31d 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -1,3 +1,4 @@ +/* oxlint-disable max-lines */ import { detectAgentStatusFromTitle, clearWorkingIndicators, @@ -14,7 +15,7 @@ import { ensurePtyDispatcher, getEagerPtyBufferHandle } from './pty-dispatcher' -import type { PtyTransport, IpcPtyTransportOptions } from './pty-dispatcher' +import type { PtyTransport, IpcPtyTransportOptions, PtyConnectResult } from './pty-dispatcher' import { createBellDetector } from './bell-detector' // Re-export public API so existing consumers keep working. @@ -24,7 +25,12 @@ export { registerEagerPtyBuffer, unregisterPtyDataHandlers } from './pty-dispatcher' -export type { EagerPtyHandle, PtyTransport, IpcPtyTransportOptions } from './pty-dispatcher' +export type { + EagerPtyHandle, + PtyTransport, + PtyConnectResult, + IpcPtyTransportOptions +} from './pty-dispatcher' export { extractLastOscTitle } from '../../../../shared/agent-detection' export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTransport { @@ -201,6 +207,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra env, command, ...(connectionId ? { connectionId } : {}), + ...(options.sessionId ? { sessionId: options.sessionId } : {}), worktreeId }) @@ -212,13 +219,28 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ptyId = result.id connected = true - onPtySpawn?.(result.id) + + // Why: for deferred reattach (Option 2), the daemon returns snapshot/ + // coldRestore data from createOrAttach. Skip onPtySpawn for reattach — + // it would reset lastActivityAt and destroy the recency sort order. + if (!result.isReattach && !result.coldRestore) { + onPtySpawn?.(result.id) + } registerPtyDataHandler(result.id) registerPtyExitHandler(result.id) storedCallbacks.onConnect?.() storedCallbacks.onStatus?.('shell') + + if (result.isReattach || result.coldRestore) { + return { + id: result.id, + snapshot: result.snapshot, + isAlternateScreen: result.isAlternateScreen, + coldRestore: result.coldRestore + } satisfies PtyConnectResult + } return result.id } catch (err) { const msg = err instanceof Error ? err.message : String(err) @@ -252,8 +274,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra registerPtyDataHandler(id) registerPtyExitHandler(id) - // Why: replay buffered data through the real handler so title/bell/agent - // tracking processes the output — otherwise restored tabs keep a default title. const bufferHandle = getEagerPtyBufferHandle(id) if (bufferHandle) { const buffered = bufferHandle.flush() @@ -273,7 +293,14 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra bufferHandle.dispose() } - // Resize to the actual terminal dimensions (eager spawn used defaults). + // Why: clear the display before writing the snapshot so restored + // content doesn't layer on top of stale output. Skip the clear for + // alternate-screen sessions — the snapshot already fills the screen + // and clearing would erase it. + if (!options.isAlternateScreen) { + storedCallbacks.onData?.('\x1b[2J\x1b[3J\x1b[H') + } + if (options.cols && options.rows) { window.api.pty.resize(id, options.cols, options.rows) } diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index 267878a07..f8cd2bd90 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -665,7 +665,7 @@ describe('reconnectPersistedTerminals', () => { }) }) - it('spawns PTYs for worktrees that were active at shutdown and sets workspaceSessionReady', async () => { + it('records daemon session IDs for deferred reattach and sets workspaceSessionReady', async () => { const store = createTestStore() const wt1 = 'repo1::/path/wt1' const wt2 = 'repo1::/path/wt2' @@ -682,7 +682,6 @@ describe('reconnectPersistedTerminals', () => { } }) - // Hydrate with activeWorktreeIdsOnShutdown indicating both worktrees had terminals store.getState().hydrateWorkspaceSession({ activeRepoId: 'repo1', activeWorktreeId: wt1, @@ -695,36 +694,61 @@ describe('reconnectPersistedTerminals', () => { activeWorktreeIdsOnShutdown: [wt1, wt2] }) - // After hydration, workspaceSessionReady is false expect(store.getState().workspaceSessionReady).toBe(false) - // ptyIds are cleared by clearTransientTerminalState expect(store.getState().tabsByWorktree[wt1][0].ptyId).toBeNull() expect(store.getState().tabsByWorktree[wt2][0].ptyId).toBeNull() - // pendingReconnectWorktreeIds is populated expect(store.getState().pendingReconnectWorktreeIds).toEqual([wt1, wt2]) - // Run reconnect await store.getState().reconnectPersistedTerminals() const s = store.getState() - // workspaceSessionReady is now true expect(s.workspaceSessionReady).toBe(true) - // Tabs now have live ptyIds - expect(s.tabsByWorktree[wt1][0].ptyId).toBe('pty-1') - expect(s.tabsByWorktree[wt2][0].ptyId).toBe('pty-2') - // ptyIdsByTabId is populated - expect(s.ptyIdsByTabId['tab1']).toContain('pty-1') - expect(s.ptyIdsByTabId['tab2']).toContain('pty-2') - // pendingReconnectWorktreeIds is cleared + // Why: Option 2 defers actual pty.spawn to connectPanePty. The store + // records daemon session IDs as tab-level ptyIds so connectPanePty + // can pass them as sessionId to the daemon's createOrAttach. + expect(s.tabsByWorktree[wt1][0].ptyId).toBe('old-pty-1') + expect(s.tabsByWorktree[wt2][0].ptyId).toBe('old-pty-2') expect(s.pendingReconnectWorktreeIds).toEqual([]) - // Spawn was called with correct cwd - expect((mockApi.pty as Record).spawn).toHaveBeenCalledTimes(2) - expect((mockApi.pty as Record).spawn).toHaveBeenCalledWith( - expect.objectContaining({ cwd: '/path/wt1' }) - ) - expect((mockApi.pty as Record).spawn).toHaveBeenCalledWith( - expect.objectContaining({ cwd: '/path/wt2' }) - ) + // No eager spawn — PTY creation deferred to pane mount + expect((mockApi.pty as Record).spawn).not.toHaveBeenCalled() + }) + + it('does not restore old pty ids onto remote tabs during reconnect preparation', async () => { + const store = createTestStore() + const wt1 = 'repo1::/remote/wt1' + + store.setState({ + repos: [ + { + id: 'repo1', + path: '/repo1', + displayName: 'Repo 1', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: wt1, repoId: 'repo1', path: '/remote/wt1' })] + } + }) + + store.getState().hydrateWorkspaceSession({ + activeRepoId: 'repo1', + activeWorktreeId: wt1, + activeTabId: 'tab1', + tabsByWorktree: { + [wt1]: [makeTab({ id: 'tab1', worktreeId: wt1, ptyId: 'old-remote-pty' })] + }, + terminalLayoutsByTabId: { tab1: makeLayout() }, + activeWorktreeIdsOnShutdown: [wt1] + }) + + await store.getState().reconnectPersistedTerminals() + + const s = store.getState() + expect(s.tabsByWorktree[wt1][0].ptyId).toBeNull() + expect(s.ptyIdsByTabId.tab1).toEqual([]) }) it('sets workspaceSessionReady even with no pending worktrees', async () => { @@ -776,11 +800,11 @@ describe('reconnectPersistedTerminals', () => { // No activeWorktreeIdsOnShutdown field }) - // Should still detect wt1 as needing reconnection from raw ptyIds expect(store.getState().pendingReconnectWorktreeIds).toEqual([wt1]) await store.getState().reconnectPersistedTerminals() - expect(store.getState().tabsByWorktree[wt1][0].ptyId).toBe('pty-1') + // Why: deferred reattach records the old daemon session ID on the tab + expect(store.getState().tabsByWorktree[wt1][0].ptyId).toBe('old-pty') }) it('reconnects the correct tab per worktree (not always tabs[0])', async () => { @@ -813,9 +837,9 @@ describe('reconnectPersistedTerminals', () => { await store.getState().reconnectPersistedTerminals() - // tab2 should get the PTY, not tab1 - expect(store.getState().tabsByWorktree[wt1][0].ptyId).toBeNull() // tab1 - expect(store.getState().tabsByWorktree[wt1][1].ptyId).toBe('pty-1') // tab2 + // tab2 should get its daemon session ID, not tab1 + expect(store.getState().tabsByWorktree[wt1][0].ptyId).toBeNull() // tab1 had no ptyId + expect(store.getState().tabsByWorktree[wt1][1].ptyId).toBe('old-pty-2') // tab2 }) it('reconnects multiple live tabs in the same worktree', async () => { @@ -848,9 +872,9 @@ describe('reconnectPersistedTerminals', () => { await store.getState().reconnectPersistedTerminals() - // Both tabs should have new PTYs - expect(store.getState().tabsByWorktree[wt1][0].ptyId).toBe('pty-1') - expect(store.getState().tabsByWorktree[wt1][1].ptyId).toBe('pty-2') + // Both tabs should have their daemon session IDs recorded + expect(store.getState().tabsByWorktree[wt1][0].ptyId).toBe('old-pty-1') + expect(store.getState().tabsByWorktree[wt1][1].ptyId).toBe('old-pty-2') }) it('does not bump lastActivityAt for reconnected worktrees', async () => { @@ -912,7 +936,64 @@ describe('reconnectPersistedTerminals', () => { expect(store.getState().pendingReconnectWorktreeIds).toEqual([existing]) await store.getState().reconnectPersistedTerminals() - expect((mockApi.pty as Record).spawn).toHaveBeenCalledTimes(1) + // Why: deferred reattach doesn't call spawn — just records session IDs + expect((mockApi.pty as Record).spawn).not.toHaveBeenCalled() + // The existing worktree's tab should have its daemon session ID + expect(store.getState().tabsByWorktree[existing][0].ptyId).toBe('old') + }) + + it('preserves split-pane ptyIdsByLeafId for deferred reattach by connectPanePty', async () => { + const store = createTestStore() + const wt1 = 'repo1::/path/wt1' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: wt1, repoId: 'repo1', path: '/path/wt1' })] + } + }) + + // Why: split-pane tab has two leaves, each with its own daemon session. + store.getState().hydrateWorkspaceSession({ + activeRepoId: 'repo1', + activeWorktreeId: wt1, + activeTabId: 'tab1', + tabsByWorktree: { + [wt1]: [makeTab({ id: 'tab1', worktreeId: wt1, ptyId: 'daemon-session-B' })] + }, + terminalLayoutsByTabId: { + tab1: { + ...makeLayout(), + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:3' } + }, + ptyIdsByLeafId: { 'pane:1': 'daemon-session-A', 'pane:3': 'daemon-session-B' } + } + }, + activeWorktreeIdsOnShutdown: [wt1] + }) + + await store.getState().reconnectPersistedTerminals() + + const s = store.getState() + // Why: deferred reattach doesn't call spawn — connectPanePty handles it + expect((mockApi.pty as Record).spawn).not.toHaveBeenCalled() + // Why: reconnect restores the tab-level ptyId so getWorktreeStatus() + // sees the tab as active (green dot) even before the terminal mounts. + // connectPanePty reads ptyIdsByLeafId for per-leaf daemon sessions. + expect(s.tabsByWorktree[wt1][0].ptyId).toBe('daemon-session-B') + // ptyIdsByLeafId preserved from hydration for connectPanePty to consume + const layout = s.terminalLayoutsByTabId['tab1'] + expect(layout.ptyIdsByLeafId).toEqual({ + 'pane:1': 'daemon-session-A', + 'pane:3': 'daemon-session-B' + }) + expect(s.workspaceSessionReady).toBe(true) }) }) diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index d3043fdcb..81d4bee64 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -86,7 +86,7 @@ describe('hydrateWorkspaceSession', () => { vi.clearAllMocks() }) - it('drops persisted ptyIdsByLeafId because restart must reconnect fresh PTYs', () => { + it('preserves ptyIdsByLeafId so reconnect can reattach each split-pane leaf', () => { const store = createTestStore() const worktreeId = 'repo1::/wt-1' seedStore(store, { @@ -105,7 +105,7 @@ describe('hydrateWorkspaceSession', () => { terminalLayoutsByTabId: { 'tab-1': { ...makeLayout(), - ptyIdsByLeafId: { 'pane:1': 'stale-leaf-pty' }, + ptyIdsByLeafId: { 'pane:1': 'daemon-session-1' }, buffersByLeafId: { 'pane:1': 'buffer' } } } @@ -113,8 +113,12 @@ describe('hydrateWorkspaceSession', () => { store.getState().hydrateWorkspaceSession(session) + // Why: ptyIdsByLeafId contains daemon session IDs that survive restart. + // reconnectPersistedTerminals uses them to reattach each split-pane + // leaf to its specific daemon session. expect(store.getState().terminalLayoutsByTabId['tab-1']).toEqual({ ...makeLayout(), + ptyIdsByLeafId: { 'pane:1': 'daemon-session-1' }, buffersByLeafId: { 'pane:1': 'buffer' } }) }) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 8bf57b9db..cd0efdf8b 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -12,7 +12,6 @@ import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-hel import { isClaudeAgent, detectAgentStatusFromTitle } from '@/lib/agent-status' import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers' import { - registerEagerPtyBuffer, ensurePtyDispatcher, unregisterPtyDataHandlers } from '@/components/terminal-pane/pty-transport' @@ -79,6 +78,24 @@ export type TerminalSlice = { workspaceSessionReady: boolean pendingReconnectWorktreeIds: string[] pendingReconnectTabByWorktree: Record + /** Maps tabId → previous ptyId from the last session. When the PTY backend is + * a daemon, the old ptyId doubles as the daemon sessionId — passing it to + * spawn triggers createOrAttach which returns the surviving terminal snapshot. */ + pendingReconnectPtyIdByTabId: Record + /** ANSI snapshots returned by daemon reattach, keyed by the new ptyId. + * TerminalPane writes these to xterm.js to restore visual state. */ + pendingSnapshotByPtyId: Record< + string, + { snapshot: string; cols?: number; rows?: number; isAlternateScreen?: boolean } + > + consumePendingSnapshot: ( + ptyId: string + ) => { snapshot: string; cols?: number; rows?: number; isAlternateScreen?: boolean } | null + /** Cold restore data from disk history after a daemon crash, keyed by + * the new ptyId. Contains read-only scrollback to display above the + * fresh shell prompt. */ + pendingColdRestoreByPtyId: Record + consumePendingColdRestore: (ptyId: string) => { scrollback: string; cwd: string } | null createTab: (worktreeId: string, targetGroupId?: string) => TerminalTab closeTab: (tabId: string) => void reorderTabs: (worktreeId: string, tabIds: string[]) => void @@ -155,6 +172,9 @@ export const createTerminalSlice: StateCreator workspaceSessionReady: false, pendingReconnectWorktreeIds: [], pendingReconnectTabByWorktree: {}, + pendingReconnectPtyIdByTabId: {}, + pendingSnapshotByPtyId: {}, + pendingColdRestoreByPtyId: {}, cacheTimerByKey: {}, setCacheTimerStartedAt: (key, ts) => { @@ -283,8 +303,12 @@ export const createTerminalSlice: StateCreator closeTab: (tabId) => { set((s) => { const next = { ...s.tabsByWorktree } + let closingPtyId: string | null = null for (const wId of Object.keys(next)) { const before = next[wId] + if (!closingPtyId) { + closingPtyId = before.find((t) => t.id === tabId)?.ptyId ?? null + } const after = before.filter((t) => t.id !== tabId) if (after.length !== before.length) { next[wId] = after @@ -336,6 +360,22 @@ export const createTerminalSlice: StateCreator } } + // Why: if the tab had a ptyId with unconsumed snapshot or cold restore + // data (e.g., tab closed before TerminalPane mounted), clean it up to + // prevent unbounded store growth across restarts. + let nextSnapshots = s.pendingSnapshotByPtyId + let nextColdRestores = s.pendingColdRestoreByPtyId + if (closingPtyId) { + if (closingPtyId in nextSnapshots) { + nextSnapshots = { ...nextSnapshots } + delete nextSnapshots[closingPtyId] + } + if (closingPtyId in nextColdRestores) { + nextColdRestores = { ...nextColdRestores } + delete nextColdRestores[closingPtyId] + } + } + return { tabsByWorktree: next, activeTabId: s.activeTabId === tabId ? null : s.activeTabId, @@ -349,7 +389,9 @@ export const createTerminalSlice: StateCreator pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, cacheTimerByKey: nextCacheTimer, - tabBarOrderByWorktree: nextTabBarOrderByWorktree + tabBarOrderByWorktree: nextTabBarOrderByWorktree, + pendingSnapshotByPtyId: nextSnapshots, + pendingColdRestoreByPtyId: nextColdRestores } }) for (const tabs of Object.values(get().unifiedTabsByWorktree)) { @@ -931,6 +973,32 @@ export const createTerminalSlice: StateCreator return pending }, + consumePendingSnapshot: (ptyId) => { + const snapshot = get().pendingSnapshotByPtyId[ptyId] + if (!snapshot) { + return null + } + set((s) => { + const next = { ...s.pendingSnapshotByPtyId } + delete next[ptyId] + return { pendingSnapshotByPtyId: next } + }) + return snapshot + }, + + consumePendingColdRestore: (ptyId) => { + const data = get().pendingColdRestoreByPtyId[ptyId] + if (!data) { + return null + } + set((s) => { + const next = { ...s.pendingColdRestoreByPtyId } + delete next[ptyId] + return { pendingColdRestoreByPtyId: next } + }) + return data + }, + hydrateWorkspaceSession: (session) => { set((s) => { const validWorktreeIds = new Set( @@ -997,6 +1065,26 @@ export const createTerminalSlice: StateCreator } } + // Why: preserve the previous session's ptyId for each tab so that + // reconnectPersistedTerminals can pass it as sessionId to the daemon's + // createOrAttach RPC, triggering reattach instead of a fresh spawn. + const pendingReconnectPtyIdByTabId: Record = {} + for (const worktreeId of pendingReconnectWorktreeIds) { + const worktree = Object.values(s.worktreesByRepo) + .flat() + .find((entry) => entry.id === worktreeId) + const repo = worktree ? s.repos.find((entry) => entry.id === worktree.repoId) : null + if (repo?.connectionId) { + continue + } + const rawTabs = session.tabsByWorktree[worktreeId] ?? [] + for (const tab of rawTabs) { + if (tab.ptyId && validTabIds.has(tab.id)) { + pendingReconnectPtyIdByTabId[tab.id] = tab.ptyId + } + } + } + // Why: restore per-worktree active terminal tab from session. // If the session has the map, validate that each tab ID still exists. // Otherwise, derive it: the active worktree gets activeTabId, others @@ -1028,31 +1116,29 @@ export const createTerminalSlice: StateCreator tabsByWorktree, pendingReconnectWorktreeIds, pendingReconnectTabByWorktree, + pendingReconnectPtyIdByTabId, ptyIdsByTabId: Object.fromEntries( Object.values(tabsByWorktree) .flat() .map((tab) => [tab.id, []] as const) ), - // Why: leaf→PTY mappings only make sense within the current renderer - // process. App restart reconnects fresh PTYs and must not attempt to - // reattach dead process IDs from the last session snapshot. + // Why: with the daemon backend, ptyIds are daemon session IDs that + // survive app restart. Preserve ptyIdsByLeafId so that + // reconnectPersistedTerminals can reattach each split-pane leaf + // to its specific daemon session (not just the tab-level ptyId). terminalLayoutsByTabId: Object.fromEntries( - Object.entries(session.terminalLayoutsByTabId) - .filter(([tabId]) => validTabIds.has(tabId)) - .map(([tabId, layout]) => { - const { ptyIdsByLeafId: _ptyIdsByLeafId, ...restartSafeLayout } = layout - return [tabId, restartSafeLayout] as const - }) + Object.entries(session.terminalLayoutsByTabId).filter(([tabId]) => validTabIds.has(tabId)) ) } }) }, - reconnectPersistedTerminals: async (signal) => { + reconnectPersistedTerminals: async (_signal) => { const { pendingReconnectWorktreeIds, pendingReconnectTabByWorktree, - worktreesByRepo, + pendingReconnectPtyIdByTabId, + terminalLayoutsByTabId, tabsByWorktree } = get() const ids = pendingReconnectWorktreeIds ?? [] @@ -1061,111 +1147,81 @@ export const createTerminalSlice: StateCreator set({ workspaceSessionReady: true, pendingReconnectWorktreeIds: [], - pendingReconnectTabByWorktree: {} + pendingReconnectTabByWorktree: {}, + pendingReconnectPtyIdByTabId: {} }) return } - const allWorktrees = Object.values(worktreesByRepo).flat() - const worktreeMap = new Map(allWorktrees.map((w) => [w.id, w])) - const spawnedPtyIds: string[] = [] - - // Why: ensure the global IPC listener for pty:data/pty:exit events is - // active before any spawn calls. This guarantees that data emitted - // immediately after spawn (before registerEagerPtyBuffer runs) is at - // least delivered to the dispatcher — and since registerEagerPtyBuffer - // runs synchronously in the microtask continuation after await spawn(), - // the handler will be in place before any macrotask-queued data arrives. + // Why: instead of eagerly spawning PTYs at default 80×24 (which fills + // eager buffers with content at wrong dimensions that gets garbled on + // flush), we defer the actual daemon createOrAttach call to connectPanePty + // where fitAddon provides real dims. + // + // This loop just records the daemon session IDs each leaf/tab needs so + // connectPanePty can pass them as sessionId to pty.spawn at mount time. + // The layout's ptyIdsByLeafId (preserved from shutdown) already has per-leaf + // mappings. For single-pane tabs without leaf mappings, store the tab-level + // ptyId as a sentinel so connectPanePty knows to reattach. ensurePtyDispatcher() for (const worktreeId of ids) { - if (signal?.aborted) { - // StrictMode unmount — kill any PTYs we already spawned and bail. - await Promise.allSettled(spawnedPtyIds.map((id) => window.api.pty.kill(id))) - return - } - - const worktree = worktreeMap.get(worktreeId) - if (!worktree) { - continue - } - const tabs = tabsByWorktree[worktreeId] ?? [] - // Why: pendingReconnectTabByWorktree was computed during hydration from - // the raw session data (before ptyIds were cleared). It tells us exactly - // which tabs had live PTYs in each worktree, so we reconnect all of them - // rather than just one arbitrary tab. + const worktree = Object.values(get().worktreesByRepo) + .flat() + .find((entry) => entry.id === worktreeId) + const repo = worktree ? get().repos.find((entry) => entry.id === worktree.repoId) : null + const supportsDeferredReattach = !repo?.connectionId const targetTabIds = pendingReconnectTabByWorktree[worktreeId] ?? [] const tabsToReconnect: TerminalTab[] = targetTabIds.length > 0 ? targetTabIds .map((id) => tabs.find((t) => t.id === id)) .filter((t): t is TerminalTab => t != null) - : tabs.slice(0, 1) // fallback: first tab only + : tabs.slice(0, 1) if (tabsToReconnect.length === 0) { continue } for (const tab of tabsToReconnect) { - if (signal?.aborted) { - await Promise.allSettled(spawnedPtyIds.map((id) => window.api.pty.kill(id))) - return - } + const tabId = tab.id + const layout = terminalLayoutsByTabId[tabId] + const leafPtyMap = layout?.ptyIdsByLeafId ?? {} + const tabLevelPtyId = pendingReconnectPtyIdByTabId[tabId] + const hasLeafMappings = Object.keys(leafPtyMap).length > 0 - try { - const { id: ptyId } = await window.api.pty.spawn({ - cols: 80, - rows: 24, - cwd: worktree.path, - worktreeId - }) - spawnedPtyIds.push(ptyId) - - if (signal?.aborted) { - await window.api.pty.kill(ptyId) - await Promise.allSettled( - spawnedPtyIds.filter((id) => id !== ptyId).map((id) => window.api.pty.kill(id)) - ) - return - } - - const tabId = tab.id - // Why: re-check that the tab/worktree still exist after the async - // spawn. If the user deleted the worktree during the spawn round- - // trip, kill the orphan PTY immediately instead of registering it. - const currentTabs = get().tabsByWorktree[worktreeId] - if (!currentTabs?.some((t) => t.id === tabId)) { - void window.api.pty.kill(ptyId) - continue - } - - // Why: register exit handler so that if the shell dies before - // TerminalPane attaches, the tab's ptyId is cleared and - // connectPanePty falls through to the normal connect() path. - registerEagerPtyBuffer(ptyId, (_exitedPtyId, _code) => { - get().clearTabPtyId(tabId, _exitedPtyId) - }) - - // Why: set ptyId directly instead of using updateTabPtyId to avoid - // bumpWorktreeActivity which would overwrite every reconnected - // worktree's lastActivityAt with the restart timestamp, destroying - // the relative recency sort order. + // Why: restore ptyId on the tab so getWorktreeStatus() sees it as + // active (green dot) even before the terminal pane mounts. For + // single-pane tabs the tab-level ptyId doubles as the daemon + // session ID. For split-pane tabs the layout's ptyIdsByLeafId + // carries per-leaf mappings; connectPanePty reads those via + // restoredPtyIdByLeafId, but the tab still needs a ptyId for + // status and orphan detection. + if (supportsDeferredReattach && tabLevelPtyId) { set((s) => { const next = { ...s.tabsByWorktree } if (!next[worktreeId]) { return {} } - next[worktreeId] = next[worktreeId].map((t) => (t.id === tabId ? { ...t, ptyId } : t)) + next[worktreeId] = next[worktreeId].map((t) => + t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t + ) + + // Why: populate ptyIdsByTabId so the sessions status segment + // can map daemon session IDs back to tabs (for bound/orphan + // detection and click-to-navigate). Without this, all sessions + // appear as orphans until the terminal pane mounts. + const allPtyIds = hasLeafMappings + ? (Object.values(leafPtyMap).filter(Boolean) as string[]) + : [tabLevelPtyId] return { tabsByWorktree: next, ptyIdsByTabId: { ...s.ptyIdsByTabId, - [tabId]: [...(s.ptyIdsByTabId[tabId] ?? []), ptyId] + [tabId]: allPtyIds } } }) - } catch { - // PTY spawn failure — this tab stays inactive, same as today. } } } @@ -1173,7 +1229,8 @@ export const createTerminalSlice: StateCreator set({ workspaceSessionReady: true, pendingReconnectWorktreeIds: [], - pendingReconnectTabByWorktree: {} + pendingReconnectTabByWorktree: {}, + pendingReconnectPtyIdByTabId: {} }) } }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 2f57c326f..779ab448f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -52,7 +52,7 @@ export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [ 'comment' ] -export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = ['claude', 'codex', 'ssh'] +export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = ['claude', 'codex', 'ssh', 'sessions'] export const REPO_COLORS = [ '#737373', // neutral diff --git a/src/shared/types.ts b/src/shared/types.ts index 81a5412f1..3fbf2e9f4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -656,7 +656,7 @@ export type OpenCodeStatusEvent = { export type WorktreeCardProperty = 'status' | 'unread' | 'ci' | 'issue' | 'pr' | 'comment' -export type StatusBarItem = 'claude' | 'codex' | 'ssh' +export type StatusBarItem = 'claude' | 'codex' | 'ssh' | 'sessions' export type PersistedUIState = { lastActiveRepoId: string | null