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

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;