feat: terminal persistence via out-of-process daemon (#729)

This commit is contained in:
Jinwoo Hong 2026-04-17 01:42:41 -04:00 committed by GitHub
parent 39832c7801
commit fd4f986c59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 8779 additions and 243 deletions

View File

@ -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: [

View File

@ -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: {

View File

@ -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",

View File

@ -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: {}

View File

@ -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')
})
})

View File

@ -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)
}
}
}

View File

@ -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<void> {
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<void>((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<void> {
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')
})
})
})

295
src/main/daemon/client.ts Normal file
View File

@ -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<typeof setTimeout>
}
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<void> | null = null
private pendingRequests = new Map<string, PendingRequest>()
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<void> {
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<void> {
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<T = unknown>(type: string, payload: unknown): Promise<T> {
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<T>((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<Socket> {
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<void> {
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()
}
}
}

View File

@ -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:')
})
})

View File

@ -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 <path> --token <path>')
}
return { socketPath, tokenPath }
}
async function main(): Promise<void> {
const { socketPath, tokenPath } = parseArgs(process.argv.slice(2))
let daemon: DaemonHandle | null = null
const shutdown = async (): Promise<void> => {
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)
})
}

View File

@ -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<boolean> {
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<void>((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<void> {
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<void> {
adapter?.dispose()
adapter = null
await spawner?.shutdown()
spawner = null
}

View File

@ -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
}
}
}

View File

@ -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<void>
}
export async function startDaemon(opts: DaemonStartOptions): Promise<DaemonHandle> {
const server = new DaemonServer({
socketPath: opts.socketPath,
tokenPath: opts.tokenPath,
spawnSubprocess: opts.spawnSubprocess
})
await server.start()
return {
shutdown: () => server.shutdown()
}
}

View File

@ -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<void> {
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<typeof createMockSubprocess>
let lastSpawnOpts: {
sessionId: string
cols: number
rows: number
cwd?: string
env?: Record<string, string>
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
)
})
})
})

View File

@ -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<string, string>()
// 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<id, timestamp> so eviction removes the oldest by insertion order,
// matching terminal-host.ts tombstone semantics.
private killedSessionTombstones = new Map<string, number>()
// 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<string, { scrollback: string; cwd: string }>()
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<PtySpawnResult> {
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<CreateOrAttachResult>('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<void> {
await this.ensureConnected()
await this.client.request<CreateOrAttachResult>('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<void> {
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<void> {
await this.client.request('signal', { sessionId: id, signal })
}
async getCwd(id: string): Promise<string> {
try {
const result = await this.client.request<{ cwd: string | null }>('getCwd', {
sessionId: id
})
return result.cwd ?? ''
} catch {
return ''
}
}
async getInitialCwd(id: string): Promise<string> {
return this.initialCwds.get(id) ?? ''
}
async clearBuffer(id: string): Promise<void> {
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<boolean> {
return false
}
async getForegroundProcess(_id: string): Promise<string | null> {
return null
}
async serialize(ids: string[]): Promise<string> {
const sessions: Record<string, { initialCwd?: string }> = {}
for (const id of ids) {
sessions[id] = { initialCwd: this.initialCwds.get(id) }
}
return JSON.stringify(sessions)
}
async revive(_state: string): Promise<void> {
// 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<string>): Promise<{
alive: string[]
killed: string[]
}> {
await this.ensureConnected()
const result = await this.client.request<ListSessionsResult>('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<ListSessionsResult>('listSessions', undefined)
return result.sessions
.filter((s) => s.isAlive)
.map((s) => ({
id: s.sessionId,
cwd: s.cwd ?? '',
title: 'shell'
}))
}
async getDefaultShell(): Promise<string> {
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<void> {
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 })
}
}
})
}
}

View File

@ -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<void> {
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<typeof createMockSubprocess>
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')
})
})
})

View File

@ -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<string, string>
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<DaemonSpawnResult> {
await this.client.ensureConnected()
this.setupEventRouting()
const result = await this.client.request<CreateOrAttachResult>('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<void> {
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<void> {
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 })
}
}
})
}
}

View File

@ -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<void> {
server = new DaemonServer({
socketPath,
tokenPath,
spawnSubprocess: () => createMockSubprocess()
})
await server.start()
}
async function connectClient(): Promise<DaemonClient> {
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<void>((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<string>((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()
})
})
})

View File

@ -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<string, string>
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<string, ConnectedClient>()
constructor(opts: DaemonServerOptions) {
this.socketPath = opts.socketPath
this.tokenPath = opts.tokenPath
this.token = randomUUID()
this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess })
}
async start(): Promise<void> {
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<void> {
this.host.dispose()
for (const [, client] of this.clients) {
client.controlSocket.destroy()
client.streamSocket?.destroy()
}
this.clients.clear()
return new Promise<void>((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<typeof createNdjsonParser>
): 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<void> {
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<unknown> {
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}`)
}
}
}

View File

@ -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()
})
})
})

View File

@ -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<void>
}
export type DaemonLauncher = (socketPath: string, tokenPath: string) => Promise<DaemonProcessHandle>
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<DaemonConnectionInfo> {
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<void> {
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`)
}

View File

@ -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()

View File

@ -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()
})
})
})

