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:
57
src/http/hook.ts
Normal file
57
src/http/hook.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import type { IncomingMessage } from 'node:http'
|
|||||||
import { loadConfig } from './config.js'
|
import { loadConfig } from './config.js'
|
||||||
import { parseClientMessage, serialize } from './protocol.js'
|
import { parseClientMessage, serialize } from './protocol.js'
|
||||||
import { isOriginAllowed } from './http/origin.js'
|
import { isOriginAllowed } from './http/origin.js'
|
||||||
|
import { parseHookEvent } from './http/hook.js'
|
||||||
import { createSessionManager } from './session/manager.js'
|
import { createSessionManager } from './session/manager.js'
|
||||||
import { detachWs, writeInput, resize } from './session/session.js'
|
import { detachWs, writeInput, resize } from './session/session.js'
|
||||||
import type { Config } from './types.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')
|
const publicDir = path.join(__dirname, '..', 'public')
|
||||||
app.use(express.static(publicDir))
|
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 ───────────────────────────────────────────────────────────
|
// ── HTTP server ───────────────────────────────────────────────────────────
|
||||||
const httpServer = createServer(app)
|
const httpServer = createServer(app)
|
||||||
|
|
||||||
@@ -219,6 +239,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
|||||||
console.error('[server] shutdown signal received — cleaning up')
|
console.error('[server] shutdown signal received — cleaning up')
|
||||||
doShutdown()
|
doShutdown()
|
||||||
httpServer.close(() => process.exit(0))
|
httpServer.close(() => process.exit(0))
|
||||||
|
httpServer.closeIdleConnections() // drop idle keep-alive (hook) sockets so close() drains
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('SIGINT', onSignal)
|
process.on('SIGINT', onSignal)
|
||||||
@@ -246,6 +267,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
|||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
doShutdown()
|
doShutdown()
|
||||||
httpServer.close(() => resolve())
|
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()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,14 @@
|
|||||||
* Coding style: immutable-update preference, no console.log, errors explicit.
|
* 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 { WS_OPEN } from '../types.js';
|
||||||
import { serialize } from '../protocol.js';
|
import { serialize } from '../protocol.js';
|
||||||
import { createSession, attachWs, kill } from './session.js';
|
import { createSession, attachWs, kill } from './session.js';
|
||||||
@@ -119,6 +126,20 @@ export function createSessionManager(cfg: Config): SessionManager {
|
|||||||
return sessions.get(id);
|
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).
|
* Reclaim detached sessions whose liveness window has expired (M3).
|
||||||
*
|
*
|
||||||
@@ -159,5 +180,5 @@ export function createSessionManager(cfg: Config): SessionManager {
|
|||||||
sessions = new Map();
|
sessions = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { handleAttach, get, reapIdle, shutdown };
|
return { handleAttach, get, handleHookEvent, reapIdle, shutdown };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,17 +58,26 @@ export function createSession(
|
|||||||
now: number,
|
now: number,
|
||||||
onExit: (session: Session) => void,
|
onExit: (session: Session) => void,
|
||||||
): Session {
|
): 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.
|
// M4: let a spawn failure (e.g. missing shell) propagate synchronously.
|
||||||
const pty: IPty = spawn(cfg.shellPath, [], {
|
const pty: IPty = spawn(cfg.shellPath, [], {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: dims.cols,
|
cols: dims.cols,
|
||||||
rows: dims.rows,
|
rows: dims.rows,
|
||||||
cwd: cfg.homeDir,
|
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 = {
|
const meta: SessionMeta = {
|
||||||
id: randomUUID(),
|
id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
shellPath: cfg.shellPath,
|
shellPath: cfg.shellPath,
|
||||||
};
|
};
|
||||||
@@ -81,6 +90,7 @@ export function createSession(
|
|||||||
lastOutputAt: now,
|
lastOutputAt: now,
|
||||||
exitedAt: null,
|
exitedAt: null,
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
|
claudeStatus: 'unknown',
|
||||||
pty,
|
pty,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
13
src/types.ts
13
src/types.ts
@@ -44,12 +44,17 @@ export type ClientMessage =
|
|||||||
| { type: 'input'; data: string }
|
| { type: 'input'; data: string }
|
||||||
| { type: 'resize'; cols: number; rows: number };
|
| { 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).
|
/** 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 =
|
export type ServerMessage =
|
||||||
| { type: 'attached'; sessionId: string }
|
| { type: 'attached'; sessionId: string }
|
||||||
| { type: 'output'; data: 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). */
|
/** parseClientMessage result — never throws; errors flow here (§5.3). */
|
||||||
export type ParseResult =
|
export type ParseResult =
|
||||||
@@ -139,6 +144,8 @@ export interface Session {
|
|||||||
* and attach replays the buffer then re-sends `exit` (L1). */
|
* and attach replays the buffer then re-sends `exit` (L1). */
|
||||||
exitedAt: number | null;
|
exitedAt: number | null;
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
|
/** Claude Code activity from hooks (H2); updated by manager.handleHookEvent. */
|
||||||
|
claudeStatus: ClaudeStatus;
|
||||||
readonly pty: IPty;
|
readonly pty: IPty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +163,8 @@ export interface Session {
|
|||||||
export interface SessionManager {
|
export interface SessionManager {
|
||||||
handleAttach(ws: WebSocketLike, sessionId: string | null, dims: Dims, now: number): Session;
|
handleAttach(ws: WebSocketLike, sessionId: string | null, dims: Dims, now: number): Session;
|
||||||
get(id: string): Session | undefined;
|
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. */
|
/** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */
|
||||||
reapIdle(now: number): number;
|
reapIdle(now: number): number;
|
||||||
shutdown(): void;
|
shutdown(): void;
|
||||||
|
|||||||
47
test/hook.test.ts
Normal file
47
test/hook.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -545,4 +545,48 @@ describe('startServer — integration', () => {
|
|||||||
await waitForClose(ws2, 3_000).catch(() => undefined)
|
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<string, unknown>)['type'] === 'attached',
|
||||||
|
5_000,
|
||||||
|
)) as Record<string, unknown>
|
||||||
|
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<string, unknown>)['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<string, unknown>
|
||||||
|
expect(status['status']).toBe('waiting')
|
||||||
|
expect(status['detail']).toBe('Bash')
|
||||||
|
|
||||||
|
ws.close()
|
||||||
|
await waitForClose(ws, 3_000).catch(() => undefined)
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user