diff --git a/src/http/hook.ts b/src/http/hook.ts new file mode 100644 index 0000000..4f9f9c2 --- /dev/null +++ b/src/http/hook.ts @@ -0,0 +1,57 @@ +/** + * src/http/hook.ts (H2) — map a Claude Code hook POST to a session status update. + * + * Pure + never throws. The web-terminal sessionId arrives in the + * `X-Webterm-Session` header (injected as $WEBTERM_SESSION on spawn); the hook + * payload arrives as JSON in the body. We translate the event into a coarse + * ClaudeStatus the UI can show. The server is still a byte-shuttle for the + * terminal stream — this is an out-of-band side-channel. + */ + +import type { ClaudeStatus } from '../types.js'; + +export interface HookEvent { + sessionId: string; + status: ClaudeStatus; + detail?: string; +} + +/** + * @param sessionId value of the `X-Webterm-Session` header + * @param body parsed JSON body of the hook POST (untrusted) + * @returns a status update, or null if the payload is unusable + */ +export function parseHookEvent(sessionId: string | undefined, body: unknown): HookEvent | null { + if (typeof sessionId !== 'string' || sessionId.length === 0) return null; + if (body === null || typeof body !== 'object') return null; + + const b = body as Record; + const event = typeof b['hook_event_name'] === 'string' ? b['hook_event_name'] : ''; + const notif = typeof b['notification_type'] === 'string' ? b['notification_type'] : ''; + const tool = typeof b['tool_name'] === 'string' ? b['tool_name'] : undefined; + + let status: ClaudeStatus; + switch (event) { + case 'PreToolUse': + case 'PostToolUse': + case 'UserPromptSubmit': + status = 'working'; + break; + case 'PermissionRequest': + status = 'waiting'; + break; + case 'Stop': + case 'SessionEnd': + status = 'idle'; + break; + case 'Notification': + status = notif === 'permission_prompt' ? 'waiting' : 'idle'; + break; + default: + return null; + } + + return status === 'waiting' && tool !== undefined + ? { sessionId, status, detail: tool } + : { sessionId, status }; +} diff --git a/src/server.ts b/src/server.ts index b2a00a6..4080949 100644 --- a/src/server.ts +++ b/src/server.ts @@ -35,6 +35,7 @@ import type { IncomingMessage } from 'node:http' import { loadConfig } from './config.js' import { parseClientMessage, serialize } from './protocol.js' import { isOriginAllowed } from './http/origin.js' +import { parseHookEvent } from './http/hook.js' import { createSessionManager } from './session/manager.js' import { detachWs, writeInput, resize } from './session/session.js' import type { Config } from './types.js' @@ -77,6 +78,25 @@ export function startServer(cfg: Config): { close(): Promise } { const publicDir = path.join(__dirname, '..', 'public') app.use(express.static(publicDir)) + // ── Claude Code hook side-channel (H2) ──────────────────────────────────── + // Hooks running inside a spawned shell POST status here (loopback only — the + // shell runs on the host). sessionId arrives in the X-Webterm-Session header. + app.post('/hook', express.json({ limit: '64kb' }), (req, res) => { + const ip = req.socket.remoteAddress ?? '' + const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1' + if (!isLoopback) { + res.status(403).end() + return + } + const ev = parseHookEvent(req.header('x-webterm-session'), req.body) + if (ev === null) { + res.status(400).end() + return + } + manager.handleHookEvent(ev.sessionId, ev.status, ev.detail) + res.status(204).end() + }) + // ── HTTP server ─────────────────────────────────────────────────────────── const httpServer = createServer(app) @@ -219,6 +239,7 @@ export function startServer(cfg: Config): { close(): Promise } { console.error('[server] shutdown signal received — cleaning up') doShutdown() httpServer.close(() => process.exit(0)) + httpServer.closeIdleConnections() // drop idle keep-alive (hook) sockets so close() drains } process.on('SIGINT', onSignal) @@ -246,6 +267,9 @@ export function startServer(cfg: Config): { close(): Promise } { return new Promise((resolve) => { doShutdown() httpServer.close(() => resolve()) + // Drop idle keep-alive sockets (e.g. an undici hook connection) so the + // close callback can fire instead of waiting for the keep-alive timeout. + httpServer.closeIdleConnections() }) }, } diff --git a/src/session/manager.ts b/src/session/manager.ts index 480b8b2..1868c1e 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -22,7 +22,14 @@ * Coding style: immutable-update preference, no console.log, errors explicit. */ -import type { Config, Dims, Session, SessionManager, WebSocketLike } from '../types.js'; +import type { + ClaudeStatus, + Config, + Dims, + Session, + SessionManager, + WebSocketLike, +} from '../types.js'; import { WS_OPEN } from '../types.js'; import { serialize } from '../protocol.js'; import { createSession, attachWs, kill } from './session.js'; @@ -119,6 +126,20 @@ export function createSessionManager(cfg: Config): SessionManager { return sessions.get(id); } + /** + * H2: a Claude Code hook reported activity for `sessionId`. Record it and push + * a `status` frame to the attached ws (no-op if the session is gone/detached). + */ + function handleHookEvent(sessionId: string, status: ClaudeStatus, detail?: string): void { + const session = sessions.get(sessionId); + if (session === undefined) return; + session.claudeStatus = status; + sendIfOpen( + session.attachedWs, + detail !== undefined ? { type: 'status', status, detail } : { type: 'status', status }, + ); + } + /** * Reclaim detached sessions whose liveness window has expired (M3). * @@ -159,5 +180,5 @@ export function createSessionManager(cfg: Config): SessionManager { sessions = new Map(); } - return { handleAttach, get, reapIdle, shutdown }; + return { handleAttach, get, handleHookEvent, reapIdle, shutdown }; } diff --git a/src/session/session.ts b/src/session/session.ts index c04479f..c98feda 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -58,17 +58,26 @@ export function createSession( now: number, onExit: (session: Session) => void, ): Session { + // Generate the id first so it can be injected into the shell env: Claude Code + // hooks running inside this shell read $WEBTERM_SESSION to tell us which tab + // an event belongs to, and POST to $WEBTERM_HOOK_URL (H2). + const id = randomUUID(); + // M4: let a spawn failure (e.g. missing shell) propagate synchronously. const pty: IPty = spawn(cfg.shellPath, [], { name: 'xterm-256color', cols: dims.cols, rows: dims.rows, cwd: cfg.homeDir, - env: process.env, + env: { + ...process.env, + WEBTERM_SESSION: id, + WEBTERM_HOOK_URL: `http://127.0.0.1:${cfg.port}/hook`, + }, }); const meta: SessionMeta = { - id: randomUUID(), + id, createdAt: now, shellPath: cfg.shellPath, }; @@ -81,6 +90,7 @@ export function createSession( lastOutputAt: now, exitedAt: null, exitCode: null, + claudeStatus: 'unknown', pty, }; diff --git a/src/types.ts b/src/types.ts index 2a48370..f07d2aa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,12 +44,17 @@ export type ClientMessage = | { type: 'input'; data: string } | { type: 'resize'; cols: number; rows: number }; +/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet. */ +export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown'; + /** server → client. exit.code = shell code; -1 when spawn never succeeded (M4). - * exit.reason optional normally, REQUIRED on spawn failure / abnormal exit. */ + * exit.reason optional normally, REQUIRED on spawn failure / abnormal exit. + * status (H2) = Claude Code activity pushed from a hook side-channel. */ export type ServerMessage = | { type: 'attached'; sessionId: string } | { type: 'output'; data: string } - | { type: 'exit'; code: number; reason?: string }; + | { type: 'exit'; code: number; reason?: string } + | { type: 'status'; status: ClaudeStatus; detail?: string }; /** parseClientMessage result — never throws; errors flow here (§5.3). */ export type ParseResult = @@ -139,6 +144,8 @@ export interface Session { * and attach replays the buffer then re-sends `exit` (L1). */ exitedAt: number | null; exitCode: number | null; + /** Claude Code activity from hooks (H2); updated by manager.handleHookEvent. */ + claudeStatus: ClaudeStatus; readonly pty: IPty; } @@ -156,6 +163,8 @@ export interface Session { export interface SessionManager { handleAttach(ws: WebSocketLike, sessionId: string | null, dims: Dims, now: number): Session; get(id: string): Session | undefined; + /** Set a session's Claude status (from a hook) and push it to the attached ws (H2). */ + handleHookEvent(sessionId: string, status: ClaudeStatus, detail?: string): void; /** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */ reapIdle(now: number): number; shutdown(): void; diff --git a/test/hook.test.ts b/test/hook.test.ts new file mode 100644 index 0000000..daa8ac6 --- /dev/null +++ b/test/hook.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { parseHookEvent } from '../src/http/hook.js' + +const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' + +describe('parseHookEvent', () => { + it('maps tool events to "working"', () => { + for (const e of ['PreToolUse', 'PostToolUse', 'UserPromptSubmit']) { + expect(parseHookEvent(SID, { hook_event_name: e })).toEqual({ + sessionId: SID, + status: 'working', + }) + } + }) + + it('maps PermissionRequest to "waiting" with the tool name as detail', () => { + expect(parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash' })).toEqual( + { sessionId: SID, status: 'waiting', detail: 'Bash' }, + ) + }) + + it('maps Notification permission_prompt to "waiting", idle_prompt to "idle"', () => { + expect( + parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'permission_prompt' }), + ).toEqual({ sessionId: SID, status: 'waiting' }) + expect( + parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'idle_prompt' }), + ).toEqual({ sessionId: SID, status: 'idle' }) + }) + + it('maps Stop / SessionEnd to "idle"', () => { + expect(parseHookEvent(SID, { hook_event_name: 'Stop' })).toEqual({ sessionId: SID, status: 'idle' }) + expect(parseHookEvent(SID, { hook_event_name: 'SessionEnd' })).toEqual({ + sessionId: SID, + status: 'idle', + }) + }) + + it('returns null for missing sessionId, bad body, or unknown event', () => { + expect(parseHookEvent(undefined, { hook_event_name: 'Stop' })).toBeNull() + expect(parseHookEvent('', { hook_event_name: 'Stop' })).toBeNull() + expect(parseHookEvent(SID, null)).toBeNull() + expect(parseHookEvent(SID, 'nope')).toBeNull() + expect(parseHookEvent(SID, { hook_event_name: 'SomethingElse' })).toBeNull() + expect(parseHookEvent(SID, {})).toBeNull() + }) +}) diff --git a/test/integration/server.test.ts b/test/integration/server.test.ts index adfec84..a53a601 100644 --- a/test/integration/server.test.ts +++ b/test/integration/server.test.ts @@ -545,4 +545,48 @@ describe('startServer — integration', () => { await waitForClose(ws2, 3_000).catch(() => undefined) }, ) + + // ── ⑦ Hook side-channel → status push (H2, needs real PTY — sandbox-off) ─── + itPty( + '⑦ [needs real PTY (sandbox-off)] POST /hook pushes a status frame to the attached ws (H2)', + async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, { + headers: { Origin: `http://127.0.0.1:${port}` }, + }) + await waitForOpen(ws, 3_000) + ws.send(JSON.stringify({ type: 'attach', sessionId: null })) + const attached = (await waitForMessage( + ws, + (m) => + typeof m === 'object' && m !== null && (m as Record)['type'] === 'attached', + 5_000, + )) as Record + const sessionId = attached['sessionId'] as string + + // Arm the status listener BEFORE firing the hook — the server pushes the + // status frame synchronously (before the POST returns 204), so attaching + // the listener after the fetch would race past it. + const statusPromise = waitForMessage( + ws, + (m) => + typeof m === 'object' && m !== null && (m as Record)['type'] === 'status', + 5_000, + ) + + // Simulate a Claude Code PermissionRequest hook firing for this session. + const res = await fetch(`http://127.0.0.1:${port}/hook`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, + body: JSON.stringify({ hook_event_name: 'PermissionRequest', tool_name: 'Bash' }), + }) + expect(res.status).toBe(204) + + const status = (await statusPromise) as Record + expect(status['status']).toBe('waiting') + expect(status['detail']).toBe('Bash') + + ws.close() + await waitForClose(ws, 3_000).catch(() => undefined) + }, + ) })