feat(v0.3): H2 server — Claude Code hooks → live status side-channel

- types: ClaudeStatus + ServerMessage 'status'; Session.claudeStatus;
  SessionManager.handleHookEvent
- session: inject WEBTERM_SESSION + WEBTERM_HOOK_URL into the spawned shell env
  so hooks know which tab they belong to
- http/hook.ts: pure event→status mapper (PreToolUse→working, PermissionRequest/
  Notification:permission_prompt→waiting, Stop/idle_prompt→idle); 6 unit tests
- server: POST /hook (loopback-only, express.json 64kb) → manager pushes 'status'
- fix: closeIdleConnections() on shutdown so an idle hook keep-alive socket
  doesn't hang close()/SIGTERM
- integration ⑦ (real PTY): attach → POST /hook → ws receives status. 205 tests green.
This commit is contained in:
Yaojia Wang
2026-06-17 18:44:07 +02:00
parent 8c2a6825cc
commit a411c89ee9
7 changed files with 218 additions and 6 deletions

57
src/http/hook.ts Normal file
View File

@@ -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<string, unknown>;
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 };
}

View File

@@ -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<void> } {
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<void> } {
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<void> } {
return new Promise<void>((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()
})
},
}

View File

@@ -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 };
}

View File

@@ -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,
};

View File

@@ -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;