feat: ClawTap v0.1.0 — initial release

Multi-adapter mobile UI for AI coding assistants.
Supports Claude Code, Codex CLI, and Gemini CLI through one interface.

Features:
- Real-time bidirectional sync via tmux + WebSocket
- Cross-AI review (send one AI's output to another for review)
- Multi-review tabs with minimize/expand
- Push notifications (PWA) with smart session-aware filtering
- Three-channel event system (hooks, file watcher, pane monitor)
- Voice input, image paste, draft persistence
- Terminal-native design (JetBrains Mono, dark theme, pixel art claw)
- CLI with --adapter flag on every command
- Zero-overhead fire-and-forget hooks
This commit is contained in:
kuannnn
2026-03-18 10:24:45 +08:00
commit 42861ea7fa
151 changed files with 33897 additions and 0 deletions
+515
View File
@@ -0,0 +1,515 @@
import { get as getAdapter, getAll as getAllAdapters, DEFAULT_ADAPTER } from './adapters/registry.js';
import type { IAdapter } from './adapters/interface.js';
import { WS, PLAN_OPTION } from './ws-types.js';
import type { ClientMessage, QueryOptions, PermissionBehavior } from './types/messages.js';
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';
/** Push notification options */
interface PushOptions {
title: string;
body: string;
tagPrefix: string;
}
/** Send a push notification for a session event — only if nobody is viewing this session. */
function triggerPush(adapter: IAdapter, sessionId: string, { title, body, tagPrefix }: PushOptions): void {
const clients = sessionClients.get(sessionId);
if (clients && clients.size > 0) return;
// Skip push for child review sessions
if (sessionReviews.getAllChildIds().has(sessionId)) return;
const session = adapter.getSession(sessionId) as { cwd?: string } | null;
const projectName = basename(session?.cwd || '') || 'Unknown';
const badge = incrementPending(sessionId);
sendPush({
title,
body: `${body} in ${projectName}`,
tag: `${tagPrefix}-${sessionId}`,
data: { sessionId, badge },
}).catch((err: Error) => console.error('[push]', err.message));
}
/**
* SessionManager — bridges adapter events to connected clients.
*
* Responsibilities:
* - Client lifecycle: register, unregister, reconnect
* - Event routing: adapter events -> client broadcasts
* - Session routing: new/resume/reconnect
*
* Transport-agnostic: works with ClientConnection, never raw WebSocket.
* Adapter-generic: no direct imports of any specific adapter.
*/
const sessionClients = new Map<string, Set<ClientConnection>>(); // sessionId -> Set<conn>
const sessionAdapterMap = new Map<string, string>(); // sessionId -> adapterName
// Codex sessions rekey from temp key to real UUID. If rekey happens before the
// WS client connects (race condition with fast direct-match), the client registers
// under the old key. This alias map resolves old → new so late-connecting clients
// find the correct session.
const rekeyAliases = new Map<string, string>(); // oldKey -> newKey
export function setupSessionManager(): void {
const adapters = getAllAdapters();
for (const [name, adapter] of adapters) {
// Bridge adapter events -> client broadcasts (identical for every adapter)
adapter.on('streaming-text', (sessionId: string, text: string) => {
broadcast(sessionId, { type: WS.TEXT_DELTA, text });
});
adapter.on('thinking', (sessionId: string, thinking: { text: string; detail?: string }) => {
broadcast(sessionId, { type: WS.THINKING, text: thinking.text, detail: thinking.detail });
});
adapter.on('tool-start', (sessionId: string, data: { toolName: string; [key: string]: unknown }) => {
console.log(`[mgr] tool-start: ${data.toolName} for ${sessionId}`);
broadcast(sessionId, { type: WS.TOOL_START, ...data });
});
adapter.on('tool-done', (sessionId: string, data: { toolName: string; [key: string]: unknown }) => {
console.log(`[mgr] tool-done: ${data.toolName} for ${sessionId}`);
broadcast(sessionId, { type: WS.TOOL_DONE, ...data });
});
adapter.on('new-messages', (sessionId: string, messages: Array<{ role: string; [key: string]: unknown }>) => {
console.log(`[mgr] new-messages: ${messages.length} msgs (roles: ${messages.map(m => m.role).join(',')}) for ${sessionId}`);
broadcast(sessionId, { type: WS.MESSAGE_COMPLETE, messages });
});
adapter.on('tool-updates', (sessionId: string, tools: Record<string, unknown>) => {
broadcast(sessionId, { type: WS.TOOL_UPDATES, tools });
});
adapter.on('session-idle', (sessionId: string) => {
// Stop hook fired — do a final poll before broadcasting turn-complete
adapter.flushMessages(sessionId);
// Small delay to ensure the pollNow result is broadcast first
setTimeout(() => {
broadcast(sessionId, { type: WS.TURN_COMPLETE, sessionId });
}, 100);
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 });
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('status-update', (sessionId: string, status: Record<string, unknown>) => {
// Dedup is handled by the adapter — just broadcast
broadcast(sessionId, { type: WS.STATUS_UPDATE, ...status });
});
adapter.on('mode-changed', (sessionId: string, mode: string) => {
console.log(`[mgr] mode-changed: ${mode} for ${sessionId}`);
broadcast(sessionId, { type: WS.MODE_UPDATED, mode });
});
adapter.on('session-ended', (sessionId: string) => {
broadcast(sessionId, { type: WS.SESSION_ENDED });
// Cascade child reviews — BEFORE deleting client set so broadcasts reach clients
const activeChildren = sessionReviews.getActiveForParent(sessionId);
for (const child of activeChildren) {
sessionReviews.endReview(child.id);
broadcast(sessionId, { type: WS.REVIEW_ENDED, reviewId: child.id });
const childAdapterObj = getAdapter(child.child_adapter);
if (childAdapterObj) {
childAdapterObj.destroySession(child.child_cli_session_id).catch(() => {});
}
}
// THEN clean up maps
sessionClients.delete(sessionId);
sessionAdapterMap.delete(sessionId);
// Clean rekey alias pointing to this session
for (const [oldKey, newKey] of rekeyAliases) {
if (newKey === sessionId) rekeyAliases.delete(oldKey);
}
});
adapter.on('session-error', (sessionId: string, data: { errorType?: string; errorDetails?: string; [key: string]: unknown }) => {
broadcast(sessionId, { type: WS.SESSION_ERROR, ...data });
triggerPush(adapter, sessionId, {
title: 'Session Error',
body: data.errorType === 'rate_limit' ? 'Rate limited' : (data.errorDetails || data.errorType || 'Unknown error'),
tagPrefix: 'error',
});
});
adapter.on('compacting', (sessionId: string) => {
broadcast(sessionId, { type: WS.COMPACTING });
});
adapter.on('compact-done', (sessionId: string) => {
broadcast(sessionId, { type: WS.COMPACT_DONE });
});
adapter.on('processing-started', (sessionId: string) => {
broadcast(sessionId, { type: WS.SESSION_STATE, streaming: true });
});
// When Codex re-keys a session from temp key to real CLI UUID,
// move clients and adapter mapping to the new key
adapter.on('session-rekeyed', (oldKey: string, newKey: string) => {
// Store alias so late-connecting clients can resolve the old key
rekeyAliases.set(oldKey, newKey);
// Move clients from old key to new key
const clients = sessionClients.get(oldKey);
if (clients) {
sessionClients.delete(oldKey);
sessionClients.set(newKey, clients);
// Update each client's sessionId
for (const conn of clients) {
conn.sessionId = newKey;
}
}
// Move adapter mapping
const adapterName = sessionAdapterMap.get(oldKey);
if (adapterName) {
sessionAdapterMap.delete(oldKey);
sessionAdapterMap.set(newKey, adapterName);
}
// 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
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,
});
}
}
});
// Set client checker so adapter can decide whether to intercept hooks
adapter.setClientChecker((sessionId: string) => {
const clients = sessionClients.get(sessionId);
return !!(clients && clients.size > 0);
});
}
}
// === Helper: resolve adapter for a session ===
function getAdapterForSession(conn: ClientConnection, sessionId?: string): { adapter: IAdapter | undefined; sid: string } {
const sid = sessionId || conn.sessionId || '';
const name = sessionAdapterMap.get(sid) || DEFAULT_ADAPTER;
return { adapter: getAdapter(name), sid };
}
function sendSessionCreated(conn: ClientConnection, adapter: IAdapter, sessionId: string): void {
const sessionObj = adapter.getSession(sessionId) as {
permissionMode?: string;
approvalPolicy?: string;
} | null;
send(conn, {
type: WS.SESSION_CREATED,
sessionId,
permissionMode: sessionObj?.permissionMode || sessionObj?.approvalPolicy,
});
}
// === Centralized Message Router ===
export async function handleIncomingMessage(conn: ClientConnection, msg: ClientMessage): Promise<void> {
switch (msg.type) {
case WS.QUERY:
return handleQuery(conn, msg.prompt as string, (msg.options as QueryOptions) || {});
case WS.PERMISSION_RESPONSE:
return handlePermissionResponse(conn, msg.requestId as string, {
behavior: msg.behavior as string,
alwaysAllow: msg.alwaysAllow as boolean | undefined,
});
case WS.ASK_RESPONSE:
return handleAskResponse(conn, msg.requestId as string, msg.response as string);
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);
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);
default:
conn.send({ type: 'error', error: `Unknown message type: ${msg.type}` });
}
}
// === 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);
if (!adapter) throw new Error(`Unknown adapter: ${adapterName}`);
let handle: { sessionId: string };
if (sessionId) {
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);
registerClient(conn, handle.sessionId);
sendSessionCreated(conn, adapter, handle.sessionId);
// Send the message (images sent as text description for now)
let messageText = prompt;
if (!sessionId && adapterName !== 'claude') {
// New session — prepend marker for Codex UUID matching (Claude already knows its UUID)
messageText = `[CLAWTAP_REF:${handle.sessionId}]\n${prompt}`;
}
if (images && images.length > 0) {
messageText = messageText + '\n\n' + images.map((img: string) => `[Image: ${img}]`).join('\n');
}
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);
if (adapter) {
adapter.respondPermission(requestId, response.behavior);
broadcast(sid, { type: WS.PERMISSION_DISMISSED, requestId });
}
}
export async function handleAskResponse(conn: ClientConnection, requestId: string, answers: string): Promise<void> {
const { adapter, sid } = getAdapterForSession(conn);
if (adapter) {
adapter.respondQuestion(requestId, answers);
broadcast(sid, { type: WS.PERMISSION_DISMISSED, requestId });
}
}
export async function handleAbort(conn: ClientConnection, sessionId?: string): Promise<void> {
const { adapter, sid } = 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);
if (!sid || !adapter) return;
await adapter.respondPlan(sid, optionIndex, text);
// Broadcast synthetic user message so plan card transitions to read-only on ALL clients
// Options: 0=bypass (YOLO), 1=manually approve, 2=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 }] });
}
export async function handleReconnect(conn: ClientConnection, sessionId?: string, adapterHint?: 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 adapter = getAdapter(adapterName);
if (!adapter) return;
registerClient(conn, sessionId); // registerClient also resolves alias internally
sessionAdapterMap.set(resolvedId, adapterName);
// Clear pending push notifications for this session and update badge (only if there were pending)
if (getPendingSessions()[resolvedId]) {
const remaining = clearPending(resolvedId);
sendPush({ data: { badge: remaining } }).catch(() => {});
}
// Always send SESSION_CREATED on reconnect — includes permissionMode
sendSessionCreated(conn, adapter, resolvedId);
// Send cached status (context %, model, cost) if available
const lastStatus = adapter.getLastStatus(resolvedId);
if (lastStatus) {
send(conn, { type: WS.STATUS_UPDATE, ...lastStatus });
}
// Advance watcher past current file position BEFORE loading history —
// prevents watcher from emitting entries during the async getMessages() read
// that would duplicate what HISTORY_LOAD delivers
adapter.syncWatcherPosition(resolvedId);
// Send current messages from store (full history for reconnection)
try {
const { messages } = await adapter.getMessages(resolvedId);
if (messages.length > 0) {
send(conn, { type: WS.HISTORY_LOAD, messages });
}
} catch {}
// Notify client if session is actively processing
if (adapter.isProcessing(resolvedId)) {
send(conn, { type: WS.SESSION_STATE, streaming: true });
}
// Replay pending state (running tools, permission/question overlays)
const pending = adapter.getReconnectState(resolvedId);
if (pending.tools) {
send(conn, { type: WS.TOOL_UPDATES, tools: pending.tools });
}
for (const req of pending.pendingRequests) {
const { type: _type, ...rest } = req as Record<string, unknown>;
send(conn, { type: WS.PERMISSION_REQUEST, ...rest });
}
// Restore active child reviews
try {
const activeReviews = sessionReviews.getActiveForParent(resolvedId);
for (const review of activeReviews) {
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;
}
send(conn, {
type: WS.REVIEW_STARTED,
reviewId: review.id,
childSessionId: review.child_cli_session_id,
childCliSessionId: review.child_cli_session_id,
childAdapter: review.child_adapter,
anchorMessageId: review.anchor_message_id,
reviewTitle: review.review_title,
});
}
} catch (err) {
console.error('[handleReconnect] Failed to restore child reviews:', err);
}
}
export async function handleSetModel(conn: ClientConnection, sessionId: string, model: string): Promise<void> {
const { adapter, sid } = 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);
if (!sid || !adapter) return;
const success = await adapter.switchPermissionMode(sid, mode);
if (success) {
broadcast(sid, { type: WS.MODE_UPDATED, mode });
// Only auto-resolve permissions for cycle-type adapters where we know the exact mode
const capabilities = adapter.getCapabilities();
if (capabilities.permissionModeType !== 'toggle') {
if (mode === 'bypassPermissions') {
adapter.resolveAllPendingAs(sid, 'allow');
} else {
adapter.releaseAllPending(sid);
}
}
}
}
// === Client Management ===
function registerClient(conn: ClientConnection, sessionId: string): void {
// Resolve rekey alias: if sessionId was a temp key that's been re-keyed, use the new key
const resolvedId = rekeyAliases.get(sessionId) || sessionId;
if (resolvedId !== sessionId) {
send(conn, { type: WS.SESSION_CREATED, sessionId: resolvedId });
}
const existingSession = conn.sessionId;
if (existingSession === resolvedId) {
const set = sessionClients.get(resolvedId);
if (set) set.add(conn);
return;
}
// Remove from old session if switching
if (existingSession) {
const oldSet = sessionClients.get(existingSession);
if (oldSet) oldSet.delete(conn);
}
conn.sessionId = resolvedId;
let clients = sessionClients.get(resolvedId);
if (!clients) {
clients = new Set();
sessionClients.set(resolvedId, clients);
}
clients.add(conn);
// Set up disconnect handler (idempotent — ClientConnection fires 'close' once)
conn.onDisconnect = (c: ClientConnection) => {
const sid = c.sessionId;
if (!sid) return;
const set = sessionClients.get(sid);
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);
}
console.log(`[session-mgr] All clients disconnected from ${sid}, session persists`);
}
}
};
}
// === Broadcasting ===
function send(conn: ClientConnection, message: Record<string, unknown>): void {
if (conn.shouldReceive(message as any)) conn.send(message as any);
}
function broadcast(sessionId: string, message: Record<string, unknown>): void {
const clients = sessionClients.get(sessionId);
if (!clients || clients.size === 0) return;
const json = JSON.stringify(message);
for (const conn of clients) {
if (conn.shouldReceive(message as any)) conn.sendRaw(json);
}
}
// TODO: childCliSessionId is redundant with childSessionId (they are always the same
// CLI UUID now). Remove childCliSessionId from WS protocol and frontend state types.
export function broadcastReviewStarted(parentSessionId: string, review: {
reviewId: string;
childSessionId: string;
childCliSessionId: string;
childAdapter: string;
anchorMessageId?: string;
reviewTitle?: string;
}): void {
broadcast(parentSessionId, { type: WS.REVIEW_STARTED, ...review });
}
export function broadcastReviewEnded(parentSessionId: string, reviewId: string): void {
broadcast(parentSessionId, { type: WS.REVIEW_ENDED, reviewId });
}
export function getClientCount(sessionId: string): number {
const clients = sessionClients.get(sessionId);
return clients ? clients.size : 0;
}
export function registerSessionAdapter(sessionId: string, adapterName: string): void {
sessionAdapterMap.set(sessionId, adapterName);
}