View File

@ -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<void> {
if (this.disposed) {
return Promise.resolve()
}
this.scanOsc7(data)
return new Promise<void>((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 ; <uri> BEL or ESC ] 7 ; <uri> 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('')
}
}

View File

@ -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')
})
})
})

View File

@ -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<string, SessionWriter>()
private disabledSessions = new Set<string>()
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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 ; <uri> BEL or ESC ] 7 ; <uri> 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<SessionMeta>): 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))
}
}

View File

@ -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)
}

View File

@ -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> = {}): 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])
})
})
})

View File

@ -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
}
}

View File

@ -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 })
})
})

41
src/main/daemon/ndjson.ts Normal file
View File

@ -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 = ''
}
}
}

View File

@ -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()
})
})

View File

@ -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()
})
}
}

View File

@ -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()
})
})

View File

@ -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<DaemonProcessHandle> => {
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<void> {
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<string, unknown>).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<void> {
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')
})
}

View File

@ -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' })
)
})
})

View File

@ -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<string, string>
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<string, string> = {
...process.env,
...opts.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca'
} as Record<string, string>
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
}
}
}

View File

@ -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<HeadlessEmulator['getSnapshot']>) {
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<typeof createMockSubprocess>
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')
})
})
})

View File

@ -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<typeof createMockSubprocess>
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')
})
})
})

309
src/main/daemon/session.ts Normal file
View File

@ -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<typeof setTimeout> | null = null
private killTimer: ReturnType<typeof setTimeout> | 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)
}
}
}

View File

@ -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, string>): string {
if (process.platform === 'win32') {
return env.COMSPEC || 'powershell.exe'
}
return env.SHELL || process.env.SHELL || '/bin/zsh'
}
export function supportsPtyStartupBarrier(env: Record<string, string>): 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<string, string>
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
}
}

View File

@ -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<string, string>
command?: string
}) => SubprocessHandle
describe('TerminalHost', () => {
let host: TerminalHost
let spawnFn: MockSpawnFn
let lastSubprocess: ReturnType<typeof createMockSubprocess> & {
_onDataCb: ((data: string) => void) | null
_onExitCb: ((code: number) => void) | null
}
beforeEach(() => {
spawnFn = vi.fn(() => {
const sub = createMockSubprocess() as ReturnType<typeof createMockSubprocess> & {
_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([])
})
})
})

View File

@ -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<string, string>
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<string, string>
command?: string
}) => SubprocessHandle
}
export class TerminalHost {
private sessions = new Map<string, Session>()
private killedTombstones = new Map<string, number>()
private spawnSubprocess: TerminalHostOptions['spawnSubprocess']
constructor(opts: TerminalHostOptions) {
this.spawnSubprocess = opts.spawnSubprocess
}
async createOrAttach(opts: CreateOrAttachOptions): Promise<CreateOrAttachResult> {
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)
}
}
}
}

263
src/main/daemon/types.ts Normal file
View File

@ -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<string, string>
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<T = unknown> = {
id: string
ok: true
payload: T
}
export type RpcResponseError = {
id: string
ok: false
error: string
}
export type RpcResponse<T = unknown> = RpcResponseOk<T> | 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'
}
}

View File

@ -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<string, unknown>).window = globalThis
}

View File

@ -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) => {

View File

@ -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

View File

@ -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<string, IPtyProvider>()
// 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<string, { id: string; cwd: string; title: string }>()
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<boolean> => {
@ -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()
}
}

View File

@ -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', () => {

View File

@ -43,6 +43,23 @@ type ExitCallback = (payload: { id: string; code: number }) => void
const dataListeners = new Set<DataCallback>()
const exitListeners = new Set<ExitCallback>()
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<PtySpawnResult> {
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 })

View File

@ -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"
}

View File

