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

47
test/hook.test.ts Normal file
View 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()
})
})

View File

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