feat: implement web terminal feature with xterm.js and Socket.IO proxy

- Add CLI-side terminal management via Bun.Terminal with TerminalManager
- Implement server-side Socket.IO proxy for terminal I/O between web and CLI
- Create web terminal UI component with xterm.js and support for resize/reconnect
- Add terminal route and navigation button in session chat
- Include comprehensive terminal implementation plan and architecture docs
This commit is contained in:
weishu
2025-12-25 21:57:18 +08:00
parent 0762f0772f
commit 18e6310451
22 changed files with 1763 additions and 4 deletions
+1 -1
View File
@@ -43,7 +43,7 @@
"update-homebrew-formula": "bun run scripts/update-homebrew-formula.ts",
"test": "bun run tools:unpack && vitest run",
"test:win": "vitest run",
"dev": "tsx src/index.ts",
"dev": "bun src/index.ts",
"dev:local-server": "tsx --env-file .env.dev-local-server src/index.ts",
"dev:integration-test-env": "tsx --env-file .env.integration-test src/index.ts",
"release-all": "bun run scripts/release-all.ts"
+50
View File
@@ -1,6 +1,7 @@
import { EventEmitter } from 'node:events'
import { randomUUID } from 'node:crypto'
import { io, type Socket } from 'socket.io-client'
import type { ZodType } from 'zod'
import { logger } from '@/ui/logger'
import { backoff } from '@/utils/time'
import { AsyncLock } from '@/utils/lock'
@@ -10,6 +11,13 @@ import type { AgentState, ClientToServerEvents, MessageContent, MessageMeta, Met
import { AgentStateSchema, MetadataSchema, UserMessageSchema } from './types'
import { RpcHandlerManager } from './rpc/RpcHandlerManager'
import { registerCommonHandlers } from '../modules/common/registerCommonHandlers'
import { TerminalManager } from '@/terminal/TerminalManager'
import {
TerminalClosePayloadSchema,
TerminalOpenPayloadSchema,
TerminalResizePayloadSchema,
TerminalWritePayloadSchema
} from '@/terminal/types'
export class ApiSessionClient extends EventEmitter {
private readonly token: string
@@ -22,6 +30,7 @@ export class ApiSessionClient extends EventEmitter {
private pendingMessages: UserMessage[] = []
private pendingMessageCallback: ((message: UserMessage) => void) | null = null
readonly rpcHandlerManager: RpcHandlerManager
private readonly terminalManager: TerminalManager
private agentStateLock = new AsyncLock()
private metadataLock = new AsyncLock()
@@ -58,6 +67,15 @@ export class ApiSessionClient extends EventEmitter {
autoConnect: false
})
this.terminalManager = new TerminalManager({
sessionId: this.sessionId,
getSessionPath: () => this.metadata?.path ?? null,
onReady: (payload) => this.socket.emit('terminal:ready', payload),
onOutput: (payload) => this.socket.emit('terminal:output', payload),
onExit: (payload) => this.socket.emit('terminal:exit', payload),
onError: (payload) => this.socket.emit('terminal:error', payload)
})
this.socket.on('connect', () => {
logger.debug('Socket connected successfully')
this.rpcHandlerManager.onSocketConnect(this.socket)
@@ -70,6 +88,7 @@ export class ApiSessionClient extends EventEmitter {
this.socket.on('disconnect', (reason) => {
logger.debug('[API] Socket disconnected:', reason)
this.rpcHandlerManager.onSocketDisconnect()
this.terminalManager.closeAll()
})
this.socket.on('connect_error', (error) => {
@@ -77,6 +96,36 @@ export class ApiSessionClient extends EventEmitter {
this.rpcHandlerManager.onSocketDisconnect()
})
const handleTerminalEvent = <T extends { sessionId: string }>(
schema: ZodType<T>,
handler: (payload: T) => void
) => (data: unknown) => {
const parsed = schema.safeParse(data)
if (!parsed.success) {
return
}
if (parsed.data.sessionId !== this.sessionId) {
return
}
handler(parsed.data)
}
this.socket.on('terminal:open', handleTerminalEvent(TerminalOpenPayloadSchema, (payload) => {
this.terminalManager.create(payload.terminalId, payload.cols, payload.rows)
}))
this.socket.on('terminal:write', handleTerminalEvent(TerminalWritePayloadSchema, (payload) => {
this.terminalManager.write(payload.terminalId, payload.data)
}))
this.socket.on('terminal:resize', handleTerminalEvent(TerminalResizePayloadSchema, (payload) => {
this.terminalManager.resize(payload.terminalId, payload.cols, payload.rows)
}))
this.socket.on('terminal:close', handleTerminalEvent(TerminalClosePayloadSchema, (payload) => {
this.terminalManager.close(payload.terminalId)
}))
this.socket.on('update', (data: Update) => {
try {
if (!data.body) return
@@ -458,6 +507,7 @@ export class ApiSessionClient extends EventEmitter {
close(): void {
this.rpcHandlerManager.onSocketDisconnect()
this.terminalManager.closeAll()
this.socket.disconnect()
}
}
+18
View File
@@ -1,6 +1,16 @@
import { z } from 'zod'
import { UsageSchema } from '@/claude/types'
import type { PermissionMode } from '@/claude/loop'
import type {
TerminalClosePayload,
TerminalExitPayload,
TerminalOpenPayload,
TerminalOutputPayload,
TerminalReadyPayload,
TerminalResizePayload,
TerminalWritePayload,
TerminalErrorPayload
} from '@/terminal/types'
export type Usage = z.infer<typeof UsageSchema>
@@ -290,6 +300,10 @@ export type MessageContent = z.infer<typeof MessageContentSchema>
export interface ServerToClientEvents {
update: (data: Update) => void
'rpc-request': (data: { method: string; params: string }, callback: (response: string) => void) => void
'terminal:open': (data: TerminalOpenPayload) => void
'terminal:write': (data: TerminalWritePayload) => void
'terminal:resize': (data: TerminalResizePayload) => void
'terminal:close': (data: TerminalClosePayload) => void
error: (data: { message: string }) => void
}
@@ -344,6 +358,10 @@ export interface ClientToServerEvents {
}) => void) => void
'rpc-register': (data: { method: string }) => void
'rpc-unregister': (data: { method: string }) => void
'terminal:ready': (data: TerminalReadyPayload) => void
'terminal:output': (data: TerminalOutputPayload) => void
'terminal:exit': (data: TerminalExitPayload) => void
'terminal:error': (data: TerminalErrorPayload) => void
ping: (callback: () => void) => void
'usage-report': (data: unknown) => void
}
+270
View File
@@ -0,0 +1,270 @@
import { logger } from '@/ui/logger'
import type {
TerminalErrorPayload,
TerminalExitPayload,
TerminalOutputPayload,
TerminalReadyPayload,
TerminalSession
} from './types'
type TerminalRuntime = TerminalSession & {
proc: Bun.Subprocess
terminal: Bun.Terminal
idleTimer: ReturnType<typeof setTimeout> | null
}
type TerminalManagerOptions = {
sessionId: string
getSessionPath: () => string | null
onReady: (payload: TerminalReadyPayload) => void
onOutput: (payload: TerminalOutputPayload) => void
onExit: (payload: TerminalExitPayload) => void
onError: (payload: TerminalErrorPayload) => void
idleTimeoutMs?: number
maxTerminals?: number
}
const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60_000
const DEFAULT_MAX_TERMINALS = 4
const SENSITIVE_ENV_KEYS = new Set([
'CLI_API_TOKEN',
'HAPI_SERVER_URL',
'HAPI_HTTP_MCP_URL',
'TELEGRAM_BOT_TOKEN',
'OPENAI_API_KEY',
'ANTHROPIC_API_KEY',
'GEMINI_API_KEY',
'GOOGLE_API_KEY'
])
function resolveEnvNumber(name: string, fallback: number): number {
const raw = process.env[name]
if (!raw) {
return fallback
}
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
}
function resolveShell(): string {
if (process.env.SHELL) {
return process.env.SHELL
}
if (process.platform === 'darwin') {
return '/bin/zsh'
}
return '/bin/bash'
}
function buildFilteredEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
for (const [key, value] of Object.entries(process.env)) {
if (!value) {
continue
}
if (SENSITIVE_ENV_KEYS.has(key)) {
continue
}
env[key] = value
}
return env
}
export class TerminalManager {
private readonly sessionId: string
private readonly getSessionPath: () => string | null
private readonly onReady: (payload: TerminalReadyPayload) => void
private readonly onOutput: (payload: TerminalOutputPayload) => void
private readonly onExit: (payload: TerminalExitPayload) => void
private readonly onError: (payload: TerminalErrorPayload) => void
private readonly idleTimeoutMs: number
private readonly maxTerminals: number
private readonly terminals: Map<string, TerminalRuntime> = new Map()
private readonly filteredEnv: NodeJS.ProcessEnv
constructor(options: TerminalManagerOptions) {
this.sessionId = options.sessionId
this.getSessionPath = options.getSessionPath
this.onReady = options.onReady
this.onOutput = options.onOutput
this.onExit = options.onExit
this.onError = options.onError
this.idleTimeoutMs = options.idleTimeoutMs ?? resolveEnvNumber('HAPI_TERMINAL_IDLE_TIMEOUT_MS', DEFAULT_IDLE_TIMEOUT_MS)
this.maxTerminals = options.maxTerminals ?? resolveEnvNumber('HAPI_TERMINAL_MAX_TERMINALS', DEFAULT_MAX_TERMINALS)
this.filteredEnv = buildFilteredEnv()
}
create(terminalId: string, cols: number, rows: number): void {
if (process.platform === 'win32') {
this.emitError(terminalId, 'Terminal is not supported on Windows.')
return
}
const existing = this.terminals.get(terminalId)
if (existing) {
existing.cols = cols
existing.rows = rows
existing.terminal.resize(cols, rows)
this.markActivity(existing)
this.onReady({ sessionId: this.sessionId, terminalId })
return
}
if (this.terminals.size >= this.maxTerminals) {
this.emitError(terminalId, `Too many terminals open (max ${this.maxTerminals}).`)
return
}
if (typeof Bun === 'undefined' || typeof Bun.spawn !== 'function') {
this.emitError(terminalId, 'Terminal is unavailable in this runtime.')
return
}
const sessionPath = this.getSessionPath() ?? process.cwd()
const shell = resolveShell()
const decoder = new TextDecoder()
try {
const proc = Bun.spawn([shell], {
cwd: sessionPath,
env: this.filteredEnv,
terminal: {
cols,
rows,
data: (terminal, data) => {
const text = decoder.decode(data, { stream: true })
if (text) {
this.onOutput({ sessionId: this.sessionId, terminalId, data: text })
}
const active = this.terminals.get(terminalId)
if (active) {
this.markActivity(active)
}
},
exit: (terminal, exitCode) => {
if (exitCode === 1) {
this.emitError(terminalId, 'Terminal stream closed unexpectedly.')
}
}
},
onExit: (subprocess, exitCode) => {
const signal = subprocess.signalCode ?? null
this.onExit({
sessionId: this.sessionId,
terminalId,
code: exitCode ?? null,
signal
})
this.cleanup(terminalId)
}
})
const terminal = proc.terminal
if (!terminal) {
try {
proc.kill()
} catch (error) {
logger.debug('[TERMINAL] Failed to kill process after missing terminal', { error })
}
this.emitError(terminalId, 'Failed to attach terminal.')
return
}
const runtime: TerminalRuntime = {
terminalId,
cols,
rows,
proc,
terminal,
idleTimer: null
}
this.terminals.set(terminalId, runtime)
this.markActivity(runtime)
this.onReady({ sessionId: this.sessionId, terminalId })
} catch (error) {
logger.debug('[TERMINAL] Failed to spawn terminal', { error })
this.emitError(terminalId, 'Failed to spawn terminal.')
}
}
write(terminalId: string, data: string): void {
const runtime = this.terminals.get(terminalId)
if (!runtime) {
this.emitError(terminalId, 'Terminal not found.')
return
}
runtime.terminal.write(data)
this.markActivity(runtime)
}
resize(terminalId: string, cols: number, rows: number): void {
const runtime = this.terminals.get(terminalId)
if (!runtime) {
return
}
runtime.cols = cols
runtime.rows = rows
runtime.terminal.resize(cols, rows)
this.markActivity(runtime)
}
close(terminalId: string): void {
this.cleanup(terminalId)
}
closeAll(): void {
for (const terminalId of this.terminals.keys()) {
this.cleanup(terminalId)
}
}
private markActivity(runtime: TerminalRuntime): void {
this.scheduleIdleTimer(runtime)
}
private scheduleIdleTimer(runtime: TerminalRuntime): void {
if (this.idleTimeoutMs <= 0) {
return
}
if (runtime.idleTimer) {
clearTimeout(runtime.idleTimer)
}
runtime.idleTimer = setTimeout(() => {
this.emitError(runtime.terminalId, 'Terminal closed due to inactivity.')
this.cleanup(runtime.terminalId)
}, this.idleTimeoutMs)
}
private cleanup(terminalId: string): void {
const runtime = this.terminals.get(terminalId)
if (!runtime) {
return
}
this.terminals.delete(terminalId)
if (runtime.idleTimer) {
clearTimeout(runtime.idleTimer)
}
if (!runtime.proc.killed && runtime.proc.exitCode === null) {
try {
runtime.proc.kill()
} catch (error) {
logger.debug('[TERMINAL] Failed to kill process', { error })
}
}
try {
runtime.terminal.close()
} catch (error) {
logger.debug('[TERMINAL] Failed to close terminal', { error })
}
}
private emitError(terminalId: string, message: string): void {
this.onError({ sessionId: this.sessionId, terminalId, message })
}
}
+72
View File
@@ -0,0 +1,72 @@
import { z } from 'zod'
export type TerminalSession = {
terminalId: string
cols: number
rows: number
}
export const TerminalOpenPayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
cols: z.number().int().positive(),
rows: z.number().int().positive()
})
export type TerminalOpenPayload = z.infer<typeof TerminalOpenPayloadSchema>
export const TerminalWritePayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
data: z.string()
})
export type TerminalWritePayload = z.infer<typeof TerminalWritePayloadSchema>
export const TerminalResizePayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
cols: z.number().int().positive(),
rows: z.number().int().positive()
})
export type TerminalResizePayload = z.infer<typeof TerminalResizePayloadSchema>
export const TerminalClosePayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1)
})
export type TerminalClosePayload = z.infer<typeof TerminalClosePayloadSchema>
export const TerminalReadyPayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1)
})
export type TerminalReadyPayload = z.infer<typeof TerminalReadyPayloadSchema>
export const TerminalOutputPayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
data: z.string()
})
export type TerminalOutputPayload = z.infer<typeof TerminalOutputPayloadSchema>
export const TerminalExitPayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
code: z.number().int().nullable(),
signal: z.string().nullable()
})
export type TerminalExitPayload = z.infer<typeof TerminalExitPayloadSchema>
export const TerminalErrorPayloadSchema = z.object({
sessionId: z.string().min(1),
terminalId: z.string().min(1),
message: z.string()
})
export type TerminalErrorPayload = z.infer<typeof TerminalErrorPayloadSchema>