feat(v0.3): H3 — remote approve/reject (no typing)

- types/protocol: ClientMessage approve/reject; ServerMessage status.pending
- server: POST /hook/permission HELD until the client decides; approve/reject
  ws messages resolve it with {hookSpecificOutput.decision.behavior allow|deny};
  5-min timeout + release-on-disconnect fall back to Claude's own prompt
- manager.handleHookEvent threads pending flag
- setup-hooks: PermissionRequest uses the held curl (writes decision to stdout);
  status events stay fire-and-forget
- FE: terminal-session approve()/reject() + pending state; tabs approval banner
  (Approve/Reject) for the active tab's held request
- Tests: protocol approve/reject; integration ⑧ held→approve→allow. 207 green.
- Browser-verified: banner 'Claude wants to use Bash' → Approve → resolves allow.
This commit is contained in:
Yaojia Wang
2026-06-17 19:13:07 +02:00
parent 04355f05e9
commit 9a6150c355
10 changed files with 279 additions and 23 deletions

View File

@@ -69,6 +69,8 @@ export class TerminalSession {
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
private statusValue: SessionStatus = 'connecting'
private claudeStatusValue: ClaudeStatus = 'unknown'
private pendingApprovalValue = false
private pendingToolValue: string | undefined = undefined
private ws: WebSocket | null = null
private sessionId: string | null
@@ -128,6 +130,14 @@ export class TerminalSession {
return this.claudeStatusValue
}
/** Whether a tool approval is held server-side and can be resolved (H3). */
get pendingApproval(): boolean {
return this.pendingApprovalValue
}
get pendingTool(): string | undefined {
return this.pendingToolValue
}
private setStatus(s: SessionStatus): void {
this.statusValue = s
this.onStatus?.(s)
@@ -218,6 +228,8 @@ export class TerminalSession {
}
case 'status': {
this.claudeStatusValue = msg.status
this.pendingApprovalValue = msg.pending === true
this.pendingToolValue = msg.pending === true ? msg.detail : undefined
this.onClaudeStatus?.(msg.status, msg.detail)
break
}
@@ -259,10 +271,24 @@ export class TerminalSession {
}, delay)
}
private sendMsg(msg: ClientMessage): void {
if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return
this.ws.send(buildMessage(msg))
}
/** Send raw bytes as input (used by term.onData and the key bar). */
send(data: string): void {
if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return
this.ws.send(buildMessage({ type: 'input', data }))
this.sendMsg({ type: 'input', data })
}
/** Resolve a held PermissionRequest (H3). */
approve(): void {
this.pendingApprovalValue = false
this.sendMsg({ type: 'approve' })
}
reject(): void {
this.pendingApprovalValue = false
this.sendMsg({ type: 'reject' })
}
/** Scrollback search (M1). */