Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/main/ipc/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ export function registerSessionIpc(manager: SessionManager): void {
return manager.attachTerminal(sessionId)
})

// Agent PTY attach/replay. DebugPanel uses this for Claude
// and Codex panes when the user asks to see the raw underlying TUI
// as an xterm terminal. Kept separate from terminal-attach because
// plain terminal panes and agent panes have different primary
// renderers and different live IPC channels.
ipcMain.handle('session:agent-pty-attach', (_evt, sessionId: string) => {
return manager.attachAgentPty(sessionId)
})

ipcMain.handle('session:agent-pty-detach', (_evt, sessionId: string) => {
manager.detachAgentPty(sessionId)
})

ipcMain.handle(
'session:input',
(_evt, sessionId: string, data: string) => {
Expand Down
173 changes: 146 additions & 27 deletions src/main/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export type SessionKind = 'claude' | 'codex' | 'terminal'
export type ManagerEvents = {
started: [{ sessionId: string; kind: SessionKind; projectDir?: string }]
'pty-data': [{ sessionId: string; data: string }]
/** Raw PTY bytes for an attached agent inline terminal. Emitted
* only after attachAgentPty() flips the per-session attach flag. */
'agent-pty-data': [{ sessionId: string; data: string }]
/** Emitted only by Claude sessions — the scraped TUI snapshot. */
screen: [{ sessionId: string } & ScreenSnapshot]
/** Emitted only by Claude sessions — parsed JSONL entries. */
Expand Down Expand Up @@ -117,6 +120,8 @@ export type SpawnResult = {
tmuxName?: string
}

type PtySize = { cols: number; rows: number }

export interface SessionManager {
on<K extends keyof ManagerEvents>(
event: K,
Expand Down Expand Up @@ -193,9 +198,35 @@ type RegistryEntry =
// content wins) so long-running shells don't blow up memory.
const TERMINAL_BUFFER_CAP = 256 * 1024

// Raw-agent terminal buffer cap. This is intentionally larger than the
// plain terminal cap because Claude/Codex TUIs emit more repaint bytes
// than a normal shell: full-screen redraws, ANSI cursor movement, and
// progress rows can churn heavily even when the visible content is
// small. We still cap aggressively because this buffer is debug-only
// replay state, not the durable transcript source of truth.
const AGENT_PTY_BUFFER_CAP = 512 * 1024

function appendCappedBuffer(prev: string, data: string, cap: number): string {
let next = prev + data
if (next.length <= cap) return next

// Naive slice by string length can split a UTF-16 surrogate pair.
// xterm will usually recover from a replacement character, but the
// replay buffer should not introduce corruption at its oldest edge
// when dropping the whole rune costs only one code unit.
let startIdx = next.length - cap
const firstCode = next.charCodeAt(startIdx)
if (firstCode >= 0xdc00 && firstCode <= 0xdfff) {
startIdx += 1
}
next = next.slice(startIdx)
return next
}

export class SessionManager extends EventEmitter {
private readonly sessions = new Map<string, RegistryEntry>()
private readonly lastActivityAt = new Map<string, number>()
private readonly sessionSizes = new Map<string, PtySize>()

// Optional tmux backing for terminal sessions. Constructed by the
// app entrypoint AFTER detectAvailability() has resolved — we only
Expand Down Expand Up @@ -237,6 +268,24 @@ export class SessionManager extends EventEmitter {
private readonly terminalBuffers = new Map<string, string>()
private readonly terminalAttached = new Set<string>()

// Agent PTY terminal replay state. Agent sessions already publish
// parsed screen snapshots to the renderer, which is perfect for the
// structured feed but not enough for a real inline terminal: xterm
// needs the raw byte stream, including ANSI cursor movement and full
// repaint sequences. We therefore keep a capped byte-string replay
// buffer per Claude/Codex session and expose it only when the debug
// panel explicitly attaches.
//
// This deliberately uses the same attach/replay contract as
// TerminalLeaf instead of forwarding every agent byte to the
// renderer from process start: most users never open the raw inline
// terminal, and agent PTYs can be noisy. Buffer in main, broadcast
// only after an attach, and let the debug component replay the
// buffer before draining live bytes.
private readonly agentPtyBuffers = new Map<string, string>()
private readonly agentPtyAttached = new Set<string>()
private readonly agentPtyRestoreSizes = new Map<string, PtySize>()

private markActivity(sessionId: string): void {
this.lastActivityAt.set(sessionId, Date.now())
}
Expand Down Expand Up @@ -267,6 +316,10 @@ export class SessionManager extends EventEmitter {
// instantiate. This eliminates the if/else duplication that caused
// cross-provider breakage when editing one provider's spawn logic.
if (kind === 'claude' || kind === 'codex') {
const initialSize = {
cols: options.cols ?? 120,
rows: options.rows ?? 40,
}
const provider = getMainProvider(kind)
const createStartedAt = performance.now()
// `session` here structurally conforms to AgentSessionLike —
Expand All @@ -282,8 +335,8 @@ export class SessionManager extends EventEmitter {
const session = provider.createSession({
cwd: options.cwd,
binary: getToolPath(kind, kind),
cols: options.cols ?? 120,
rows: options.rows ?? 40,
cols: initialSize.cols,
rows: initialSize.rows,
snapshotIntervalMs: 16,
resumeSessionId: options.resumeSessionId,
dangerousMode: options.dangerousMode,
Expand All @@ -303,6 +356,8 @@ export class SessionManager extends EventEmitter {
provider: kind,
})

this.sessionSizes.set(sessionId, initialSize)
this.agentPtyBuffers.set(sessionId, '')
session.on('started', ({ projectDir }: { projectDir: string }) =>
{
this.markActivity(sessionId)
Expand All @@ -311,6 +366,14 @@ export class SessionManager extends EventEmitter {
)
session.on('pty-data', (data: string) => {
this.markActivity(sessionId)
const prev = this.agentPtyBuffers.get(sessionId) ?? ''
this.agentPtyBuffers.set(
sessionId,
appendCappedBuffer(prev, data, AGENT_PTY_BUFFER_CAP),
)
if (this.agentPtyAttached.has(sessionId)) {
this.emit('agent-pty-data', { sessionId, data })
}
this.emit('pty-data', { sessionId, data })
})
session.on('screen', (snap: ScreenSnapshot) => {
Expand Down Expand Up @@ -363,6 +426,10 @@ export class SessionManager extends EventEmitter {
this.markActivity(sessionId)
this.emit('exit', { sessionId, exitCode, signal })
this.sessions.delete(sessionId)
this.agentPtyBuffers.delete(sessionId)
this.agentPtyAttached.delete(sessionId)
this.agentPtyRestoreSizes.delete(sessionId)
this.sessionSizes.delete(sessionId)
})

this.sessions.set(sessionId, { kind, session })
Expand All @@ -387,6 +454,10 @@ export class SessionManager extends EventEmitter {
// on `session` because the wrapper already owns its own
// EventEmitter — nothing outside the registry subscribed.
this.sessions.delete(sessionId)
this.agentPtyBuffers.delete(sessionId)
this.agentPtyAttached.delete(sessionId)
this.agentPtyRestoreSizes.delete(sessionId)
this.sessionSizes.delete(sessionId)
performanceService.error('session.spawn.providerStart.error', err, {
sessionId,
kind,
Expand Down Expand Up @@ -445,10 +516,15 @@ export class SessionManager extends EventEmitter {
})
}

const session = new TerminalSession({
cwd: options.cwd,
const initialSize = {
cols: options.cols ?? 80,
rows: options.rows ?? 24,
}

const session = new TerminalSession({
cwd: options.cwd,
cols: initialSize.cols,
rows: initialSize.rows,
runtime: useTmux ? 'tmux' : 'direct',
tmuxSessionName: tmuxSessionName ?? undefined,
})
Expand All @@ -457,6 +533,7 @@ export class SessionManager extends EventEmitter {
// data events. The buffer accumulates every byte of PTY output
// and is replayed to the renderer on attach — see the block
// comment on terminalBuffers above for the full reasoning.
this.sessionSizes.set(sessionId, initialSize)
this.terminalBuffers.set(sessionId, '')

// Terminal sessions only emit started / data / exit. The 'data'
Expand All @@ -477,28 +554,10 @@ export class SessionManager extends EventEmitter {
// longer sessions just lose the oldest bytes, which is the
// standard terminal scrollback behavior.
const prev = this.terminalBuffers.get(sessionId) ?? ''
let next = prev + data
if (next.length > TERMINAL_BUFFER_CAP) {
// Naive slice by string length can split a UTF-16 surrogate
// pair — emoji and most CJK chars in JS strings are stored
// as a (high-surrogate, low-surrogate) pair that occupies
// two `.length` code units. If TERMINAL_BUFFER_CAP lands
// between the two, the replay starts with an orphaned low
// surrogate, which xterm renders as U+FFFD or garbage.
//
// Detect a low surrogate at the slice boundary and advance
// one code unit so we drop the whole rune instead of
// keeping its tail. This loses one character at the oldest
// edge of the scrollback — imperceptible vs the visible
// corruption the naive path produces.
let startIdx = next.length - TERMINAL_BUFFER_CAP
const firstCode = next.charCodeAt(startIdx)
if (firstCode >= 0xdc00 && firstCode <= 0xdfff) {
startIdx += 1
}
next = next.slice(startIdx)
}
this.terminalBuffers.set(sessionId, next)
this.terminalBuffers.set(
sessionId,
appendCappedBuffer(prev, data, TERMINAL_BUFFER_CAP),
)
// Only broadcast live events AFTER the renderer has attached.
// Before attach, the data is still in the buffer and will be
// replayed when the renderer calls attachTerminal. See the
Expand All @@ -514,6 +573,7 @@ export class SessionManager extends EventEmitter {
this.sessions.delete(sessionId)
this.terminalBuffers.delete(sessionId)
this.terminalAttached.delete(sessionId)
this.sessionSizes.delete(sessionId)
})

this.sessions.set(sessionId, { kind: 'terminal', session, tmuxName: tmuxSessionName })
Expand Down Expand Up @@ -541,6 +601,7 @@ export class SessionManager extends EventEmitter {
this.sessions.delete(sessionId)
this.terminalBuffers.delete(sessionId)
this.terminalAttached.delete(sessionId)
this.sessionSizes.delete(sessionId)
performanceService.error('session.spawn.terminalStart.error', err, { sessionId })
throw err
}
Expand Down Expand Up @@ -608,6 +669,55 @@ export class SessionManager extends EventEmitter {
return buffer
}

/**
* Agent PTY attach/replay entry point.
*
* This is the Claude/Codex counterpart to attachTerminal(). It is
* intentionally separate because agent panes are not terminal panes:
* their primary renderer is the structured feed, while this inline
* terminal is a debug-only view into the underlying provider TUI.
* Returning the buffered raw bytes lets the inline xterm reconstruct
* the provider's latest terminal state, then `agent-pty-data`
* carries subsequent live bytes for as long as the session remains
* open.
*/
attachAgentPty(sessionId: string): string {
const entry = this.sessions.get(sessionId)
if (!entry) return ''
if (entry.kind !== 'claude' && entry.kind !== 'codex') {
console.warn(
`[SessionManager] attachAgentPty called on non-agent session`,
{ sessionId, kind: entry.kind },
)
return ''
}
const buffer = this.agentPtyBuffers.get(sessionId) ?? ''
if (!this.agentPtyAttached.has(sessionId)) {
const currentSize = this.sessionSizes.get(sessionId)
if (currentSize) {
this.agentPtyRestoreSizes.set(sessionId, { ...currentSize })
}
}
this.agentPtyAttached.add(sessionId)
return buffer
}

/**
* Detach the debug inline terminal from a Claude/Codex session.
* This disables raw PTY IPC forwarding and restores the provider PTY
* size that was active before the inline terminal took ownership.
*/
detachAgentPty(sessionId: string): void {
const entry = this.sessions.get(sessionId)
this.agentPtyAttached.delete(sessionId)
const restoreSize = this.agentPtyRestoreSizes.get(sessionId)
this.agentPtyRestoreSizes.delete(sessionId)
if (!entry || (entry.kind !== 'claude' && entry.kind !== 'codex')) return
if (!restoreSize) return
entry.session.resize(restoreSize.cols, restoreSize.rows)
this.sessionSizes.set(sessionId, restoreSize)
}

/**
* Write bytes to a session's PTY. Silently no-ops if the session
* doesn't exist — this happens naturally if a session exits between
Expand All @@ -628,7 +738,10 @@ export class SessionManager extends EventEmitter {

/** Resize a session's terminal + PTY. No-op if session doesn't exist. */
resize(sessionId: string, cols: number, rows: number): void {
this.sessions.get(sessionId)?.session.resize(cols, rows)
const entry = this.sessions.get(sessionId)
if (!entry) return
entry.session.resize(cols, rows)
this.sessionSizes.set(sessionId, { cols, rows })
}

/**
Expand Down Expand Up @@ -679,6 +792,12 @@ export class SessionManager extends EventEmitter {
// orphan and kills it. This is the explicit "buffer for undo"
// behavior the user asked for in the P1 brainstorm.
this.sessions.delete(sessionId)
if (entry.kind === 'claude' || entry.kind === 'codex') {
this.agentPtyBuffers.delete(sessionId)
this.agentPtyAttached.delete(sessionId)
this.agentPtyRestoreSizes.delete(sessionId)
}
this.sessionSizes.delete(sessionId)
return true
}

Expand Down
11 changes: 8 additions & 3 deletions src/main/sessions/forwarder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import { enqueueJsonl, flushAndDropJsonl } from '@main/sessions/jsonlCoalescer.j
// right tile. The forwarder's job is the dumbest possible one: tag
// the channel, push the payload.
//
// terminal-data is intentionally a separate channel from screen /
// jsonl-entry — keeps every Claude pane listener from unpacking and
// ignoring raw PTY bytes it doesn't care about.
// terminal-data and agent-pty-data are intentionally separate channels from
// screen / jsonl-entry. terminal-data is for plain shell panes;
// agent-pty-data is an opt-in inline terminal for Claude/Codex panes.
// Keeping both out of the normal structured feed path prevents every
// agent pane listener from unpacking and ignoring raw PTY bytes.

export function wireSessionForwarder(
manager: SessionManager,
Expand All @@ -37,6 +39,9 @@ export function wireSessionForwarder(
manager.on('terminal-data', payload =>
sendToMainWindow('session:terminal-data', payload),
)
manager.on('agent-pty-data', payload =>
sendToMainWindow('session:agent-pty-data', payload),
)
manager.on('process-state', payload => sendToMainWindow('session:process-state', payload))
manager.on('trust-dialog', payload => sendToMainWindow('session:trust-dialog', payload))
manager.on('resume-prompt', payload => sendToMainWindow('session:resume-prompt', payload))
Expand Down
19 changes: 19 additions & 0 deletions src/preload/api/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
SessionJsonlEntryEvent,
SessionJsonlErrorEvent,
SessionPermissionPromptEvent,
SessionAgentPtyDataEvent,
SessionKind,
SessionScreenEvent,
SessionSemanticEvent,
Expand Down Expand Up @@ -75,6 +76,20 @@ export const sessionApi = {
attachTerminal: (sessionId: string): Promise<string> =>
ipcRenderer.invoke('session:terminal-attach', sessionId),

/**
* Attach this renderer to a Claude/Codex session's raw PTY terminal.
* Returns a capped replay buffer and enables live
* 'session:agent-pty-data' events for the session. Used by
* DebugPanel's inline terminal view;
* normal agent panes continue to render from screen/jsonl/semantic
* state and do not subscribe to this high-volume byte stream.
*/
attachAgentPty: (sessionId: string): Promise<string> =>
ipcRenderer.invoke('session:agent-pty-attach', sessionId),

detachAgentPty: (sessionId: string): Promise<void> =>
ipcRenderer.invoke('session:agent-pty-detach', sessionId),

// --- Per-session I/O ---
sendInput: (sessionId: string, data: string): Promise<boolean> =>
ipcRenderer.invoke('session:input', sessionId, data),
Expand Down Expand Up @@ -139,6 +154,10 @@ export const sessionApi = {
onSessionTerminalData: (cb: (e: SessionTerminalDataEvent) => void): Unsub =>
subscribe('session:terminal-data', cb),

/** Raw PTY bytes for attached Claude/Codex inline terminals. */
onSessionAgentPtyData: (cb: (e: SessionAgentPtyDataEvent) => void): Unsub =>
subscribe('session:agent-pty-data', cb),

onSessionProcessState: (
cb: (e: { sessionId: string; active: boolean; status?: string }) => void,
): Unsub => subscribe('session:process-state', cb),
Expand Down
2 changes: 2 additions & 0 deletions src/preload/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export type SessionJsonlEntriesEvent = {
export type SessionJsonlErrorEvent = { sessionId: string; message: string }
/** Raw PTY output for a terminal session — destined for xterm.js. */
export type SessionTerminalDataEvent = { sessionId: string; data: string }
/** Raw PTY output for an attached Claude/Codex inline terminal. */
export type SessionAgentPtyDataEvent = { sessionId: string; data: string }
export type SessionTrustDialogEvent = {
sessionId: string
visible: boolean
Expand Down
Loading