mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
+1
-1
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user