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
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env node
/**
* End-to-End Verification Test
*
* Tests the full pipeline: Server → WS → tmux → Claude → hooks → JSONL → WS
*
* Captures a millisecond-precision timeline of ALL events and validates:
* 1. Event ordering (thinking → text-delta → tool events → message-complete → turn-complete)
* 2. Bidirectional sync (Desktop tmux ↔ Mobile WS)
* 3. Hook delivery (tool-start, tool-done from HTTP hooks)
* 4. JSONL-based messages (message-complete)
* 5. Streaming text preview (text-delta from pane capture)
*/
import { spawn } from 'child_process';
import WebSocket from 'ws';
import { readFileSync } from 'fs';
import { join } from 'path';
const PORT = 3458; // Avoid conflict with main server
const PASSWORD = 'test-e2e-' + Date.now();
const PROJECT_DIR = join(import.meta.dirname, '..', '..');
const events = [];
const startTime = Date.now();
function log(source, type, data = '') {
const elapsed = Date.now() - startTime;
const entry = { elapsed, source, type, data: typeof data === 'object' ? JSON.stringify(data).substring(0, 150) : String(data).substring(0, 150) };
events.push(entry);
const dataStr = entry.data ? ` ${entry.data}` : '';
console.log(` T+${String(elapsed).padStart(6)}ms ${source.padEnd(8)} ${type.padEnd(20)}${dataStr}`);
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function getToken() {
const res = await fetch(`http://localhost:${PORT}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: PASSWORD }),
});
const data = await res.json();
return data.token;
}
function connectWs(token) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://localhost:${PORT}/ws?token=${encodeURIComponent(token)}`);
ws.on('open', () => {
log('WS', 'connected');
resolve(ws);
});
ws.on('error', reject);
});
}
async function main() {
console.log('\n=== End-to-End Verification Test ===\n');
// 1. Start server
console.log('Starting server...');
const serverProcess = spawn('node', ['server/index.js'], {
cwd: PROJECT_DIR,
env: { ...process.env, PORT: String(PORT), CLAUDE_UI_PASSWORD: PASSWORD },
stdio: ['pipe', 'pipe', 'pipe'],
});
let serverReady = false;
serverProcess.stdout.on('data', (d) => {
const text = d.toString();
if (text.includes('running on')) serverReady = true;
});
serverProcess.stderr.on('data', (d) => {
const text = d.toString().trim();
if (text) console.error(` [server stderr] ${text.substring(0, 200)}`);
});
// Wait for server ready
for (let i = 0; i < 30; i++) {
if (serverReady) break;
await sleep(500);
}
if (!serverReady) {
console.error('Server failed to start');
serverProcess.kill();
process.exit(1);
}
log('SERVER', 'ready');
try {
// 2. Authenticate
const token = await getToken();
log('AUTH', 'token_received');
// 3. Connect WebSocket
const ws = await connectWs(token);
const wsMessages = [];
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
wsMessages.push(msg);
log('WS_RECV', msg.type, msg.type === 'text-delta' ? `"${(msg.text || '').substring(0, 80)}..."` :
msg.type === 'message-complete' ? `${msg.messages?.length || 0} messages` :
msg.type === 'tool-start' ? `${msg.toolName}` :
msg.type === 'tool-done' ? `${msg.toolName}` :
msg.type === 'thinking' ? msg.text :
msg.type === 'turn-complete' ? 'ready' :
msg.type === 'session-created' ? msg.sessionId :
msg.type === 'tool-updates' ? `${Object.keys(msg.tools || {}).length} tools` :
'');
});
// 4. Send a query that triggers tool use
const testPrompt = 'Read the file package.json and tell me just the "name" field. Be very brief, one sentence.';
log('SEND', 'query', testPrompt.substring(0, 60));
ws.send(JSON.stringify({
type: 'query',
prompt: testPrompt,
options: { cwd: PROJECT_DIR },
}));
// 5. Wait for response (up to 90 seconds)
let turnComplete = false;
const maxWait = 90000;
const waitStart = Date.now();
while (!turnComplete && Date.now() - waitStart < maxWait) {
await sleep(500);
turnComplete = wsMessages.some(m => m.type === 'turn-complete');
// Also check for message-complete as a fallback signal
if (!turnComplete && Date.now() - waitStart > 60000) {
turnComplete = wsMessages.some(m => m.type === 'message-complete');
}
}
if (!turnComplete) {
log('TIMEOUT', 'no_turn_complete', `waited ${maxWait}ms`);
}
// Wait a bit more for any trailing events
await sleep(2000);
ws.close();
log('WS', 'disconnected');
// 6. Analyze results
console.log('\n\n=== Analysis ===\n');
const sessionCreated = wsMessages.find(m => m.type === 'session-created');
const textDeltas = wsMessages.filter(m => m.type === 'text-delta');
const thinkingMsgs = wsMessages.filter(m => m.type === 'thinking');
const toolStarts = wsMessages.filter(m => m.type === 'tool-start');
const toolDones = wsMessages.filter(m => m.type === 'tool-done');
const messageCompletes = wsMessages.filter(m => m.type === 'message-complete');
const turnCompletes = wsMessages.filter(m => m.type === 'turn-complete');
const toolUpdates = wsMessages.filter(m => m.type === 'tool-updates');
const errors = wsMessages.filter(m => m.type === 'error');
console.log('Event counts:');
console.log(` session-created: ${sessionCreated ? 1 : 0}`);
console.log(` thinking: ${thinkingMsgs.length}`);
console.log(` text-delta: ${textDeltas.length}`);
console.log(` tool-start: ${toolStarts.length} (${toolStarts.map(m => m.toolName).join(', ')})`);
console.log(` tool-done: ${toolDones.length} (${toolDones.map(m => m.toolName).join(', ')})`);
console.log(` tool-updates: ${toolUpdates.length}`);
console.log(` message-complete: ${messageCompletes.length}`);
console.log(` turn-complete: ${turnCompletes.length}`);
console.log(` errors: ${errors.length} ${errors.map(m => m.error).join(', ')}`);
console.log('\n--- Checks ---\n');
const checks = [
{ name: 'Session created', pass: !!sessionCreated },
{ name: 'Got thinking or text-delta (streaming)', pass: thinkingMsgs.length > 0 || textDeltas.length > 0 },
{ name: 'Tool start received (hooks working)', pass: toolStarts.length > 0 },
{ name: 'Tool done received (hooks working)', pass: toolDones.length > 0 },
{ name: 'Tool start before tool done', pass: toolStarts.length > 0 && toolDones.length > 0 &&
events.findIndex(e => e.type === 'tool-start') < events.findIndex(e => e.type === 'tool-done') },
{ name: 'Message complete received (JSONL working)', pass: messageCompletes.length > 0 },
{ name: 'Turn complete received (Stop hook)', pass: turnCompletes.length > 0 },
{ name: 'No errors', pass: errors.length === 0 },
{ name: 'Message complete before turn complete', pass:
messageCompletes.length > 0 && turnCompletes.length > 0 &&
events.findIndex(e => e.type === 'message-complete') < events.findIndex(e => e.type === 'turn-complete') },
];
let allPass = true;
for (const c of checks) {
console.log(` ${c.pass ? '✓' : '✗'} ${c.name}`);
if (!c.pass) allPass = false;
}
// Show message content if available
if (messageCompletes.length > 0) {
const lastMsg = messageCompletes[messageCompletes.length - 1];
const textContent = lastMsg.messages?.flatMap(m =>
(m.content || []).filter(b => b.type === 'text').map(b => b.text)
).join(' ');
if (textContent) {
console.log(`\n Response: "${textContent.substring(0, 200)}"`);
}
}
console.log(`\n${allPass ? '✓ ALL CHECKS PASSED' : '✗ SOME CHECKS FAILED'}\n`);
return allPass;
} finally {
serverProcess.kill();
await sleep(1000);
}
}
main()
.then(pass => process.exit(pass ? 0 : 1))
.catch(err => {
console.error('Test error:', err);
process.exit(1);
});
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env node
/**
* MVP Test 1: Hook Delivery
*
* Validates that Claude Code HTTP hooks reach our server with correct data.
*
* Steps:
* 1. Start Express server on port 3457 (avoid conflict with main server)
* 2. Temporarily add hooks to ~/.claude/settings.local.json
* 3. Run: claude -p "read the file package.json" --verbose --output-format json
* 4. Collect all hook POSTs received
* 5. Assert: PreToolUse, PostToolUse, Stop received with correct data
* 6. Restore settings
*/
import express from 'express';
import { execSync, spawn } from 'child_process';
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
import { join } from 'path';
const PORT = 3457;
const SETTINGS_PATH = join(process.env.HOME, '.claude', 'settings.local.json');
const PROJECT_DIR = join(import.meta.dirname, '..', '..');
const TIMEOUT_MS = 60000;
// Event log
const events = [];
const startTime = Date.now();
function log(source, type, data = {}) {
const elapsed = Date.now() - startTime;
const entry = { elapsed, source, type, ...data };
events.push(entry);
console.log(` T+${String(elapsed).padStart(6)}ms ${source.padEnd(8)} ${type} ${JSON.stringify(data).substring(0, 120)}`);
}
async function main() {
console.log('\n=== MVP Test 1: Hook Delivery ===\n');
// Backup existing settings
let settingsBackup = null;
if (existsSync(SETTINGS_PATH)) {
settingsBackup = readFileSync(SETTINGS_PATH, 'utf-8');
console.log('Backed up existing settings.local.json');
}
// Write hook configuration
const hookConfig = {
hooks: {
PreToolUse: [{
matcher: '*',
hooks: [{ type: 'http', url: `http://localhost:${PORT}/hooks/pre-tool-use`, timeout: 10 }]
}],
PostToolUse: [{
matcher: '*',
hooks: [{ type: 'http', url: `http://localhost:${PORT}/hooks/post-tool-use`, timeout: 10 }]
}],
Stop: [{
hooks: [{ type: 'http', url: `http://localhost:${PORT}/hooks/stop`, timeout: 10 }]
}]
}
};
writeFileSync(SETTINGS_PATH, JSON.stringify(hookConfig, null, 2));
console.log(`Wrote hook config to ${SETTINGS_PATH}`);
// Start Express server
const app = express();
app.use(express.json({ limit: '10mb' }));
app.post('/hooks/pre-tool-use', (req, res) => {
log('HOOK', 'PreToolUse', {
tool_name: req.body.tool_name,
session_id: req.body.session_id?.substring(0, 8),
has_tool_input: !!req.body.tool_input,
has_tool_use_id: !!req.body.tool_use_id
});
res.json({});
});
app.post('/hooks/post-tool-use', (req, res) => {
log('HOOK', 'PostToolUse', {
tool_name: req.body.tool_name,
session_id: req.body.session_id?.substring(0, 8),
has_tool_response: !!req.body.tool_response
});
res.json({});
});
app.post('/hooks/stop', (req, res) => {
log('HOOK', 'Stop', {
session_id: req.body.session_id?.substring(0, 8),
});
res.json({});
});
const server = await new Promise((resolve) => {
const s = app.listen(PORT, () => {
console.log(`Hook server listening on port ${PORT}`);
resolve(s);
});
});
try {
// Run Claude CLI with a prompt that triggers tool use
console.log('\nRunning: claude -p "read the file package.json and tell me the project name" ...\n');
console.log('--- Event Timeline ---');
const claudeProcess = spawn('claude', [
'-p', 'Read the file package.json in the current directory and tell me what the "name" field is. Be brief.',
'--output-format', 'json',
'--dangerously-skip-permissions',
'--no-session-persistence'
], {
cwd: PROJECT_DIR,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
shell: true
});
let stdout = '';
let stderr = '';
claudeProcess.stdout.on('data', (data) => { stdout += data.toString(); });
claudeProcess.stderr.on('data', (data) => { stderr += data.toString(); });
// Wait for process to finish or timeout
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
claudeProcess.kill();
reject(new Error('Claude process timed out'));
}, TIMEOUT_MS);
claudeProcess.on('close', (code) => {
clearTimeout(timeout);
log('CLAUDE', 'exit', { code });
resolve();
});
claudeProcess.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
// Parse Claude output
try {
const result = JSON.parse(stdout);
log('RESULT', 'response', {
session_id: result.session_id?.substring(0, 8),
result_length: result.result?.length,
});
} catch {
log('RESULT', 'raw', { length: stdout.length, preview: stdout.substring(0, 100) });
}
// Wait a moment for any trailing hooks
await new Promise(r => setTimeout(r, 2000));
// Analyze results
console.log('\n--- Results ---\n');
const preToolUseEvents = events.filter(e => e.type === 'PreToolUse');
const postToolUseEvents = events.filter(e => e.type === 'PostToolUse');
const stopEvents = events.filter(e => e.type === 'Stop');
const checks = [
{
name: 'PreToolUse hook received',
pass: preToolUseEvents.length > 0,
detail: `${preToolUseEvents.length} event(s): ${preToolUseEvents.map(e => e.tool_name).join(', ')}`
},
{
name: 'PreToolUse has tool_name',
pass: preToolUseEvents.every(e => !!e.tool_name),
detail: preToolUseEvents.map(e => e.tool_name).join(', ')
},
{
name: 'PreToolUse has session_id',
pass: preToolUseEvents.every(e => !!e.session_id),
detail: preToolUseEvents[0]?.session_id || 'N/A'
},
{
name: 'PreToolUse has tool_input',
pass: preToolUseEvents.every(e => e.has_tool_input),
detail: ''
},
{
name: 'PostToolUse hook received',
pass: postToolUseEvents.length > 0,
detail: `${postToolUseEvents.length} event(s): ${postToolUseEvents.map(e => e.tool_name).join(', ')}`
},
{
name: 'PostToolUse has tool_response',
pass: postToolUseEvents.every(e => e.has_tool_response),
detail: ''
},
{
name: 'Stop hook received',
pass: stopEvents.length > 0,
detail: `${stopEvents.length} event(s)`
},
{
name: 'PreToolUse before PostToolUse',
pass: preToolUseEvents.length > 0 && postToolUseEvents.length > 0 &&
preToolUseEvents[0].elapsed < postToolUseEvents[0].elapsed,
detail: preToolUseEvents.length > 0 && postToolUseEvents.length > 0
? `Pre: T+${preToolUseEvents[0].elapsed}ms, Post: T+${postToolUseEvents[0].elapsed}ms`
: 'N/A'
},
{
name: 'Stop after PostToolUse',
pass: stopEvents.length > 0 && postToolUseEvents.length > 0 &&
stopEvents[stopEvents.length - 1].elapsed >= postToolUseEvents[postToolUseEvents.length - 1].elapsed,
detail: stopEvents.length > 0 && postToolUseEvents.length > 0
? `Post: T+${postToolUseEvents[postToolUseEvents.length - 1].elapsed}ms, Stop: T+${stopEvents[stopEvents.length - 1].elapsed}ms`
: 'N/A'
},
];
let allPass = true;
for (const check of checks) {
const icon = check.pass ? '✓' : '✗';
console.log(` ${icon} ${check.name}${check.detail ? `${check.detail}` : ''}`);
if (!check.pass) allPass = false;
}
console.log(`\n${allPass ? '✓ ALL CHECKS PASSED' : '✗ SOME CHECKS FAILED'}\n`);
if (stderr && !allPass) {
console.log('Claude stderr (first 500 chars):');
console.log(stderr.substring(0, 500));
}
return allPass;
} finally {
// Cleanup
server.close();
if (settingsBackup) {
writeFileSync(SETTINGS_PATH, settingsBackup);
console.log('Restored settings.local.json backup');
} else {
try { unlinkSync(SETTINGS_PATH); } catch {}
console.log('Removed temporary settings.local.json');
}
}
}
main()
.then(pass => process.exit(pass ? 0 : 1))
.catch(err => {
console.error('Test error:', err);
// Cleanup settings on error
try { unlinkSync(SETTINGS_PATH); } catch {}
process.exit(1);
});
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env node
/**
* MVP Test 2: JSONL Watcher with fs.watch()
*
* Validates that fs.watch() + byte-offset reading reliably detects new JSONL entries.
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
const TEMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-test-'));
const TEST_FILE = path.join(TEMP_DIR, 'test.jsonl');
class ImprovedJsonlWatcher {
constructor(filePath) {
this.filePath = filePath;
this.lastByteOffset = 0;
this._onEntries = null;
this._fsWatcher = null;
this._fallbackInterval = null;
}
start({ skipExisting = false } = {}) {
if (skipExisting) {
try { this.lastByteOffset = fs.statSync(this.filePath).size; } catch {}
}
// Primary: fs.watch() for instant notification
this._fsWatcher = fs.watch(this.filePath, (event) => {
// console.log(` [dbg]fs.watch event: ${event}, calling poll`);
this._poll();
});
// Fallback: poll every 2s
this._fallbackInterval = setInterval(() => this._poll(), 2000);
// Initial poll
this._poll();
}
stop() {
if (this._fsWatcher) { this._fsWatcher.close(); this._fsWatcher = null; }
if (this._fallbackInterval) { clearInterval(this._fallbackInterval); this._fallbackInterval = null; }
}
onNewEntries(cb) { this._onEntries = cb; }
pollNow() { this._poll(); }
_poll() {
try {
const stats = fs.statSync(this.filePath);
// console.log(` [dbg]poll: size=${stats.size} offset=${this.lastByteOffset}`);
if (stats.size <= this.lastByteOffset) return;
const newSize = stats.size - this.lastByteOffset;
const buffer = Buffer.alloc(newSize);
const fd = fs.openSync(this.filePath, 'r');
fs.readSync(fd, buffer, 0, newSize, this.lastByteOffset);
fs.closeSync(fd);
const text = buffer.toString('utf-8');
const lines = text.split('\n');
// Remove trailing empty string from split (artifact of text ending with \n)
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
const entries = [];
let bytesConsumed = 0;
for (const line of lines) {
const lineBytes = Buffer.byteLength(line + '\n', 'utf-8');
if (!line.trim()) { bytesConsumed += lineBytes; continue; }
try {
entries.push(JSON.parse(line));
bytesConsumed += lineBytes;
} catch {
break; // partial line — don't advance offset, retry next poll
}
}
this.lastByteOffset += bytesConsumed;
if (entries.length > 0 && this._onEntries) this._onEntries(entries);
} catch (err) {
if (err.code !== 'ENOENT') console.error('Poll error:', err.message);
}
}
}
async function main() {
console.log('\n=== MVP Test 2: JSONL Watcher with fs.watch() ===\n');
fs.writeFileSync(TEST_FILE, '');
const watcher = new ImprovedJsonlWatcher(TEST_FILE);
const detections = [];
const appends = [];
const startTime = Date.now();
watcher.onNewEntries((entries) => {
for (const entry of entries) {
detections.push({ elapsed: Date.now() - startTime, id: entry.id, latency: Date.now() - entry.t });
}
});
watcher.start();
// Append entries at various delays
const appendAt = [100, 250, 500, 800, 1200, 1700, 2300, 3000];
for (const t of appendAt) {
await new Promise(r => setTimeout(r, t - (appends.length > 0 ? appendAt[appends.length - 1] : 0)));
const now = Date.now();
fs.appendFileSync(TEST_FILE, JSON.stringify({ id: appends.length, t: now }) + '\n');
appends.push({ id: appends.length, elapsed: now - startTime });
}
await new Promise(r => setTimeout(r, 2500));
// Test partial JSON line
console.log('Testing partial JSON handling...');
const partial = JSON.stringify({ id: 99, t: Date.now() });
fs.appendFileSync(TEST_FILE, partial.substring(0, 20)); // Write half
await new Promise(r => setTimeout(r, 500));
const beforeCount = detections.length;
fs.appendFileSync(TEST_FILE, partial.substring(20) + '\n'); // Complete it
await new Promise(r => setTimeout(r, 2500));
watcher.stop();
// Print timeline
console.log('\n--- Event Timeline ---\n');
for (const a of appends) {
const d = detections.find(d => d.id === a.id);
const status = d ? `detected T+${String(d.elapsed).padStart(5)}ms (latency: ${d.latency}ms)` : 'NOT DETECTED';
console.log(` Entry ${a.id}: appended T+${String(a.elapsed).padStart(5)}ms → ${status}`);
}
const pd = detections.find(d => d.id === 99);
console.log(` Entry 99 (partial): ${pd ? `detected (latency: ${pd.latency}ms)` : 'NOT DETECTED'}`);
// Validate
console.log('\n--- Results ---\n');
const allDetected = appends.every(a => detections.some(d => d.id === a.id));
const noDups = new Set(detections.map(d => d.id)).size === detections.length;
const lats = detections.filter(d => d.id !== 99).map(d => d.latency);
const avg = lats.length ? Math.round(lats.reduce((a, b) => a + b) / lats.length) : -1;
const max = lats.length ? Math.max(...lats) : -1;
const checks = [
{ name: 'All entries detected', pass: allDetected, detail: `${detections.filter(d => d.id !== 99).length}/${appends.length}` },
{ name: 'No duplicates', pass: noDups, detail: `${detections.length} unique` },
{ name: 'Avg latency < 200ms', pass: avg >= 0 && avg < 200, detail: `avg=${avg}ms max=${max}ms` },
{ name: 'Partial JSON handled', pass: !!pd, detail: pd ? `latency=${pd.latency}ms` : 'missed' },
];
let allPass = true;
for (const c of checks) {
console.log(` ${c.pass ? '✓' : '✗'} ${c.name}${c.detail}`);
if (!c.pass) allPass = false;
}
console.log(`\n${allPass ? '✓ ALL CHECKS PASSED' : '✗ SOME CHECKS FAILED'}\n`);
try { fs.unlinkSync(TEST_FILE); } catch {}
return allPass;
}
main().then(p => process.exit(p ? 0 : 1)).catch(e => { console.error(e); process.exit(1); });