feat: terminal persistence via out-of-process daemon (#729)
This commit is contained in:
parent
39832c7801
commit
fd4f986c59
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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:')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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`)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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('')
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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 = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
|
|
@ -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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue