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:
@@ -87,6 +87,9 @@
|
||||
"@tanstack/react-query": "^5.71.10",
|
||||
"@tanstack/react-query-devtools": "^5.71.10",
|
||||
"@tanstack/react-router": "^1.114.3",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"diff": "^7.0.0",
|
||||
@@ -96,6 +99,7 @@
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^3.20.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -669,6 +673,12 @@
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
|
||||
|
||||
"@xterm/addon-fit": ["@xterm/addon-fit@0.10.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ=="],
|
||||
|
||||
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.11.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q=="],
|
||||
|
||||
"@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
|
||||
|
||||
+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>
|
||||
+3
-1
@@ -16,7 +16,9 @@
|
||||
"typecheck:cli": "cd cli && bun run typecheck",
|
||||
"typecheck:server": "cd server && bun run typecheck",
|
||||
"typecheck:web": "cd web && bun run typecheck",
|
||||
"test": "cd cli && bun run test",
|
||||
"test": "bun run test:cli && bun run test:server",
|
||||
"test:cli": "cd cli && bun run test",
|
||||
"test:server": "cd server && bun run test",
|
||||
"clean-session": "bun run server/scripts/cleanup-sessions.ts",
|
||||
"release-all": "cd cli && bun run release-all"
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"scripts": {
|
||||
"start": "bun run src/index.ts",
|
||||
"dev": "bun --watch run src/index.ts",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "bun build src/index.ts --outdir dist --target bun",
|
||||
"generate:embedded-web-assets": "bun run scripts/generate-embedded-web-assets.ts"
|
||||
|
||||
@@ -84,6 +84,8 @@ async function main() {
|
||||
|
||||
const socketServer = createSocketServer({
|
||||
store,
|
||||
jwtSecret,
|
||||
getSession: (sessionId) => syncEngine?.getSession(sessionId) ?? store.getSession(sessionId),
|
||||
onWebappEvent: (event: SyncEvent) => syncEngine?.handleRealtimeEvent(event),
|
||||
onSessionAlive: (payload) => syncEngine?.handleSessionAlive(payload),
|
||||
onSessionEnd: (payload) => syncEngine?.handleSessionEnd(payload),
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Store } from '../../store'
|
||||
import { RpcRegistry } from '../rpcRegistry'
|
||||
import type { SyncEvent } from '../../sync/syncEngine'
|
||||
import { extractTodoWriteTodosFromMessageContent } from '../../sync/todos'
|
||||
import { TerminalRegistry } from '../terminalRegistry'
|
||||
|
||||
type SessionAlivePayload = {
|
||||
sid: string
|
||||
@@ -61,10 +62,35 @@ const machineUpdateStateSchema = z.object({
|
||||
daemonState: z.unknown().nullable()
|
||||
})
|
||||
|
||||
const terminalReadySchema = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
terminalId: z.string().min(1)
|
||||
})
|
||||
|
||||
const terminalOutputSchema = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
terminalId: z.string().min(1),
|
||||
data: z.string()
|
||||
})
|
||||
|
||||
const terminalExitSchema = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
terminalId: z.string().min(1),
|
||||
code: z.number().int().nullable(),
|
||||
signal: z.string().nullable()
|
||||
})
|
||||
|
||||
const terminalErrorSchema = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
terminalId: z.string().min(1),
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
export type CliHandlersDeps = {
|
||||
io: Server
|
||||
store: Store
|
||||
rpcRegistry: RpcRegistry
|
||||
terminalRegistry: TerminalRegistry
|
||||
onSessionAlive?: (payload: SessionAlivePayload) => void
|
||||
onSessionEnd?: (payload: SessionEndPayload) => void
|
||||
onMachineAlive?: (payload: MachineAlivePayload) => void
|
||||
@@ -72,7 +98,8 @@ export type CliHandlersDeps = {
|
||||
}
|
||||
|
||||
export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void {
|
||||
const { io, store, rpcRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent } = deps
|
||||
const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent } = deps
|
||||
const terminalNamespace = io.of('/terminal')
|
||||
|
||||
const auth = socket.handshake.auth as Record<string, unknown> | undefined
|
||||
const sessionId = typeof auth?.sessionId === 'string' ? auth.sessionId : null
|
||||
@@ -103,6 +130,14 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
rpcRegistry.unregisterAll(socket)
|
||||
const removed = terminalRegistry.removeByCliSocket(socket.id)
|
||||
for (const entry of removed) {
|
||||
const terminalSocket = terminalNamespace.sockets.get(entry.socketId)
|
||||
terminalSocket?.emit('terminal:error', {
|
||||
terminalId: entry.terminalId,
|
||||
message: 'CLI disconnected.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('message', (data: unknown) => {
|
||||
@@ -332,4 +367,65 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void
|
||||
socket.on('ping', (callback: () => void) => {
|
||||
callback()
|
||||
})
|
||||
|
||||
const forwardTerminalEvent = (event: string, payload: { sessionId: string; terminalId: string } & Record<string, unknown>) => {
|
||||
const entry = terminalRegistry.get(payload.terminalId)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
if (entry.cliSocketId !== socket.id) {
|
||||
return
|
||||
}
|
||||
if (payload.sessionId !== entry.sessionId) {
|
||||
return
|
||||
}
|
||||
const terminalSocket = terminalNamespace.sockets.get(entry.socketId)
|
||||
if (!terminalSocket) {
|
||||
return
|
||||
}
|
||||
terminalSocket.emit(event, payload)
|
||||
}
|
||||
|
||||
socket.on('terminal:ready', (data: unknown) => {
|
||||
const parsed = terminalReadySchema.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
return
|
||||
}
|
||||
terminalRegistry.markActivity(parsed.data.terminalId)
|
||||
forwardTerminalEvent('terminal:ready', parsed.data)
|
||||
})
|
||||
|
||||
socket.on('terminal:output', (data: unknown) => {
|
||||
const parsed = terminalOutputSchema.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
return
|
||||
}
|
||||
terminalRegistry.markActivity(parsed.data.terminalId)
|
||||
forwardTerminalEvent('terminal:output', parsed.data)
|
||||
})
|
||||
|
||||
socket.on('terminal:exit', (data: unknown) => {
|
||||
const parsed = terminalExitSchema.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
return
|
||||
}
|
||||
const entry = terminalRegistry.get(parsed.data.terminalId)
|
||||
if (!entry || entry.sessionId !== parsed.data.sessionId || entry.cliSocketId !== socket.id) {
|
||||
return
|
||||
}
|
||||
terminalRegistry.remove(parsed.data.terminalId)
|
||||
const terminalSocket = terminalNamespace.sockets.get(entry.socketId)
|
||||
if (!terminalSocket) {
|
||||
return
|
||||
}
|
||||
terminalSocket.emit('terminal:exit', parsed.data)
|
||||
})
|
||||
|
||||
socket.on('terminal:error', (data: unknown) => {
|
||||
const parsed = terminalErrorSchema.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
return
|
||||
}
|
||||
forwardTerminalEvent('terminal:error', parsed.data)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { Server, Socket } from 'socket.io'
|
||||
import { registerTerminalHandlers } from './terminal'
|
||||
import { TerminalRegistry } from '../terminalRegistry'
|
||||
|
||||
type EmittedEvent = {
|
||||
event: string
|
||||
data: unknown
|
||||
}
|
||||
|
||||
class FakeSocket {
|
||||
readonly id: string
|
||||
readonly data: Record<string, unknown> = {}
|
||||
readonly emitted: EmittedEvent[] = []
|
||||
private readonly handlers = new Map<string, (...args: unknown[]) => void>()
|
||||
|
||||
constructor(id: string) {
|
||||
this.id = id
|
||||
}
|
||||
|
||||
on(event: string, handler: (...args: unknown[]) => void): this {
|
||||
this.handlers.set(event, handler)
|
||||
return this
|
||||
}
|
||||
|
||||
emit(event: string, data: unknown): boolean {
|
||||
this.emitted.push({ event, data })
|
||||
return true
|
||||
}
|
||||
|
||||
trigger(event: string, data?: unknown): void {
|
||||
const handler = this.handlers.get(event)
|
||||
if (!handler) {
|
||||
return
|
||||
}
|
||||
if (typeof data === 'undefined') {
|
||||
handler()
|
||||
return
|
||||
}
|
||||
handler(data)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeNamespace {
|
||||
readonly sockets = new Map<string, FakeSocket>()
|
||||
readonly adapter = { rooms: new Map<string, Set<string>>() }
|
||||
}
|
||||
|
||||
class FakeServer {
|
||||
private readonly namespaces = new Map<string, FakeNamespace>()
|
||||
|
||||
of(name: string): FakeNamespace {
|
||||
const existing = this.namespaces.get(name)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const namespace = new FakeNamespace()
|
||||
this.namespaces.set(name, namespace)
|
||||
return namespace
|
||||
}
|
||||
}
|
||||
|
||||
type Harness = {
|
||||
io: FakeServer
|
||||
terminalSocket: FakeSocket
|
||||
cliNamespace: FakeNamespace
|
||||
terminalRegistry: TerminalRegistry
|
||||
}
|
||||
|
||||
function createHarness(options?: {
|
||||
sessionActive?: boolean
|
||||
maxTerminalsPerSocket?: number
|
||||
maxTerminalsPerSession?: number
|
||||
}): Harness {
|
||||
const io = new FakeServer()
|
||||
const terminalSocket = new FakeSocket('terminal-socket')
|
||||
const terminalRegistry = new TerminalRegistry({ idleTimeoutMs: 0 })
|
||||
const cliNamespace = io.of('/cli')
|
||||
|
||||
registerTerminalHandlers(terminalSocket as unknown as Socket, {
|
||||
io: io as unknown as Server,
|
||||
getSession: () => ({ active: options?.sessionActive ?? true }),
|
||||
terminalRegistry,
|
||||
maxTerminalsPerSocket: options?.maxTerminalsPerSocket ?? 4,
|
||||
maxTerminalsPerSession: options?.maxTerminalsPerSession ?? 4
|
||||
})
|
||||
|
||||
return { io, terminalSocket, cliNamespace, terminalRegistry }
|
||||
}
|
||||
|
||||
function connectCliSocket(cliNamespace: FakeNamespace, cliSocket: FakeSocket, sessionId: string): void {
|
||||
cliNamespace.sockets.set(cliSocket.id, cliSocket)
|
||||
const roomId = `session:${sessionId}`
|
||||
const room = cliNamespace.adapter.rooms.get(roomId) ?? new Set<string>()
|
||||
room.add(cliSocket.id)
|
||||
cliNamespace.adapter.rooms.set(roomId, room)
|
||||
}
|
||||
|
||||
function lastEmit(socket: FakeSocket, event: string): EmittedEvent | undefined {
|
||||
return [...socket.emitted].reverse().find((entry) => entry.event === event)
|
||||
}
|
||||
|
||||
describe('terminal socket handlers', () => {
|
||||
it('rejects terminal creation when session is inactive', () => {
|
||||
const { terminalSocket, terminalRegistry } = createHarness({ sessionActive: false })
|
||||
|
||||
terminalSocket.trigger('terminal:create', {
|
||||
sessionId: 'session-1',
|
||||
terminalId: 'terminal-1',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
const errorEvent = lastEmit(terminalSocket, 'terminal:error')
|
||||
expect(errorEvent).toBeDefined()
|
||||
expect(errorEvent?.data).toEqual({
|
||||
terminalId: 'terminal-1',
|
||||
message: 'Session is inactive or unavailable.'
|
||||
})
|
||||
expect(terminalRegistry.get('terminal-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens a terminal and forwards write/resize/close to the CLI socket', () => {
|
||||
const { terminalSocket, cliNamespace, terminalRegistry } = createHarness()
|
||||
const cliSocket = new FakeSocket('cli-socket-1')
|
||||
connectCliSocket(cliNamespace, cliSocket, 'session-1')
|
||||
|
||||
terminalSocket.trigger('terminal:create', {
|
||||
sessionId: 'session-1',
|
||||
terminalId: 'terminal-1',
|
||||
cols: 120,
|
||||
rows: 40
|
||||
})
|
||||
|
||||
const openEvent = lastEmit(cliSocket, 'terminal:open')
|
||||
expect(openEvent?.data).toEqual({
|
||||
sessionId: 'session-1',
|
||||
terminalId: 'terminal-1',
|
||||
cols: 120,
|
||||
rows: 40
|
||||
})
|
||||
expect(terminalRegistry.get('terminal-1')).not.toBeNull()
|
||||
|
||||
terminalSocket.trigger('terminal:write', {
|
||||
terminalId: 'terminal-1',
|
||||
data: 'ls\n'
|
||||
})
|
||||
const writeEvent = lastEmit(cliSocket, 'terminal:write')
|
||||
expect(writeEvent?.data).toEqual({
|
||||
sessionId: 'session-1',
|
||||
terminalId: 'terminal-1',
|
||||
data: 'ls\n'
|
||||
})
|
||||
|
||||
terminalSocket.trigger('terminal:resize', {
|
||||
terminalId: 'terminal-1',
|
||||
cols: 100,
|
||||
rows: 30
|
||||
})
|
||||
const resizeEvent = lastEmit(cliSocket, 'terminal:resize')
|
||||
expect(resizeEvent?.data).toEqual({
|
||||
sessionId: 'session-1',
|
||||
terminalId: 'terminal-1',
|
||||
cols: 100,
|
||||
rows: 30
|
||||
})
|
||||
|
||||
terminalSocket.trigger('terminal:close', {
|
||||
terminalId: 'terminal-1'
|
||||
})
|
||||
const closeEvent = lastEmit(cliSocket, 'terminal:close')
|
||||
expect(closeEvent?.data).toEqual({
|
||||
sessionId: 'session-1',
|
||||
terminalId: 'terminal-1'
|
||||
})
|
||||
expect(terminalRegistry.get('terminal-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('cleans up and notifies CLI on terminal socket disconnect', () => {
|
||||
const { terminalSocket, cliNamespace, terminalRegistry } = createHarness()
|
||||
const cliSocket = new FakeSocket('cli-socket-1')
|
||||
connectCliSocket(cliNamespace, cliSocket, 'session-1')
|
||||
|
||||
terminalSocket.trigger('terminal:create', {
|
||||
sessionId: 'session-1',
|
||||
terminalId: 'terminal-1',
|
||||
cols: 90,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
terminalSocket.trigger('disconnect')
|
||||
|
||||
const closeEvent = lastEmit(cliSocket, 'terminal:close')
|
||||
expect(closeEvent?.data).toEqual({
|
||||
sessionId: 'session-1',
|
||||
terminalId: 'terminal-1'
|
||||
})
|
||||
expect(terminalRegistry.get('terminal-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('enforces per-socket terminal limits', () => {
|
||||
const { terminalSocket, cliNamespace } = createHarness({ maxTerminalsPerSocket: 1 })
|
||||
const cliSocket = new FakeSocket('cli-socket-1')
|
||||
connectCliSocket(cliNamespace, cliSocket, 'session-1')
|
||||
|
||||
terminalSocket.trigger('terminal:create', {
|
||||
sessionId: 'session-1',
|
||||
terminalId: 'terminal-1',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
terminalSocket.trigger('terminal:create', {
|
||||
sessionId: 'session-1',
|
||||
terminalId: 'terminal-2',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
const errorEvent = lastEmit(terminalSocket, 'terminal:error')
|
||||
expect(errorEvent?.data).toEqual({
|
||||
terminalId: 'terminal-2',
|
||||
message: 'Too many terminals open (max 1).'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { Server, Socket } from 'socket.io'
|
||||
import { z } from 'zod'
|
||||
import type { TerminalRegistry, TerminalRegistryEntry } from '../terminalRegistry'
|
||||
|
||||
const terminalCreateSchema = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
terminalId: z.string().min(1),
|
||||
cols: z.number().int().positive(),
|
||||
rows: z.number().int().positive()
|
||||
})
|
||||
|
||||
const terminalWriteSchema = z.object({
|
||||
terminalId: z.string().min(1),
|
||||
data: z.string()
|
||||
})
|
||||
|
||||
const terminalResizeSchema = z.object({
|
||||
terminalId: z.string().min(1),
|
||||
cols: z.number().int().positive(),
|
||||
rows: z.number().int().positive()
|
||||
})
|
||||
|
||||
const terminalCloseSchema = z.object({
|
||||
terminalId: z.string().min(1)
|
||||
})
|
||||
|
||||
export type TerminalHandlersDeps = {
|
||||
io: Server
|
||||
getSession: (sessionId: string) => { active: boolean } | null
|
||||
terminalRegistry: TerminalRegistry
|
||||
maxTerminalsPerSocket: number
|
||||
maxTerminalsPerSession: number
|
||||
}
|
||||
|
||||
export function registerTerminalHandlers(socket: Socket, deps: TerminalHandlersDeps): void {
|
||||
const { io, getSession, terminalRegistry, maxTerminalsPerSocket, maxTerminalsPerSession } = deps
|
||||
const cliNamespace = io.of('/cli')
|
||||
|
||||
const emitTerminalError = (terminalId: string, message: string) => {
|
||||
socket.emit('terminal:error', { terminalId, message })
|
||||
}
|
||||
|
||||
const resolveEntryForSocket = (terminalId: string): TerminalRegistryEntry | null => {
|
||||
const entry = terminalRegistry.get(terminalId)
|
||||
if (!entry || entry.socketId !== socket.id) {
|
||||
return null
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
const resolveCliSocket = (entry: TerminalRegistryEntry, reportError: boolean): Socket | null => {
|
||||
const cliSocket = cliNamespace.sockets.get(entry.cliSocketId)
|
||||
if (!cliSocket) {
|
||||
terminalRegistry.remove(entry.terminalId)
|
||||
if (reportError) {
|
||||
emitTerminalError(entry.terminalId, 'CLI disconnected.')
|
||||
}
|
||||
return null
|
||||
}
|
||||
return cliSocket
|
||||
}
|
||||
|
||||
const emitCloseToCli = (entry: TerminalRegistryEntry): void => {
|
||||
const cliSocket = cliNamespace.sockets.get(entry.cliSocketId)
|
||||
if (!cliSocket) {
|
||||
return
|
||||
}
|
||||
cliSocket.emit('terminal:close', {
|
||||
sessionId: entry.sessionId,
|
||||
terminalId: entry.terminalId
|
||||
})
|
||||
}
|
||||
|
||||
const pickCliSocketId = (sessionId: string): string | null => {
|
||||
const room = cliNamespace.adapter.rooms.get(`session:${sessionId}`)
|
||||
if (!room || room.size === 0) {
|
||||
return null
|
||||
}
|
||||
for (const socketId of room) {
|
||||
if (cliNamespace.sockets.has(socketId)) {
|
||||
return socketId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
socket.on('terminal:create', (data: unknown) => {
|
||||
const parsed = terminalCreateSchema.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
return
|
||||
}
|
||||
|
||||
const { sessionId, terminalId, cols, rows } = parsed.data
|
||||
const session = getSession(sessionId)
|
||||
if (!session || !session.active) {
|
||||
emitTerminalError(terminalId, 'Session is inactive or unavailable.')
|
||||
return
|
||||
}
|
||||
|
||||
if (terminalRegistry.countForSocket(socket.id) >= maxTerminalsPerSocket) {
|
||||
emitTerminalError(terminalId, `Too many terminals open (max ${maxTerminalsPerSocket}).`)
|
||||
return
|
||||
}
|
||||
|
||||
if (terminalRegistry.countForSession(sessionId) >= maxTerminalsPerSession) {
|
||||
emitTerminalError(terminalId, `Too many terminals open for this session (max ${maxTerminalsPerSession}).`)
|
||||
return
|
||||
}
|
||||
|
||||
const cliSocketId = pickCliSocketId(sessionId)
|
||||
if (!cliSocketId) {
|
||||
emitTerminalError(terminalId, 'CLI is not connected for this session.')
|
||||
return
|
||||
}
|
||||
|
||||
const entry = terminalRegistry.register(terminalId, sessionId, socket.id, cliSocketId)
|
||||
if (!entry) {
|
||||
emitTerminalError(terminalId, 'Terminal ID is already in use.')
|
||||
return
|
||||
}
|
||||
|
||||
const cliSocket = cliNamespace.sockets.get(cliSocketId)
|
||||
if (!cliSocket) {
|
||||
terminalRegistry.remove(terminalId)
|
||||
emitTerminalError(terminalId, 'CLI is not connected for this session.')
|
||||
return
|
||||
}
|
||||
|
||||
cliSocket.emit('terminal:open', {
|
||||
sessionId,
|
||||
terminalId,
|
||||
cols,
|
||||
rows
|
||||
})
|
||||
terminalRegistry.markActivity(terminalId)
|
||||
})
|
||||
|
||||
socket.on('terminal:write', (data: unknown) => {
|
||||
const parsed = terminalWriteSchema.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
return
|
||||
}
|
||||
|
||||
const { terminalId, data: payload } = parsed.data
|
||||
const entry = resolveEntryForSocket(terminalId)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
|
||||
const cliSocket = resolveCliSocket(entry, true)
|
||||
if (!cliSocket) {
|
||||
return
|
||||
}
|
||||
cliSocket.emit('terminal:write', {
|
||||
sessionId: entry.sessionId,
|
||||
terminalId,
|
||||
data: payload
|
||||
})
|
||||
terminalRegistry.markActivity(terminalId)
|
||||
})
|
||||
|
||||
socket.on('terminal:resize', (data: unknown) => {
|
||||
const parsed = terminalResizeSchema.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
return
|
||||
}
|
||||
|
||||
const { terminalId, cols, rows } = parsed.data
|
||||
const entry = resolveEntryForSocket(terminalId)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
|
||||
const cliSocket = resolveCliSocket(entry, true)
|
||||
if (!cliSocket) {
|
||||
return
|
||||
}
|
||||
cliSocket.emit('terminal:resize', {
|
||||
sessionId: entry.sessionId,
|
||||
terminalId,
|
||||
cols,
|
||||
rows
|
||||
})
|
||||
terminalRegistry.markActivity(terminalId)
|
||||
})
|
||||
|
||||
socket.on('terminal:close', (data: unknown) => {
|
||||
const parsed = terminalCloseSchema.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
return
|
||||
}
|
||||
|
||||
const { terminalId } = parsed.data
|
||||
const entry = resolveEntryForSocket(terminalId)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
|
||||
terminalRegistry.remove(terminalId)
|
||||
emitCloseToCli(entry)
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
const removed = terminalRegistry.removeBySocket(socket.id)
|
||||
for (const entry of removed) {
|
||||
emitCloseToCli(entry)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,35 @@
|
||||
import { Server as Engine } from '@socket.io/bun-engine'
|
||||
import { Server } from 'socket.io'
|
||||
import { jwtVerify } from 'jose'
|
||||
import { z } from 'zod'
|
||||
import type { Store } from '../store'
|
||||
import { configuration } from '../configuration'
|
||||
import { registerCliHandlers } from './handlers/cli'
|
||||
import { registerTerminalHandlers } from './handlers/terminal'
|
||||
import { RpcRegistry } from './rpcRegistry'
|
||||
import type { SyncEvent } from '../sync/syncEngine'
|
||||
import { TerminalRegistry } from './terminalRegistry'
|
||||
|
||||
const jwtPayloadSchema = z.object({
|
||||
uid: z.number()
|
||||
})
|
||||
|
||||
const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60_000
|
||||
const DEFAULT_MAX_TERMINALS = 4
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export type SocketServerDeps = {
|
||||
store: Store
|
||||
jwtSecret: Uint8Array
|
||||
getSession?: (sessionId: string) => { active: boolean } | null
|
||||
onWebappEvent?: (event: SyncEvent) => void
|
||||
onSessionAlive?: (payload: { sid: string; time: number; thinking?: boolean; mode?: 'local' | 'remote' }) => void
|
||||
onSessionEnd?: (payload: { sid: string; time: number }) => void
|
||||
@@ -46,8 +68,28 @@ export function createSocketServer(deps: SocketServerDeps): {
|
||||
io.bind(engine)
|
||||
|
||||
const rpcRegistry = new RpcRegistry()
|
||||
|
||||
const idleTimeoutMs = resolveEnvNumber('HAPI_TERMINAL_IDLE_TIMEOUT_MS', DEFAULT_IDLE_TIMEOUT_MS)
|
||||
const maxTerminals = resolveEnvNumber('HAPI_TERMINAL_MAX_TERMINALS', DEFAULT_MAX_TERMINALS)
|
||||
const maxTerminalsPerSocket = maxTerminals
|
||||
const maxTerminalsPerSession = maxTerminals
|
||||
const cliNs = io.of('/cli')
|
||||
const terminalNs = io.of('/terminal')
|
||||
const terminalRegistry = new TerminalRegistry({
|
||||
idleTimeoutMs,
|
||||
onIdle: (entry) => {
|
||||
const terminalSocket = terminalNs.sockets.get(entry.socketId)
|
||||
terminalSocket?.emit('terminal:error', {
|
||||
terminalId: entry.terminalId,
|
||||
message: 'Terminal closed due to inactivity.'
|
||||
})
|
||||
const cliSocket = cliNs.sockets.get(entry.cliSocketId)
|
||||
cliSocket?.emit('terminal:close', {
|
||||
sessionId: entry.sessionId,
|
||||
terminalId: entry.terminalId
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
cliNs.use((socket, next) => {
|
||||
const auth = socket.handshake.auth as Record<string, unknown> | undefined
|
||||
const token = typeof auth?.token === 'string' ? auth.token : null
|
||||
@@ -60,11 +102,40 @@ export function createSocketServer(deps: SocketServerDeps): {
|
||||
io,
|
||||
store: deps.store,
|
||||
rpcRegistry,
|
||||
terminalRegistry,
|
||||
onSessionAlive: deps.onSessionAlive,
|
||||
onSessionEnd: deps.onSessionEnd,
|
||||
onMachineAlive: deps.onMachineAlive,
|
||||
onWebappEvent: deps.onWebappEvent
|
||||
}))
|
||||
|
||||
terminalNs.use(async (socket, next) => {
|
||||
const auth = socket.handshake.auth as Record<string, unknown> | undefined
|
||||
const token = typeof auth?.token === 'string' ? auth.token : null
|
||||
if (!token) {
|
||||
return next(new Error('Missing token'))
|
||||
}
|
||||
|
||||
try {
|
||||
const verified = await jwtVerify(token, deps.jwtSecret, { algorithms: ['HS256'] })
|
||||
const parsed = jwtPayloadSchema.safeParse(verified.payload)
|
||||
if (!parsed.success) {
|
||||
return next(new Error('Invalid token payload'))
|
||||
}
|
||||
socket.data.userId = parsed.data.uid
|
||||
next()
|
||||
return
|
||||
} catch {
|
||||
return next(new Error('Invalid token'))
|
||||
}
|
||||
})
|
||||
terminalNs.on('connection', (socket) => registerTerminalHandlers(socket, {
|
||||
io,
|
||||
getSession: (sessionId) => deps.getSession?.(sessionId) ?? deps.store.getSession(sessionId),
|
||||
terminalRegistry,
|
||||
maxTerminalsPerSocket,
|
||||
maxTerminalsPerSession
|
||||
}))
|
||||
|
||||
return { io, engine, rpcRegistry }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
export type TerminalRegistryEntry = {
|
||||
terminalId: string
|
||||
sessionId: string
|
||||
socketId: string
|
||||
cliSocketId: string
|
||||
idleTimer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
type TerminalRegistryOptions = {
|
||||
idleTimeoutMs: number
|
||||
onIdle?: (entry: TerminalRegistryEntry) => void
|
||||
}
|
||||
|
||||
export class TerminalRegistry {
|
||||
private readonly terminals = new Map<string, TerminalRegistryEntry>()
|
||||
private readonly terminalsBySocket = new Map<string, Set<string>>()
|
||||
private readonly terminalsBySession = new Map<string, Set<string>>()
|
||||
private readonly terminalsByCliSocket = new Map<string, Set<string>>()
|
||||
private readonly idleTimeoutMs: number
|
||||
private readonly onIdle?: (entry: TerminalRegistryEntry) => void
|
||||
|
||||
constructor(options: TerminalRegistryOptions) {
|
||||
this.idleTimeoutMs = options.idleTimeoutMs
|
||||
this.onIdle = options.onIdle
|
||||
}
|
||||
|
||||
register(terminalId: string, sessionId: string, socketId: string, cliSocketId: string): TerminalRegistryEntry | null {
|
||||
if (this.terminals.has(terminalId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entry: TerminalRegistryEntry = {
|
||||
terminalId,
|
||||
sessionId,
|
||||
socketId,
|
||||
cliSocketId,
|
||||
idleTimer: null
|
||||
}
|
||||
|
||||
this.terminals.set(terminalId, entry)
|
||||
this.addToIndex(this.terminalsBySocket, socketId, terminalId)
|
||||
this.addToIndex(this.terminalsBySession, sessionId, terminalId)
|
||||
this.addToIndex(this.terminalsByCliSocket, cliSocketId, terminalId)
|
||||
this.scheduleIdle(entry)
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
markActivity(terminalId: string): void {
|
||||
const entry = this.terminals.get(terminalId)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
this.scheduleIdle(entry)
|
||||
}
|
||||
|
||||
get(terminalId: string): TerminalRegistryEntry | null {
|
||||
return this.terminals.get(terminalId) ?? null
|
||||
}
|
||||
|
||||
remove(terminalId: string): TerminalRegistryEntry | null {
|
||||
const entry = this.terminals.get(terminalId)
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
this.terminals.delete(terminalId)
|
||||
this.removeFromIndex(this.terminalsBySocket, entry.socketId, terminalId)
|
||||
this.removeFromIndex(this.terminalsBySession, entry.sessionId, terminalId)
|
||||
this.removeFromIndex(this.terminalsByCliSocket, entry.cliSocketId, terminalId)
|
||||
if (entry.idleTimer) {
|
||||
clearTimeout(entry.idleTimer)
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
removeBySocket(socketId: string): TerminalRegistryEntry[] {
|
||||
const ids = this.terminalsBySocket.get(socketId)
|
||||
if (!ids || ids.size === 0) {
|
||||
return []
|
||||
}
|
||||
return Array.from(ids).map((terminalId) => this.remove(terminalId)).filter(Boolean) as TerminalRegistryEntry[]
|
||||
}
|
||||
|
||||
removeByCliSocket(socketId: string): TerminalRegistryEntry[] {
|
||||
const ids = this.terminalsByCliSocket.get(socketId)
|
||||
if (!ids || ids.size === 0) {
|
||||
return []
|
||||
}
|
||||
return Array.from(ids).map((terminalId) => this.remove(terminalId)).filter(Boolean) as TerminalRegistryEntry[]
|
||||
}
|
||||
|
||||
countForSocket(socketId: string): number {
|
||||
return this.terminalsBySocket.get(socketId)?.size ?? 0
|
||||
}
|
||||
|
||||
countForSession(sessionId: string): number {
|
||||
return this.terminalsBySession.get(sessionId)?.size ?? 0
|
||||
}
|
||||
|
||||
private scheduleIdle(entry: TerminalRegistryEntry): void {
|
||||
if (this.idleTimeoutMs <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.idleTimer) {
|
||||
clearTimeout(entry.idleTimer)
|
||||
}
|
||||
|
||||
entry.idleTimer = setTimeout(() => {
|
||||
const current = this.terminals.get(entry.terminalId)
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
this.onIdle?.(current)
|
||||
this.remove(entry.terminalId)
|
||||
}, this.idleTimeoutMs)
|
||||
}
|
||||
|
||||
private addToIndex(index: Map<string, Set<string>>, key: string, terminalId: string): void {
|
||||
const set = index.get(key)
|
||||
if (set) {
|
||||
set.add(terminalId)
|
||||
} else {
|
||||
index.set(key, new Set([terminalId]))
|
||||
}
|
||||
}
|
||||
|
||||
private removeFromIndex(index: Map<string, Set<string>>, key: string, terminalId: string): void {
|
||||
const set = index.get(key)
|
||||
if (!set) {
|
||||
return
|
||||
}
|
||||
set.delete(terminalId)
|
||||
if (set.size === 0) {
|
||||
index.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@
|
||||
"@assistant-ui/react-markdown": "^0.11.8",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@shikijs/langs": "^3.20.0",
|
||||
"@shikijs/themes": "^3.20.0",
|
||||
"@tanstack/react-query": "^5.71.10",
|
||||
@@ -28,6 +31,7 @@
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^3.20.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^2.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -38,6 +38,26 @@ function SwitchToRemoteIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
function TerminalIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" ry="2" />
|
||||
<polyline points="7 9 10 12 7 15" />
|
||||
<line x1="12" y1="15" x2="17" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function AbortIcon(props: { spinning: boolean }) {
|
||||
if (props.spinning) {
|
||||
return (
|
||||
@@ -94,6 +114,9 @@ export function ComposerButtons(props: {
|
||||
controlsDisabled: boolean
|
||||
showSettingsButton: boolean
|
||||
onSettingsToggle: () => void
|
||||
showTerminalButton: boolean
|
||||
terminalDisabled: boolean
|
||||
onTerminal: () => void
|
||||
showAbortButton: boolean
|
||||
abortDisabled: boolean
|
||||
isAborting: boolean
|
||||
@@ -119,6 +142,19 @@ export function ComposerButtons(props: {
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{props.showTerminalButton ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Terminal"
|
||||
title="Terminal"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 transition-colors hover:bg-[var(--app-bg)] hover:text-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={props.onTerminal}
|
||||
disabled={props.terminalDisabled}
|
||||
>
|
||||
<TerminalIcon />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{props.showAbortButton ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -55,6 +55,7 @@ export function HappyComposer(props: {
|
||||
onPermissionModeChange?: (mode: PermissionMode) => void
|
||||
onModelModeChange?: (mode: ModelMode) => void
|
||||
onSwitchToRemote?: () => void
|
||||
onTerminal?: () => void
|
||||
autocompletePrefixes?: string[]
|
||||
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
|
||||
}) {
|
||||
@@ -70,6 +71,7 @@ export function HappyComposer(props: {
|
||||
onPermissionModeChange,
|
||||
onModelModeChange,
|
||||
onSwitchToRemote,
|
||||
onTerminal,
|
||||
autocompletePrefixes = ['@', '/'],
|
||||
autocompleteSuggestions = defaultSuggestionHandler
|
||||
} = props
|
||||
@@ -174,6 +176,7 @@ export function HappyComposer(props: {
|
||||
const abortDisabled = controlsDisabled || isAborting || !threadIsRunning
|
||||
const switchDisabled = controlsDisabled || isSwitching || !controlledByUser
|
||||
const showSwitchButton = Boolean(controlledByUser && onSwitchToRemote)
|
||||
const showTerminalButton = Boolean(onTerminal)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAborting) return
|
||||
@@ -477,6 +480,9 @@ export function HappyComposer(props: {
|
||||
controlsDisabled={controlsDisabled}
|
||||
showSettingsButton={showSettingsButton}
|
||||
onSettingsToggle={handleSettingsToggle}
|
||||
showTerminalButton={showTerminalButton}
|
||||
terminalDisabled={controlsDisabled}
|
||||
onTerminal={onTerminal ?? (() => {})}
|
||||
showAbortButton={showAbortButton}
|
||||
abortDisabled={abortDisabled}
|
||||
isAborting={isAborting}
|
||||
|
||||
@@ -120,6 +120,13 @@ export function SessionChat(props: {
|
||||
})
|
||||
}, [navigate, props.session.id])
|
||||
|
||||
const handleViewTerminal = useCallback(() => {
|
||||
navigate({
|
||||
to: '/sessions/$sessionId/terminal',
|
||||
params: { sessionId: props.session.id }
|
||||
})
|
||||
}, [navigate, props.session.id])
|
||||
|
||||
const runtime = useHappyRuntime({
|
||||
session: props.session,
|
||||
blocks: reconciled.blocks,
|
||||
@@ -176,6 +183,7 @@ export function SessionChat(props: {
|
||||
onPermissionModeChange={handlePermissionModeChange}
|
||||
onModelModeChange={handleModelModeChange}
|
||||
onSwitchToRemote={handleSwitchToRemote}
|
||||
onTerminal={props.session.active ? handleViewTerminal : undefined}
|
||||
/>
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
function resolveThemeColors(): { background: string; foreground: string; selectionBackground: string } {
|
||||
const styles = getComputedStyle(document.documentElement)
|
||||
const background = styles.getPropertyValue('--app-bg').trim() || '#000000'
|
||||
const foreground = styles.getPropertyValue('--app-fg').trim() || '#ffffff'
|
||||
const selectionBackground = styles.getPropertyValue('--app-subtle-bg').trim() || 'rgba(255, 255, 255, 0.2)'
|
||||
return { background, foreground, selectionBackground }
|
||||
}
|
||||
|
||||
export function TerminalView(props: {
|
||||
onMount?: (terminal: Terminal) => void
|
||||
onResize?: (cols: number, rows: number) => void
|
||||
className?: string
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const onMountRef = useRef(props.onMount)
|
||||
const onResizeRef = useRef(props.onResize)
|
||||
|
||||
useEffect(() => {
|
||||
onMountRef.current = props.onMount
|
||||
}, [props.onMount])
|
||||
|
||||
useEffect(() => {
|
||||
onResizeRef.current = props.onResize
|
||||
}, [props.onResize])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
const { background, foreground, selectionBackground } = resolveThemeColors()
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSize: 13,
|
||||
theme: {
|
||||
background,
|
||||
foreground,
|
||||
cursor: foreground,
|
||||
selectionBackground
|
||||
},
|
||||
convertEol: true
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
const webLinksAddon = new WebLinksAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.loadAddon(webLinksAddon)
|
||||
terminal.open(container)
|
||||
|
||||
const resizeTerminal = () => {
|
||||
fitAddon.fit()
|
||||
onResizeRef.current?.(terminal.cols, terminal.rows)
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
requestAnimationFrame(resizeTerminal)
|
||||
})
|
||||
observer.observe(container)
|
||||
|
||||
requestAnimationFrame(resizeTerminal)
|
||||
onMountRef.current?.(terminal)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
terminal.dispose()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`h-full w-full ${props.className ?? ''}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
|
||||
type TerminalConnectionState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'connecting' }
|
||||
| { status: 'connected' }
|
||||
| { status: 'error'; error: string }
|
||||
|
||||
type UseTerminalSocketOptions = {
|
||||
token: string
|
||||
sessionId: string
|
||||
terminalId: string
|
||||
}
|
||||
|
||||
type TerminalReadyPayload = {
|
||||
terminalId: string
|
||||
}
|
||||
|
||||
type TerminalOutputPayload = {
|
||||
terminalId: string
|
||||
data: string
|
||||
}
|
||||
|
||||
type TerminalExitPayload = {
|
||||
terminalId: string
|
||||
code: number | null
|
||||
signal: string | null
|
||||
}
|
||||
|
||||
type TerminalErrorPayload = {
|
||||
terminalId: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export function useTerminalSocket(options: UseTerminalSocketOptions): {
|
||||
state: TerminalConnectionState
|
||||
connect: (cols: number, rows: number) => void
|
||||
write: (data: string) => void
|
||||
resize: (cols: number, rows: number) => void
|
||||
disconnect: () => void
|
||||
onOutput: (handler: (data: string) => void) => void
|
||||
onExit: (handler: (code: number | null, signal: string | null) => void) => void
|
||||
} {
|
||||
const [state, setState] = useState<TerminalConnectionState>({ status: 'idle' })
|
||||
const socketRef = useRef<Socket | null>(null)
|
||||
const outputHandlerRef = useRef<(data: string) => void>(() => {})
|
||||
const exitHandlerRef = useRef<(code: number | null, signal: string | null) => void>(() => {})
|
||||
const sessionIdRef = useRef(options.sessionId)
|
||||
const terminalIdRef = useRef(options.terminalId)
|
||||
const tokenRef = useRef(options.token)
|
||||
const lastSizeRef = useRef<{ cols: number; rows: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
sessionIdRef.current = options.sessionId
|
||||
terminalIdRef.current = options.terminalId
|
||||
}, [options.sessionId, options.terminalId])
|
||||
|
||||
useEffect(() => {
|
||||
tokenRef.current = options.token
|
||||
const socket = socketRef.current
|
||||
if (!socket) {
|
||||
return
|
||||
}
|
||||
if (!options.token) {
|
||||
if (socket.connected) {
|
||||
socket.disconnect()
|
||||
}
|
||||
return
|
||||
}
|
||||
socket.auth = { token: options.token }
|
||||
if (socket.connected) {
|
||||
socket.disconnect()
|
||||
socket.connect()
|
||||
}
|
||||
}, [options.token])
|
||||
|
||||
const isCurrentTerminal = useCallback((terminalId: string) => terminalId === terminalIdRef.current, [])
|
||||
|
||||
const emitCreate = useCallback((socket: Socket, size: { cols: number; rows: number }) => {
|
||||
socket.emit('terminal:create', {
|
||||
sessionId: sessionIdRef.current,
|
||||
terminalId: terminalIdRef.current,
|
||||
cols: size.cols,
|
||||
rows: size.rows
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setErrorState = useCallback((message: string) => {
|
||||
setState({ status: 'error', error: message })
|
||||
}, [])
|
||||
|
||||
const connect = useCallback((cols: number, rows: number) => {
|
||||
lastSizeRef.current = { cols, rows }
|
||||
const token = tokenRef.current
|
||||
const sessionId = sessionIdRef.current
|
||||
const terminalId = terminalIdRef.current
|
||||
|
||||
if (!token || !sessionId || !terminalId) {
|
||||
setErrorState('Missing terminal credentials.')
|
||||
return
|
||||
}
|
||||
|
||||
if (socketRef.current) {
|
||||
const socket = socketRef.current
|
||||
socket.auth = { token }
|
||||
if (socket.connected) {
|
||||
emitCreate(socket, { cols, rows })
|
||||
} else {
|
||||
socket.connect()
|
||||
}
|
||||
setState({ status: 'connecting' })
|
||||
return
|
||||
}
|
||||
|
||||
const socket = io('/terminal', {
|
||||
auth: { token },
|
||||
path: '/socket.io/',
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
transports: ['polling', 'websocket'],
|
||||
autoConnect: false
|
||||
})
|
||||
|
||||
socketRef.current = socket
|
||||
setState({ status: 'connecting' })
|
||||
|
||||
socket.on('connect', () => {
|
||||
const size = lastSizeRef.current ?? { cols, rows }
|
||||
setState({ status: 'connecting' })
|
||||
emitCreate(socket, size)
|
||||
})
|
||||
|
||||
socket.on('terminal:ready', (payload: TerminalReadyPayload) => {
|
||||
if (!isCurrentTerminal(payload.terminalId)) {
|
||||
return
|
||||
}
|
||||
setState({ status: 'connected' })
|
||||
})
|
||||
|
||||
socket.on('terminal:output', (payload: TerminalOutputPayload) => {
|
||||
if (!isCurrentTerminal(payload.terminalId)) {
|
||||
return
|
||||
}
|
||||
outputHandlerRef.current(payload.data)
|
||||
})
|
||||
|
||||
socket.on('terminal:exit', (payload: TerminalExitPayload) => {
|
||||
if (!isCurrentTerminal(payload.terminalId)) {
|
||||
return
|
||||
}
|
||||
exitHandlerRef.current(payload.code, payload.signal)
|
||||
setErrorState('Terminal exited.')
|
||||
})
|
||||
|
||||
socket.on('terminal:error', (payload: TerminalErrorPayload) => {
|
||||
if (!isCurrentTerminal(payload.terminalId)) {
|
||||
return
|
||||
}
|
||||
setErrorState(payload.message)
|
||||
})
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
const message = error instanceof Error ? error.message : 'Connection error'
|
||||
setErrorState(message)
|
||||
})
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
if (reason === 'io client disconnect') {
|
||||
setState({ status: 'idle' })
|
||||
return
|
||||
}
|
||||
setErrorState(`Disconnected: ${reason}`)
|
||||
})
|
||||
|
||||
socket.connect()
|
||||
}, [emitCreate, setErrorState, isCurrentTerminal])
|
||||
|
||||
const write = useCallback((data: string) => {
|
||||
const socket = socketRef.current
|
||||
if (!socket || !socket.connected) {
|
||||
return
|
||||
}
|
||||
socket.emit('terminal:write', { terminalId: terminalIdRef.current, data })
|
||||
}, [])
|
||||
|
||||
const resize = useCallback((cols: number, rows: number) => {
|
||||
lastSizeRef.current = { cols, rows }
|
||||
const socket = socketRef.current
|
||||
if (!socket || !socket.connected) {
|
||||
return
|
||||
}
|
||||
socket.emit('terminal:resize', { terminalId: terminalIdRef.current, cols, rows })
|
||||
}, [])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
const socket = socketRef.current
|
||||
if (!socket) {
|
||||
return
|
||||
}
|
||||
socket.removeAllListeners()
|
||||
socket.disconnect()
|
||||
socketRef.current = null
|
||||
setState({ status: 'idle' })
|
||||
}, [])
|
||||
|
||||
const onOutput = useCallback((handler: (data: string) => void) => {
|
||||
outputHandlerRef.current = handler
|
||||
}, [])
|
||||
|
||||
const onExit = useCallback((handler: (code: number | null, signal: string | null) => void) => {
|
||||
exitHandlerRef.current = handler
|
||||
}, [])
|
||||
|
||||
return {
|
||||
state,
|
||||
connect,
|
||||
write,
|
||||
resize,
|
||||
disconnect,
|
||||
onOutput,
|
||||
onExit
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { useSendMessage } from '@/hooks/mutations/useSendMessage'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
import FilesPage from '@/routes/sessions/files'
|
||||
import FilePage from '@/routes/sessions/file'
|
||||
import TerminalPage from '@/routes/sessions/terminal'
|
||||
|
||||
function BackIcon(props: { className?: string }) {
|
||||
return (
|
||||
@@ -251,6 +252,12 @@ const sessionFilesRoute = createRoute({
|
||||
component: FilesPage,
|
||||
})
|
||||
|
||||
const sessionTerminalRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/sessions/$sessionId/terminal',
|
||||
component: TerminalPage,
|
||||
})
|
||||
|
||||
type SessionFileSearch = {
|
||||
path: string
|
||||
staged?: boolean
|
||||
@@ -282,6 +289,7 @@ export const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
sessionsRoute,
|
||||
sessionRoute,
|
||||
sessionTerminalRoute,
|
||||
sessionFilesRoute,
|
||||
sessionFileRoute,
|
||||
newSessionRoute,
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { useAppContext } from '@/lib/app-context'
|
||||
import { useAppGoBack } from '@/hooks/useAppGoBack'
|
||||
import { useSession } from '@/hooks/queries/useSession'
|
||||
import { useTerminalSocket } from '@/hooks/useTerminalSocket'
|
||||
import { TerminalView } from '@/components/Terminal/TerminalView'
|
||||
import { LoadingState } from '@/components/LoadingState'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
function BackIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionBadge(props: { status: 'idle' | 'connecting' | 'connected' | 'error' }) {
|
||||
switch (props.status) {
|
||||
case 'connected':
|
||||
return <Badge variant="success">Connected</Badge>
|
||||
case 'connecting':
|
||||
return <Badge variant="warning">Connecting</Badge>
|
||||
case 'error':
|
||||
return <Badge variant="destructive">Error</Badge>
|
||||
default:
|
||||
return <Badge variant="default">Idle</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
export default function TerminalPage() {
|
||||
const { sessionId } = useParams({ from: '/sessions/$sessionId/terminal' })
|
||||
const { api, token } = useAppContext()
|
||||
const goBack = useAppGoBack()
|
||||
const { session } = useSession(api, sessionId)
|
||||
const terminalId = useMemo(() => {
|
||||
if (typeof crypto?.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}, [sessionId])
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const inputDisposableRef = useRef<{ dispose: () => void } | null>(null)
|
||||
const connectOnceRef = useRef(false)
|
||||
const lastSizeRef = useRef<{ cols: number; rows: number } | null>(null)
|
||||
const [exitInfo, setExitInfo] = useState<{ code: number | null; signal: string | null } | null>(null)
|
||||
|
||||
const {
|
||||
state: terminalState,
|
||||
connect,
|
||||
write,
|
||||
resize,
|
||||
disconnect,
|
||||
onOutput,
|
||||
onExit
|
||||
} = useTerminalSocket({
|
||||
token,
|
||||
sessionId,
|
||||
terminalId
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onOutput((data) => {
|
||||
terminalRef.current?.write(data)
|
||||
})
|
||||
}, [onOutput])
|
||||
|
||||
useEffect(() => {
|
||||
onExit((code, signal) => {
|
||||
setExitInfo({ code, signal })
|
||||
terminalRef.current?.write(`\r\n[process exited${code !== null ? ` with code ${code}` : ''}]`)
|
||||
connectOnceRef.current = false
|
||||
})
|
||||
}, [onExit])
|
||||
|
||||
const handleTerminalMount = useCallback((terminal: Terminal) => {
|
||||
terminalRef.current = terminal
|
||||
inputDisposableRef.current?.dispose()
|
||||
inputDisposableRef.current = terminal.onData((data) => {
|
||||
write(data)
|
||||
})
|
||||
}, [write])
|
||||
|
||||
const handleResize = useCallback((cols: number, rows: number) => {
|
||||
lastSizeRef.current = { cols, rows }
|
||||
if (!session?.active) {
|
||||
return
|
||||
}
|
||||
if (!connectOnceRef.current) {
|
||||
connectOnceRef.current = true
|
||||
connect(cols, rows)
|
||||
} else {
|
||||
resize(cols, rows)
|
||||
}
|
||||
}, [session?.active, connect, resize])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.active) {
|
||||
return
|
||||
}
|
||||
if (connectOnceRef.current) {
|
||||
return
|
||||
}
|
||||
const size = lastSizeRef.current
|
||||
if (!size) {
|
||||
return
|
||||
}
|
||||
connectOnceRef.current = true
|
||||
connect(size.cols, size.rows)
|
||||
}, [session?.active, connect])
|
||||
|
||||
useEffect(() => {
|
||||
connectOnceRef.current = false
|
||||
setExitInfo(null)
|
||||
disconnect()
|
||||
}, [sessionId, disconnect])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
inputDisposableRef.current?.dispose()
|
||||
connectOnceRef.current = false
|
||||
disconnect()
|
||||
}
|
||||
}, [disconnect])
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.active === false) {
|
||||
disconnect()
|
||||
connectOnceRef.current = false
|
||||
}
|
||||
}, [session?.active, disconnect])
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalState.status === 'error') {
|
||||
connectOnceRef.current = false
|
||||
return
|
||||
}
|
||||
if (terminalState.status === 'connecting' || terminalState.status === 'connected') {
|
||||
setExitInfo(null)
|
||||
}
|
||||
}, [terminalState.status])
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoadingState label="Loading session…" className="text-sm" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const subtitle = session.metadata?.path ?? sessionId
|
||||
const status = terminalState.status
|
||||
const errorMessage = terminalState.status === 'error' ? terminalState.error : null
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="bg-[var(--app-bg)] pt-[env(safe-area-inset-top)]">
|
||||
<div className="mx-auto w-full max-w-content flex items-center gap-2 p-3 border-b border-[var(--app-border)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
|
||||
>
|
||||
<BackIcon />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold">Terminal</div>
|
||||
<div className="truncate text-xs text-[var(--app-hint)]">{subtitle}</div>
|
||||
</div>
|
||||
<ConnectionBadge status={status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.active ? null : (
|
||||
<div className="px-3 pt-3">
|
||||
<div className="mx-auto w-full max-w-content rounded-md bg-[var(--app-subtle-bg)] p-3 text-sm text-[var(--app-hint)]">
|
||||
Session is inactive. Terminal is unavailable.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="mx-auto w-full max-w-content px-3 pt-3">
|
||||
<div className="rounded-md border border-[var(--app-badge-error-border)] bg-[var(--app-badge-error-bg)] p-3 text-xs text-[var(--app-badge-error-text)]">
|
||||
{errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{exitInfo ? (
|
||||
<div className="mx-auto w-full max-w-content px-3 pt-3">
|
||||
<div className="rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] p-3 text-xs text-[var(--app-hint)]">
|
||||
Terminal exited{exitInfo.code !== null ? ` with code ${exitInfo.code}` : ''}{exitInfo.signal ? ` (${exitInfo.signal})` : ''}.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex-1 overflow-hidden bg-[var(--app-bg)]">
|
||||
<div className="mx-auto h-full w-full max-w-content">
|
||||
<TerminalView
|
||||
onMount={handleTerminalMount}
|
||||
onResize={handleResize}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user