Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9bab4ba
refactor(claude): extract parseClaudeStartOptions
junmo-kim Jun 19, 2026
288da8e
refactor(pty): split interactive PTY launch from the local/remote con…
junmo-kim Jun 19, 2026
5d77808
refactor(claude): extract question-answer input builders to a shared …
junmo-kim Jun 12, 2026
0804007
refactor(web): extract shared QuickKeys from the terminal view
junmo-kim Jun 14, 2026
2cbe528
refactor(pty): extract the respawn loop into RemoteLauncherBase
junmo-kim Jun 14, 2026
4755755
feat(pty): add interactive PTY process manager and shared driver
junmo-kim Jun 12, 2026
c19d771
feat(pty): isolate claude folder-trust in a disposable config dir
junmo-kim Jun 12, 2026
782474b
perf(scanner): incremental byte reads and adaptive polling
junmo-kim Jun 12, 2026
6b03e99
feat(pty): drive the claude PTY launcher with chat, model, resume and…
junmo-kim Jun 12, 2026
0d022d7
feat(pty): stream the interactive terminal and structured chat to the…
junmo-kim Jun 12, 2026
885e6da
feat(pty): add PTY-mode option to the web new-session flow
junmo-kim Jun 12, 2026
4884698
feat(pty): inject Esc to interrupt the running turn on stop
junmo-kim Jun 14, 2026
0929136
feat(pty): make the agent terminal interactive (raw keys + quick keys)
junmo-kim Jun 14, 2026
d0d6cc6
feat(pty): forward user messages to local-mode Claude stdin
junmo-kim Jun 19, 2026
5096b89
feat(pty): clear PTY input, reset queue, and signal restore on abort
junmo-kim Jun 23, 2026
a75d10a
feat(pty): restore aborted prompt to the web composer on abort
junmo-kim Jun 23, 2026
bff26e5
fix(pty): restore only the in-flight prompt on abort
junmo-kim Jun 23, 2026
cbcbca8
fix(pty): honor a name-level Bash session allow
junmo-kim Jun 23, 2026
310bca9
fix(pty): keep the runner OAuth token when launching claude
junmo-kim Jun 23, 2026
a076dbe
fix(pty): fail spawn when the claude PTY never becomes ready
junmo-kim Jun 23, 2026
7dc7355
fix(pty): forward startingMode to spawnSession (was lost as serviceTier)
junmo-kim Jun 23, 2026
5520fee
fix(pty): submit queued prompts only when the prompt is actually live
junmo-kim Jun 23, 2026
bc6e328
fix(pty): ack queued messages dropped on abort
junmo-kim Jun 23, 2026
0be9c0f
fix(pty): gate PTY resume on session-ready like spawn
junmo-kim Jun 23, 2026
ff8891d
fix(pty): never submit a queued prompt into a still-running turn
junmo-kim Jun 23, 2026
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
218 changes: 218 additions & 0 deletions cli/src/agent/AgentPtyManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentPtyManager } from './AgentPtyManager'

const globalWithBun = globalThis as unknown as {
Bun?: {
spawn?: unknown
}
}
const originalBun = globalWithBun.Bun

function makeMockProc(): { terminal: Bun.Terminal; killed: boolean; exitCode: number | null; signalCode: string | null; kill: ReturnType<typeof vi.fn>; onExit?: (code: number | null) => void } {
return {
terminal: {
write: vi.fn(),
resize: vi.fn(),
close: vi.fn(),
} as unknown as Bun.Terminal,
killed: false,
exitCode: null,
signalCode: null,
kill: vi.fn(() => { (proc as any).killed = true }),
}
}

let proc: ReturnType<typeof makeMockProc>