@ -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 = {

View File

@ -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<void>
ackColdRestore: (id: string) => void
hasChildProcesses: (id: string) => Promise<boolean>
getForegroundProcess: (id: string) => Promise<string | null>
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

View File

@ -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<void>
ackColdRestore: (id: string) => void
hasChildProcesses: (id: string) => Promise<boolean>
getForegroundProcess: (id: string) => Promise<string | null>
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

View File

@ -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<void> => 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<boolean> =>

View File

@ -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 (
<div
className={`flex items-center gap-2 px-2 py-1.5 rounded ${
tabId ? 'cursor-pointer hover:bg-accent/60' : ''
}`}
onClick={tabId ? () => onNavigate(tabId) : undefined}
>
<span
className={`size-1.5 shrink-0 rounded-full ${isBound ? 'bg-emerald-500' : 'bg-muted-foreground/40'}`}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium font-mono">{sessionLabel(session)}</div>
</div>
{!isBound && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onKill(session.id)
}}
className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label={`Kill session ${session.id}`}
>
<Trash2 className="size-3" />
</button>
)}
</div>
)
}
export function SessionsStatusSegment({
compact: _compact,
iconOnly
}: {
compact: boolean
iconOnly: boolean
}): React.JSX.Element {
const [sessions, setSessions] = useState<DaemonSession[]>([])
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<string, string>()
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<string, string>()
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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1.5 cursor-pointer rounded px-1 py-0.5 hover:bg-accent/70"
aria-label="Terminal sessions"
>
<Terminal className="size-3 text-muted-foreground" />
{!iconOnly && (
<span className="text-[11px] tabular-nums">
{sessions.length}
{orphanCount > 0 && <span className="text-yellow-500 ml-0.5">({orphanCount})</span>}
</span>
)}
{iconOnly && sessions.length > 0 && (
<span className="text-[11px] tabular-nums">{sessions.length}</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end" sideOffset={8} className="w-[260px]">
<div className="px-2 pt-1.5 pb-1 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
Terminal Sessions ({sessions.length})
</div>
{sessions.length === 0 ? (
<div className="px-2 py-3 text-center text-[11px] text-muted-foreground">
No active sessions
</div>
) : (
<div className="max-h-[240px] overflow-y-auto scrollbar-sleek">
{[...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 (
<SessionRow
key={s.id}
session={s}
isBound={workspaceSessionReady && boundPtyIds.has(s.id)}
tabId={tabId}
onKill={handleKill}
onNavigate={handleNavigate}
/>
)
})}
</div>
)}
{orphanCount > 0 && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-2">
<button
type="button"
onClick={() => void handleKillOrphans()}
className="inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
Kill {orphanCount} Orphan{orphanCount > 1 ? 's' : ''}
</button>
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -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 {
<div className="flex-1" />
{showSsh && <SshStatusSegment compact={compact} iconOnly={iconOnly} />}
<div className="flex items-center gap-3">
{showSessions && <SessionsStatusSegment compact={compact} iconOnly={iconOnly} />}
{showSsh && <SshStatusSegment compact={compact} iconOnly={iconOnly} />}
</div>
</div>
</ContextMenuTrigger>
@ -523,6 +528,12 @@ function StatusBarInner(): React.JSX.Element | null {
>
SSH Status
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem
checked={statusBarItems.includes('sessions')}
onCheckedChange={() => toggleStatusBarItem('sessions')}
>
Terminal Sessions
</ContextMenuCheckboxItem>
</ContextMenuContent>
</ContextMenu>
)

View File

@ -6,6 +6,8 @@ type StoreState = {
repos: { id: string; connectionId?: string | null }[]
cacheTimerByKey: Record<string, number | null>
settings: { promptCacheTimerEnabled?: boolean } | null
consumePendingColdRestore: ReturnType<typeof vi.fn>
consumePendingSnapshot: ReturnType<typeof vi.fn>
}
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<string, unknown>) => 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')
})
})

View File

@ -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

View File

@ -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 | string>
}) => void | Promise<void | string | PtyConnectResult>
/** 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

View File

@ -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 }

View File

@ -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)
}

View File

@ -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<string, unknown>).spawn).toHaveBeenCalledTimes(2)
expect((mockApi.pty as Record<string, unknown>).spawn).toHaveBeenCalledWith(
expect.objectContaining({ cwd: '/path/wt1' })
)
expect((mockApi.pty as Record<string, unknown>).spawn).toHaveBeenCalledWith(
expect.objectContaining({ cwd: '/path/wt2' })
)
// No eager spawn — PTY creation deferred to pane mount
expect((mockApi.pty as Record<string, unknown>).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<string, unknown>).spawn).toHaveBeenCalledTimes(1)
// Why: deferred reattach doesn't call spawn — just records session IDs
expect((mockApi.pty as Record<string, unknown>).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<string, unknown>).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)
})
})

View File

@ -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' }
})
})

View File

@ -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<string, string[]>
/** 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<string, string>
/** 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<string, { scrollback: string; cwd: string }>
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<AppState, [], [], TerminalSlice>
workspaceSessionReady: false,
pendingReconnectWorktreeIds: [],
pendingReconnectTabByWorktree: {},
pendingReconnectPtyIdByTabId: {},
pendingSnapshotByPtyId: {},
pendingColdRestoreByPtyId: {},
cacheTimerByKey: {},
setCacheTimerStartedAt: (key, ts) => {
@ -283,8 +303,12 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
}
}
// 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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
}
}
// 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<string, string> = {}
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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
set({
workspaceSessionReady: true,
pendingReconnectWorktreeIds: [],
pendingReconnectTabByWorktree: {}
pendingReconnectTabByWorktree: {},
pendingReconnectPtyIdByTabId: {}
})
}
})

View File

@ -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

View File

@ -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