feat: ClawTap v0.2.0
Interactive Prompts: - Unified InteractivePrompt type across all 3 adapters (Claude/Codex/Gemini) - InteractivePromptOverlay component with options, text input, countdown - Gemini + Codex pane monitors detect tool confirmation, ask user, plan approval - respondInteractivePrompt routing: permission → respondPermission, options → _selectOption - Claude AskUserQuestion nested questions[0] structure parsing Cross-AI Review: - Client-generated reviewId, removed pendingReview state - FloatingReviewPanel uses CSS display:none instead of unmount (keeps hooks alive) - Child review sessions default to YOLO/bypass permission mode - Send back to parent, send to existing/new review, tab switching, end review - Collapsed review cards with read-only panel for ended reviews - Full reconnect support: active + ended reviews restore correctly AskUserQuestion Tool Card UI: - Dedicated renderer replaces raw JSON display - Options shown with selected (green) / unselected (gray) indicators - Free text answers shown in quoted format with green border - Collapsed summary: question → answer - Shared parseAskQuestionInput utility (client + server) - Historical tool results attached via _result on tool_use blocks Adapter Fixes: - Session→adapter mapping persisted in SQLite (survives server restart) - SESSION_CREATED deferred for pendingRekey adapters (Codex/Gemini) - session-rekeyed handler sends complete SESSION_CREATED with adapter + cwd - Gemini: auto-accept folder trust, privacy notice, IDE nudge, YOLO * prompt - Claude: auto-accept bypass permissions confirmation (v2.1.85+) - Port fallback (EADDRINUSE → try +1), statusLine shell script wrapper Other: - Desktop Enter sends / Shift+Enter newline; Mobile Enter newline - Strip CLAWTAP_REF marker from session list - Active sessions tab shows adapter badge - Rename CLAUDE_UI_PASSWORD → CLAWTAP_PASSWORD Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+127
-34
@@ -5,7 +5,8 @@ import type { ClientMessage, QueryOptions, PermissionBehavior } from './types/me
|
||||
import { sendPush, incrementPending, clearPending, getPendingSessions } from './push.js';
|
||||
import { basename } from 'path';
|
||||
import type { ClientConnection } from './transport/client-connection.js';
|
||||
import { sessionReviews } from './db.js';
|
||||
import { sessionReviews, sessionAdapters } from './db.js';
|
||||
import { parseAskQuestionInput } from './adapters/shared/ask-question-utils.js';
|
||||
|
||||
/** Push notification options */
|
||||
interface PushOptions {
|
||||
@@ -94,14 +95,48 @@ export function setupSessionManager(): void {
|
||||
triggerPush(adapter, sessionId, { title: 'Claude finished', body: 'Turn complete', tagPrefix: 'idle' });
|
||||
});
|
||||
|
||||
adapter.on('permission-request', (sessionId: string, data: { toolName?: string; [key: string]: unknown }) => {
|
||||
broadcast(sessionId, { type: WS.PERMISSION_REQUEST, ...data });
|
||||
adapter.on('permission-request', (sessionId: string, data: { requestId?: string; toolName?: string; input?: any; [key: string]: unknown }) => {
|
||||
// Convert legacy Claude hook event to InteractivePrompt format
|
||||
broadcast(sessionId, {
|
||||
type: WS.INTERACTIVE_PROMPT,
|
||||
requestId: data.requestId || `perm-${Date.now()}`,
|
||||
promptType: 'permission',
|
||||
title: 'Permission Request',
|
||||
description: `${data.toolName || 'Tool'} wants to execute`,
|
||||
toolName: data.toolName,
|
||||
toolInput: data.input,
|
||||
options: [
|
||||
{ value: 'allow', label: 'Allow' },
|
||||
{ value: 'allow_session', label: 'Allow All' },
|
||||
{ value: 'deny', label: 'Deny' },
|
||||
],
|
||||
});
|
||||
triggerPush(adapter, sessionId, { title: 'Permission needed', body: data.toolName || 'tool', tagPrefix: 'perm' });
|
||||
});
|
||||
|
||||
adapter.on('ask-question', (sessionId: string, data: { toolName?: string; [key: string]: unknown }) => {
|
||||
broadcast(sessionId, { type: WS.PERMISSION_REQUEST, ...data });
|
||||
triggerPush(adapter, sessionId, { title: 'Question from Claude', body: 'Waiting for answer', tagPrefix: 'ask' });
|
||||
adapter.on('ask-question', (sessionId: string, data: { requestId?: string; toolName?: string; input?: any; [key: string]: unknown }) => {
|
||||
const parsed = parseAskQuestionInput(data.input || {});
|
||||
broadcast(sessionId, {
|
||||
type: WS.INTERACTIVE_PROMPT,
|
||||
requestId: data.requestId || `ask-${Date.now()}`,
|
||||
promptType: 'question',
|
||||
title: parsed.header || 'Question',
|
||||
description: parsed.question,
|
||||
toolName: 'AskUserQuestion',
|
||||
toolInput: data.input || {},
|
||||
options: parsed.options,
|
||||
textInput: parsed.options ? undefined : { placeholder: 'Enter your response...' },
|
||||
});
|
||||
triggerPush(adapter, sessionId, { title: 'Question', body: questionText.substring(0, 50) || 'Waiting for answer', tagPrefix: 'ask' });
|
||||
});
|
||||
|
||||
adapter.on('interactive-prompt', (sessionId: string, prompt: any) => {
|
||||
broadcast(sessionId, { type: WS.INTERACTIVE_PROMPT, ...prompt });
|
||||
const pushTitle = prompt.promptType === 'permission' ? 'Permission needed'
|
||||
: prompt.promptType === 'question' ? 'Question'
|
||||
: prompt.promptType === 'plan' ? 'Plan approval'
|
||||
: 'Action needed';
|
||||
triggerPush(adapter, sessionId, { title: pushTitle, body: prompt.title || '', tagPrefix: 'prompt' });
|
||||
});
|
||||
|
||||
adapter.on('status-update', (sessionId: string, status: Record<string, unknown>) => {
|
||||
@@ -181,15 +216,12 @@ export function setupSessionManager(): void {
|
||||
}
|
||||
// Update any active reviews that reference the old key as child (FIX 3)
|
||||
sessionReviews.updateChildCliId(oldKey, newKey);
|
||||
// Send updated SESSION_CREATED so frontend knows the real ID
|
||||
// Send SESSION_CREATED with the real UUID — for pendingRekey adapters,
|
||||
// this is the ONLY SESSION_CREATED the client receives.
|
||||
const resolvedAdapter = getAdapter(adapterName || DEFAULT_ADAPTER);
|
||||
if (resolvedAdapter && clients) {
|
||||
for (const conn of clients) {
|
||||
send(conn, {
|
||||
type: WS.SESSION_CREATED,
|
||||
sessionId: newKey,
|
||||
permissionMode: (resolvedAdapter.getSession(newKey) as any)?.permissionMode || (resolvedAdapter.getSession(newKey) as any)?.approvalPolicy,
|
||||
});
|
||||
sendSessionCreated(conn, resolvedAdapter, newKey);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -204,20 +236,60 @@ export function setupSessionManager(): void {
|
||||
|
||||
// === Helper: resolve adapter for a session ===
|
||||
|
||||
function getAdapterForSession(conn: ClientConnection, sessionId?: string): { adapter: IAdapter | undefined; sid: string } {
|
||||
/**
|
||||
* Resolve which adapter owns a session.
|
||||
* 1. In-memory map (fastest, covers active sessions)
|
||||
* 2. SQLite (populated when /api/sessions is fetched, survives restarts)
|
||||
* 3. Probe each adapter with getMessages (one-time cost, then cached)
|
||||
* Returns null if no adapter recognizes the session.
|
||||
*/
|
||||
async function resolveAdapterForSession(sessionId: string): Promise<string | null> {
|
||||
// 1. Memory
|
||||
const mapped = sessionAdapterMap.get(sessionId);
|
||||
if (mapped) return mapped;
|
||||
|
||||
// 2. SQLite
|
||||
const persisted = sessionAdapters.get(sessionId);
|
||||
if (persisted) {
|
||||
sessionAdapterMap.set(sessionId, persisted);
|
||||
return persisted;
|
||||
}
|
||||
|
||||
// 3. Probe each adapter — runs once per unknown session, then cached
|
||||
for (const [name, adapter] of getAllAdapters()) {
|
||||
try {
|
||||
const { messages } = await adapter.getMessages(sessionId);
|
||||
if (messages.length > 0) {
|
||||
sessionAdapterMap.set(sessionId, name);
|
||||
sessionAdapters.set(sessionId, name);
|
||||
return name;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[resolveAdapter] probe ${name} for ${sessionId.slice(0, 8)} failed:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getAdapterForSession(conn: ClientConnection, sessionId?: string): Promise<{ adapter: IAdapter | undefined; sid: string }> {
|
||||
const sid = sessionId || conn.sessionId || '';
|
||||
const name = sessionAdapterMap.get(sid) || DEFAULT_ADAPTER;
|
||||
return { adapter: getAdapter(name), sid };
|
||||
const name = await resolveAdapterForSession(sid);
|
||||
return { adapter: name ? getAdapter(name) : undefined, sid };
|
||||
}
|
||||
|
||||
function sendSessionCreated(conn: ClientConnection, adapter: IAdapter, sessionId: string): void {
|
||||
const sessionObj = adapter.getSession(sessionId) as {
|
||||
permissionMode?: string;
|
||||
approvalPolicy?: string;
|
||||
cwd?: string;
|
||||
} | null;
|
||||
const adapterName = sessionAdapterMap.get(sessionId) || sessionAdapters.get(sessionId);
|
||||
send(conn, {
|
||||
type: WS.SESSION_CREATED,
|
||||
sessionId,
|
||||
adapter: adapterName,
|
||||
cwd: sessionObj?.cwd,
|
||||
permissionMode: sessionObj?.permissionMode || sessionObj?.approvalPolicy,
|
||||
});
|
||||
}
|
||||
@@ -238,13 +310,18 @@ export async function handleIncomingMessage(conn: ClientConnection, msg: ClientM
|
||||
case WS.ABORT:
|
||||
return handleAbort(conn, msg.sessionId as string | undefined);
|
||||
case WS.RECONNECT:
|
||||
return handleReconnect(conn, msg.sessionId as string | undefined, msg.adapter as string | undefined);
|
||||
return handleReconnect(conn, msg.sessionId as string | undefined);
|
||||
case WS.SET_PERMISSION_MODE:
|
||||
return handleSetPermissionMode(conn, msg.sessionId as string, msg.mode as string);
|
||||
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);
|
||||
case WS.PROMPT_RESPONSE:
|
||||
return handlePromptResponse(conn, msg.requestId as string, {
|
||||
selectedOption: msg.selectedOption as string | undefined,
|
||||
textValue: msg.textValue as string | undefined,
|
||||
});
|
||||
default:
|
||||
conn.send({ type: 'error', error: `Unknown message type: ${msg.type}` });
|
||||
}
|
||||
@@ -253,20 +330,29 @@ export async function handleIncomingMessage(conn: ClientConnection, msg: ClientM
|
||||
// === Message Handlers ===
|
||||
|
||||
export async function handleQuery(conn: ClientConnection, prompt: string, options: QueryOptions): Promise<void> {
|
||||
const { cwd, model, sessionId, permissionMode, images, adapter: adapterName } = options;
|
||||
const adapter = getAdapter(adapterName || DEFAULT_ADAPTER);
|
||||
let { cwd, model, sessionId, permissionMode, images, adapter: adapterName } = options;
|
||||
let adapter = getAdapter(adapterName || DEFAULT_ADAPTER);
|
||||
if (!adapter) throw new Error(`Unknown adapter: ${adapterName}`);
|
||||
|
||||
let handle: { sessionId: string };
|
||||
let handle: { sessionId: string; pendingRekey?: boolean };
|
||||
if (sessionId) {
|
||||
const resolvedName = await resolveAdapterForSession(sessionId);
|
||||
if (resolvedName && resolvedName !== (adapterName || DEFAULT_ADAPTER)) {
|
||||
adapter = getAdapter(resolvedName)!;
|
||||
adapterName = resolvedName;
|
||||
}
|
||||
handle = await adapter.resumeSession(sessionId, cwd as string, { permissionMode });
|
||||
} else {
|
||||
handle = await adapter.startSession(cwd || process.cwd(), { model, permissionMode });
|
||||
}
|
||||
|
||||
sessionAdapterMap.set(handle.sessionId, adapterName || DEFAULT_ADAPTER);
|
||||
registerSessionAdapter(handle.sessionId, adapterName || DEFAULT_ADAPTER);
|
||||
registerClient(conn, handle.sessionId);
|
||||
sendSessionCreated(conn, adapter, handle.sessionId);
|
||||
// Adapters with pendingRekey (Codex/Gemini) don't get SESSION_CREATED here —
|
||||
// the session-rekeyed handler sends it after rekey with the real UUID.
|
||||
if (!handle.pendingRekey) {
|
||||
sendSessionCreated(conn, adapter, handle.sessionId);
|
||||
}
|
||||
|
||||
// Send the message (images sent as text description for now)
|
||||
let messageText = prompt;
|
||||
@@ -280,8 +366,8 @@ export async function handleQuery(conn: ClientConnection, prompt: string, option
|
||||
await adapter.sendMessage(handle.sessionId, messageText, { clientId: conn.clientId });
|
||||
}
|
||||
|
||||
export function handlePermissionResponse(conn: ClientConnection, requestId: string, response: { behavior: PermissionBehavior; alwaysAllow?: boolean }): void {
|
||||
const { adapter, sid } = getAdapterForSession(conn);
|
||||
export async function handlePermissionResponse(conn: ClientConnection, requestId: string, response: { behavior: PermissionBehavior; alwaysAllow?: boolean }): Promise<void> {
|
||||
const { adapter, sid } = await getAdapterForSession(conn);
|
||||
if (adapter) {
|
||||
adapter.respondPermission(requestId, response.behavior);
|
||||
broadcast(sid, { type: WS.PERMISSION_DISMISSED, requestId });
|
||||
@@ -289,20 +375,28 @@ export function handlePermissionResponse(conn: ClientConnection, requestId: stri
|
||||
}
|
||||
|
||||
export async function handleAskResponse(conn: ClientConnection, requestId: string, answers: string): Promise<void> {
|
||||
const { adapter, sid } = getAdapterForSession(conn);
|
||||
const { adapter, sid } = await getAdapterForSession(conn);
|
||||
if (adapter) {
|
||||
adapter.respondQuestion(requestId, answers);
|
||||
broadcast(sid, { type: WS.PERMISSION_DISMISSED, requestId });
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePromptResponse(conn: ClientConnection, requestId: string, response: { selectedOption?: string; textValue?: string }): Promise<void> {
|
||||
const { adapter, sid } = await getAdapterForSession(conn);
|
||||
if (adapter) {
|
||||
adapter.respondInteractivePrompt(requestId, response.selectedOption, response.textValue);
|
||||
broadcast(sid, { type: WS.PROMPT_DISMISSED, requestId });
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAbort(conn: ClientConnection, sessionId?: string): Promise<void> {
|
||||
const { adapter, sid } = getAdapterForSession(conn, sessionId);
|
||||
const { adapter, sid } = await getAdapterForSession(conn, sessionId);
|
||||
if (sid && adapter) await adapter.interrupt(sid);
|
||||
}
|
||||
|
||||
export async function handlePlanResponse(conn: ClientConnection, sessionId: string, optionIndex: number, text?: string): Promise<void> {
|
||||
const { adapter, sid } = getAdapterForSession(conn, sessionId);
|
||||
const { adapter, sid } = await getAdapterForSession(conn, sessionId);
|
||||
if (!sid || !adapter) return;
|
||||
await adapter.respondPlan(sid, optionIndex, text);
|
||||
// Broadcast synthetic user message so plan card transitions to read-only on ALL clients
|
||||
@@ -312,17 +406,17 @@ export async function handlePlanResponse(conn: ClientConnection, sessionId: stri
|
||||
broadcast(sid, { type: WS.MESSAGE_COMPLETE, messages: [{ role: 'user', content: msg }] });
|
||||
}
|
||||
|
||||
export async function handleReconnect(conn: ClientConnection, sessionId?: string, adapterHint?: string): Promise<void> {
|
||||
export async function handleReconnect(conn: ClientConnection, sessionId?: string): Promise<void> {
|
||||
if (!sessionId) return;
|
||||
|
||||
// Resolve rekey alias (Codex temp key → real UUID)
|
||||
const resolvedId = rekeyAliases.get(sessionId) || sessionId;
|
||||
|
||||
const adapterName = sessionAdapterMap.get(resolvedId) || adapterHint || DEFAULT_ADAPTER;
|
||||
const adapterName = await resolveAdapterForSession(resolvedId);
|
||||
if (!adapterName) return;
|
||||
const adapter = getAdapter(adapterName);
|
||||
if (!adapter) return;
|
||||
|
||||
registerClient(conn, sessionId); // registerClient also resolves alias internally
|
||||
registerClient(conn, sessionId);
|
||||
sessionAdapterMap.set(resolvedId, adapterName);
|
||||
|
||||
// Clear pending push notifications for this session and update badge (only if there were pending)
|
||||
@@ -374,8 +468,6 @@ export async function handleReconnect(conn: ClientConnection, sessionId?: string
|
||||
const childAdapterObj = getAdapter(review.child_adapter);
|
||||
if (!childAdapterObj) continue;
|
||||
|
||||
// Check if child session still exists in adapter's in-memory Map.
|
||||
// If not (server restarted or windows killed), mark review as ended.
|
||||
if (!childAdapterObj.getSession(review.child_cli_session_id)) {
|
||||
sessionReviews.endReview(review.id);
|
||||
continue;
|
||||
@@ -397,14 +489,14 @@ export async function handleReconnect(conn: ClientConnection, sessionId?: string
|
||||
}
|
||||
|
||||
export async function handleSetModel(conn: ClientConnection, sessionId: string, model: string): Promise<void> {
|
||||
const { adapter, sid } = getAdapterForSession(conn, sessionId);
|
||||
const { adapter, sid } = await getAdapterForSession(conn, sessionId);
|
||||
if (adapter && sid) {
|
||||
await adapter.switchModel(sid, model);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSetPermissionMode(conn: ClientConnection, sessionId: string, mode: string): Promise<void> {
|
||||
const { adapter, sid } = getAdapterForSession(conn, sessionId);
|
||||
const { adapter, sid } = await getAdapterForSession(conn, sessionId);
|
||||
if (!sid || !adapter) return;
|
||||
|
||||
const success = await adapter.switchPermissionMode(sid, mode);
|
||||
@@ -512,4 +604,5 @@ export function getClientCount(sessionId: string): number {
|
||||
|
||||
export function registerSessionAdapter(sessionId: string, adapterName: string): void {
|
||||
sessionAdapterMap.set(sessionId, adapterName);
|
||||
sessionAdapters.set(sessionId, adapterName);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user