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:
@@ -0,0 +1,37 @@
|
||||
import crypto from 'crypto';
|
||||
import type { ServerMessage } from '../types/messages.js';
|
||||
|
||||
/**
|
||||
* ClientConnection — abstract base class for transport-agnostic client connections.
|
||||
*
|
||||
* Each connected client (WebSocket, SSE, etc.) is represented by a ClientConnection.
|
||||
* SessionManager works exclusively with this abstraction — never with raw WebSocket.
|
||||
*/
|
||||
export abstract class ClientConnection {
|
||||
readonly clientId: string = crypto.randomUUID();
|
||||
readonly transportName: string;
|
||||
sessionId: string | null = null;
|
||||
onDisconnect: ((conn: ClientConnection) => void) | null = null;
|
||||
|
||||
constructor(transportName: string) {
|
||||
this.transportName = transportName;
|
||||
}
|
||||
|
||||
/** Send a message to this client. Implementation handles serialization. */
|
||||
abstract send(message: ServerMessage): void;
|
||||
|
||||
/** Check if the connection is still alive. */
|
||||
abstract isAlive(): boolean;
|
||||
|
||||
/** Close the connection. */
|
||||
close(): void {}
|
||||
|
||||
/** Filter: should this client receive a given message? Default: yes. */
|
||||
shouldReceive(_message: ServerMessage): boolean { return true; }
|
||||
|
||||
/** Send a pre-serialized JSON string. Default fallback parses and calls send(). */
|
||||
sendRaw(json: string): void { this.send(JSON.parse(json)); }
|
||||
|
||||
/** Notify the disconnect handler (called by subclass when connection drops). */
|
||||
notifyDisconnect(): void { this.onDisconnect?.(this); }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type WebSocket from 'ws';
|
||||
import { ClientConnection } from './client-connection.js';
|
||||
import type { ServerMessage } from '../types/messages.js';
|
||||
|
||||
/**
|
||||
* WebSocketConnection — wraps a raw ws.WebSocket as a ClientConnection.
|
||||
*/
|
||||
export class WebSocketConnection extends ClientConnection {
|
||||
private ws: WebSocket;
|
||||
|
||||
constructor(ws: WebSocket) {
|
||||
super('websocket');
|
||||
this.ws = ws;
|
||||
ws.on('close', () => this.notifyDisconnect());
|
||||
}
|
||||
|
||||
send(message: ServerMessage): void {
|
||||
try {
|
||||
if (this.ws.readyState === 1) this.ws.send(JSON.stringify(message));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
sendRaw(json: string): void {
|
||||
try {
|
||||
if (this.ws.readyState === 1) this.ws.send(json);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
isAlive(): boolean {
|
||||
return this.ws.readyState === 1;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.ws.close();
|
||||
}
|
||||
|
||||
/** Access the underlying WebSocket (for ping/pong, etc.) */
|
||||
get rawWs(): WebSocket { return this.ws; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import type WebSocket from 'ws';
|
||||
import type { Server as HttpServer } from 'http';
|
||||
import type { Server as HttpsServer } from 'https';
|
||||
import { verifyWebSocketToken } from '../auth.js';
|
||||
import { WebSocketConnection } from './websocket-connection.js';
|
||||
import type { ClientConnection } from './client-connection.js';
|
||||
import type { ClientMessage } from '../types/messages.js';
|
||||
|
||||
/**
|
||||
* WebSocketTransport — manages the WebSocket server lifecycle.
|
||||
*
|
||||
* Emits:
|
||||
* 'connection' (conn: ClientConnection) — new authenticated client connected
|
||||
* 'message' (conn: ClientConnection, msg: ClientMessage) — parsed message from client
|
||||
*/
|
||||
export class WebSocketTransport extends EventEmitter {
|
||||
private wss: WebSocketServer | null = null;
|
||||
private pingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Create WebSocketServer on /ws path with JWT verification and ping/pong keepalive. */
|
||||
setup(server: HttpServer | HttpsServer): void {
|
||||
this.wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/ws',
|
||||
verifyClient: ({ req }, cb) => {
|
||||
const url = new URL(req.url!, `http://${req.headers.host}`);
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token || !verifyWebSocketToken(token)) {
|
||||
cb(false, 401, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
cb(true);
|
||||
},
|
||||
});
|
||||
|
||||
this.wss.on('connection', (ws: WebSocket) => {
|
||||
const conn = new WebSocketConnection(ws);
|
||||
|
||||
this.emit('connection', conn);
|
||||
|
||||
ws.on('message', (raw: Buffer | ArrayBuffer | Buffer[]) => {
|
||||
let msg: ClientMessage;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString()) as ClientMessage;
|
||||
} catch {
|
||||
conn.send({ type: 'error', error: 'Invalid JSON' });
|
||||
return;
|
||||
}
|
||||
this.emit('message', conn, msg);
|
||||
});
|
||||
});
|
||||
|
||||
// Ping/pong keepalive every 30s
|
||||
this.pingInterval = setInterval(() => {
|
||||
if (!this.wss) return;
|
||||
for (const ws of this.wss.clients) {
|
||||
if (ws.readyState === 1) {
|
||||
ws.ping();
|
||||
}
|
||||
}
|
||||
}, 30_000);
|
||||
this.pingInterval.unref();
|
||||
}
|
||||
|
||||
/** Shut down the WebSocket server and stop keepalive. */
|
||||
destroy(): void {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
if (this.wss) {
|
||||
this.wss.close();
|
||||
this.wss = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user