feat: plan-approval overlay, auth-expiry handling, cert download, login reset

- Add a dedicated plan-approval bottom sheet (interactive-prompt) driven by
  ExitPlanMode's now-4-option layout (Claude Code v2.1.177), with
  approve/YOLO/reject/feedback wired through respondPlan(requestId).
- PermissionManager: stop auto-expiring AskUserQuestion/plan prompts and stop
  releasing pending prompts on client disconnect — they reflect real CLI
  state and must survive reconnects.
- Detect server-rejected tokens (401/403 or failed WS handshake) and bounce
  back to the login screen via a new AUTH_EXPIRED_EVENT.
- Add `/cert` endpoint + Settings entry so phones can download/trust the
  self-signed HTTPS cert directly; fix cert IP detection on non-macOS hosts.
- Add `clawtap reset-login` command and `/api/auth/reset-attempts` endpoint
  to clear login rate-limit lockouts.
- Codex adapter: only forward recognized keystrokes/option indices to tmux,
  never the synthetic "deny" dismissal signal.
- PWA: actively poll for service-worker updates (iOS standalone apps don't
  reliably check on navigation).
- update-service.sh: install tmux if missing, prompt to create the env file
  instead of failing, add --skip-firewall, and improve logging.
This commit is contained in:
2026-06-15 16:30:24 -04:00
parent 4e6dfb4726
commit 2ded310472
23 changed files with 656 additions and 97 deletions
+24 -11
View File
@@ -394,7 +394,7 @@ export async function handleIncomingMessage(conn: ClientConnection, msg: ClientM
case WS.SET_MODEL:
return handleSetModel(conn, msg.sessionId as string, msg.model as string);
case WS.PLAN_RESPONSE:
return handlePlanResponse(conn, msg.sessionId as string, msg.optionIndex as number, msg.text as string | undefined);
return handlePlanResponse(conn, msg.sessionId as string, msg.optionIndex as number, msg.text as string | undefined, msg.requestId as string | undefined);
case WS.PROMPT_RESPONSE:
return handlePromptResponse(conn, msg.requestId as string, {
selectedOption: msg.selectedOption as string | undefined,
@@ -488,12 +488,13 @@ export async function handleAbort(conn: ClientConnection, sessionId?: string): P
if (sid && adapter) await adapter.interrupt(sid);
}
export async function handlePlanResponse(conn: ClientConnection, sessionId: string, optionIndex: number, text?: string): Promise<void> {
export async function handlePlanResponse(conn: ClientConnection, sessionId: string, optionIndex: number, text?: string, requestId?: string): Promise<void> {
const { adapter, sid } = await getAdapterForSession(conn, sessionId);
if (!sid || !adapter) return;
await adapter.respondPlan(sid, optionIndex, text);
await adapter.respondPlan(sid, optionIndex, text, requestId);
if (requestId) broadcast(sid, { type: WS.PROMPT_DISMISSED, requestId });
// Broadcast synthetic user message so plan card transitions to read-only on ALL clients
// Options: 0=bypass (YOLO), 1=manually approve, 2=text feedback
// Options: 0=bypass (YOLO), 1=manually approve, 3=text feedback
const labels = ['Plan approved (YOLO).', 'Plan approved.'];
const msg = optionIndex === PLAN_OPTION.TEXT_FEEDBACK ? (text || 'Rejected.') : (labels[optionIndex] || 'Plan approved.');
broadcast(sid, { type: WS.MESSAGE_COMPLETE, messages: [{ role: 'user', content: msg }] });
@@ -572,6 +573,17 @@ export async function handleReconnect(conn: ClientConnection, sessionId?: string
send(conn, { type: WS.TOOL_UPDATES, tools: pending.tools });
}
for (const req of pending.pendingRequests) {
if (req.type === 'plan') {
send(conn, {
type: WS.INTERACTIVE_PROMPT,
requestId: req.requestId,
promptType: 'plan',
title: 'Plan ready',
toolName: req.toolName,
toolInput: req.input,
});
continue;
}
const { type: _type, ...rest } = req as Record<string, unknown>;
send(conn, { type: WS.PERMISSION_REQUEST, ...rest });
}
@@ -667,13 +679,14 @@ function registerClient(conn: ClientConnection, sessionId: string): void {
if (set) {
set.delete(c);
if (set.size === 0) {
const adapterName = sessionAdapterMap.get(sid) || DEFAULT_ADAPTER;
const adapter = getAdapter(adapterName);
// Only release pending permissions if session is idle — if processing,
// the client may be refreshing and will reconnect shortly to see the overlay
if (adapter && !adapter.isProcessing(sid)) {
adapter.releaseAllPending(sid);
}
// Do NOT release pending permissions/questions here. They only exist
// because the CLI itself posted a prompt and is genuinely waiting on
// an answer — that's real, live CLI state, not stale UI residue.
// Wiping our tracking just makes ClawTap forget about a prompt that's
// still sitting on screen, so a reconnecting client sees no dialog at
// all (a "ghost" prompt the CLI is waiting on but nothing can answer).
// Let pending entries persist until answered, resolved by the CLI, or
// naturally expired by PermissionManager's internal timeout.
console.log(`[session-mgr] All clients disconnected from ${sid}, session persists`);
}
}