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

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