describe('AgentPtyManager', () => {
beforeEach(() => {
proc = makeMockProc()
const spawnMock = vi.fn(() => proc)
globalWithBun.Bun = {
spawn: spawnMock,
}
})

afterEach(() => {
if (originalBun === undefined) {
delete globalWithBun.Bun
} else {
globalWithBun.Bun = originalBun
}
})

it('spawns a process with terminal option', () => {
const manager = new AgentPtyManager()
const onData = vi.fn()

manager.spawn({
command: 'claude',
args: ['--model', 'sonnet'],
cwd: '/workspace/project',
cols: 80,
rows: 24,
onData,
})

expect(globalWithBun.Bun!.spawn).toHaveBeenCalledWith(
['claude', '--model', 'sonnet'],
expect.objectContaining({
cwd: '/workspace/project',
terminal: expect.objectContaining({
cols: 80,
rows: 24,
data: expect.any(Function),
}),
})
)
expect(manager.isRunning).toBe(true)
})

it('calls onData callback when terminal emits data', () => {
const manager = new AgentPtyManager()
const onData = vi.fn()

manager.spawn({
command: 'claude',
onData,
})

const spawnCall = (globalWithBun.Bun!.spawn as ReturnType<typeof vi.fn>).mock.calls[0]
const terminalConfig = spawnCall[1].terminal
const decoder = new TextDecoder()
const data = new TextEncoder().encode('hello from claude')

terminalConfig.data(proc.terminal, data)

expect(onData).toHaveBeenCalledWith('hello from claude')
})

it('writes data to terminal', () => {
const manager = new AgentPtyManager()

manager.spawn({
command: 'claude',
onData: vi.fn(),
})

manager.write('test input\n')

expect(proc.terminal.write).toHaveBeenCalledWith('test input\n')
})

it('resizes terminal dimensions', () => {
const manager = new AgentPtyManager()

manager.spawn({
command: 'claude',
cols: 80,
rows: 24,
onData: vi.fn(),
})

manager.resize(120, 40)

expect(proc.terminal.resize).toHaveBeenCalledWith(120, 40)
})

it('kills the process and cleans up', () => {
const manager = new AgentPtyManager()

manager.spawn({
command: 'claude',
onData: vi.fn(),
})

manager.kill()

expect(proc.kill).toHaveBeenCalled()
expect(proc.terminal.close).toHaveBeenCalled()
expect(manager.isRunning).toBe(false)
})

it('reports exit code via onExit callback', () => {
const manager = new AgentPtyManager()
const onExit = vi.fn()

manager.spawn({
command: 'claude',
onData: vi.fn(),
onExit,
})

const spawnCall = (globalWithBun.Bun!.spawn as ReturnType<typeof vi.fn>).mock.calls[0]
const onExitHandler = spawnCall[1].onExit

onExitHandler(proc, 0)

expect(onExit).toHaveBeenCalledWith(0, null)
expect(manager.exitCode).toBe(0)
})

it('does not call spawn if Bun is unavailable', () => {
delete globalWithBun.Bun
const manager = new AgentPtyManager()
const onError = vi.fn()

manager.spawn({
command: 'claude',
onData: vi.fn(),
onError,
})

expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('Bun') })
)
expect(manager.isRunning).toBe(false)
})

it('does not write if not spawned', () => {
const manager = new AgentPtyManager()
manager.write('data')
// No error should be thrown
})

it('does not resize if not spawned', () => {
const manager = new AgentPtyManager()
manager.resize(80, 24)
// No error should be thrown
})

it('does not kill if not spawned', () => {
const manager = new AgentPtyManager()
manager.kill()
// No error should be thrown
})

it('tracks exit code and signal code', () => {
const manager = new AgentPtyManager()

manager.spawn({
command: 'claude',
onData: vi.fn(),
})

const spawnCall = (globalWithBun.Bun!.spawn as ReturnType<typeof vi.fn>).mock.calls[0]
const onExitHandler = spawnCall[1].onExit

proc.signalCode = 'SIGTERM'
onExitHandler(proc, null)

expect(manager.exitCode).toBe(null)
expect(manager.signalCode).toBe('SIGTERM')
expect(manager.isRunning).toBe(false)
})

it('applies environment variables from filtered env', () => {
const manager = new AgentPtyManager()

manager.spawn({
command: 'claude',
env: { TERM: 'xterm-256color', CUSTOM_VAR: 'value' },
onData: vi.fn(),
})

const spawnCall = (globalWithBun.Bun!.spawn as ReturnType<typeof vi.fn>).mock.calls[0]
expect(spawnCall[1].env).toEqual({ TERM: 'xterm-256color', CUSTOM_VAR: 'value' })
})
})
133 changes: 133 additions & 0 deletions cli/src/agent/AgentPtyManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { logger } from '@/ui/logger'

export type AgentPtyOptions = {
command: string
args?: string[]
cwd?: string
env?: Record<string, string>
cols?: number
rows?: number
onData: (data: string) => void
onExit?: (code: number | null, signal: string | null) => void
onError?: (error: Error) => void
}

function getOptionalBun(): typeof Bun | null {
return typeof Bun === 'undefined' ? null : Bun
}

export class AgentPtyManager {
private proc: Bun.Subprocess | null = null
private terminal: Bun.Terminal | null = null
private _exitCode: number | null = null
private _signalCode: string | null = null
private _isRunning: boolean = false

get exitCode(): number | null {
return this._exitCode
}

get signalCode(): string | null {
return this._signalCode
}

get isRunning(): boolean {
return this._isRunning
}

spawn(opts: AgentPtyOptions): void {
const bun = getOptionalBun()
if (!bun || typeof bun.spawn !== 'function') {
const err = new Error('Bun.spawn is unavailable in this runtime')
opts.onError?.(err)
return
}

const cmd = opts.command
const args = opts.args ?? []
const cwd = opts.cwd
const decoder = new TextDecoder()

try {
this.proc = bun.spawn([cmd, ...args], {
cwd,
env: opts.env ?? process.env,
terminal: {
cols: opts.cols ?? 80,
rows: opts.rows ?? 24,
data: (_terminal, data) => {
const text = decoder.decode(data, { stream: true })
if (text) {
opts.onData(text)
}
},
},
onExit: (subprocess, exitCode) => {
this._exitCode = exitCode
this._signalCode = subprocess.signalCode ?? null
this._isRunning = false
opts.onExit?.(this._exitCode, this._signalCode)
},
})

this.terminal = this.proc.terminal ?? null
if (!this.terminal) {
try {
this.proc.kill()
} catch (error) {
logger.debug('[AgentPtyManager] Failed to kill process after missing terminal', { error })
}
this.proc = null
const err = new Error('Failed to attach terminal to spawned process')
opts.onError?.(err)
return
}

this._isRunning = true
} catch (error) {
logger.debug('[AgentPtyManager] Failed to spawn process', { error })
this.proc = null
this.terminal = null
opts.onError?.(error instanceof Error ? error : new Error(String(error)))
}
}

write(data: string): void {
if (!this.terminal || !this._isRunning) {
return
}
this.terminal.write(data)
}

resize(cols: number, rows: number): void {
if (!this.terminal || !this._isRunning) {
return
}
this.terminal.resize(cols, rows)
}

kill(): void {
if (!this.proc || !this._isRunning) {
return
}

if (!this.proc.killed && this.proc.exitCode === null) {
try {
this.proc.kill()
} catch (error) {
logger.debug('[AgentPtyManager] Failed to kill process', { error })
}
}

if (this.terminal) {
try {
this.terminal.close()
} catch (error) {
logger.debug('[AgentPtyManager] Failed to close terminal', { error })
}
}

this.terminal = null
this._isRunning = false
}
}
28 changes: 28 additions & 0 deletions cli/src/agent/__tests__/bracketedPaste.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { bracketPasteIfMultiline } from '../bracketedPaste'

const START = '\x1b[200~'
const END = '\x1b[201~'

describe('bracketPasteIfMultiline', () => {
it('leaves a single-line message untouched', () => {
expect(bracketPasteIfMultiline('hello world')).toBe('hello world')
})

it('wraps a multiline message in bracketed-paste markers', () => {
expect(bracketPasteIfMultiline('line 1\nline 2')).toBe(`${START}line 1\nline 2${END}`)
})

it('wraps an attachment-formatted prompt (@path\\n\\ntext)', () => {
expect(bracketPasteIfMultiline('@/tmp/a.png\n\ndescribe this'))
.toBe(`${START}@/tmp/a.png\n\ndescribe this${END}`)
})

it('wraps a trailing newline (so it is not interpreted as a premature submit)', () => {
expect(bracketPasteIfMultiline('text\n')).toBe(`${START}text\n${END}`)
})

it('leaves an empty string untouched', () => {
expect(bracketPasteIfMultiline('')).toBe('')
})
})
Loading
Loading