0fcf66fc22
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>
513 lines
19 KiB
TypeScript
513 lines
19 KiB
TypeScript
import express from 'express';
|
|
import type { Request, Response } from 'express';
|
|
import { createServer } from 'http';
|
|
import { createServer as createHttpsServer } from 'https';
|
|
import { fileURLToPath } from 'url';
|
|
import { dirname, join, resolve } from 'path';
|
|
import { homedir } from 'os';
|
|
import { readFileSync } from 'fs';
|
|
import {
|
|
initAuth,
|
|
login,
|
|
authMiddleware,
|
|
} from './auth.js';
|
|
import './adapters/init.js';
|
|
import { initAll, installAllHooks, listAvailable, get as getAdapter, getAll as getAllAdapters, cleanupAll, DEFAULT_ADAPTER } from './adapters/registry.js';
|
|
import { initPush, getVapidPublicKey, saveSubscription, removeSubscription, getPendingSessions } from './push.js';
|
|
import {
|
|
setupSessionManager,
|
|
handleIncomingMessage,
|
|
getClientCount,
|
|
broadcastReviewStarted,
|
|
broadcastReviewEnded,
|
|
registerSessionAdapter,
|
|
} from './session-manager.js';
|
|
import { WebSocketTransport } from './transport/websocket-transport.js';
|
|
import { loadConfig } from './config.js';
|
|
import type { AppConfig } from './config.js';
|
|
import { initDB, closeDB, sessionReviews, sessionAdapters, savedInstructions } from './db.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
import multer from 'multer';
|
|
import { randomUUID } from 'crypto';
|
|
import { mkdirSync, writeFileSync, unlinkSync } from 'fs';
|
|
import type { Server as HttpServer } from 'http';
|
|
import type { Server as HttpsServer } from 'https';
|
|
|
|
// --- Start ---
|
|
|
|
async function start(): Promise<void> {
|
|
const config = loadConfig();
|
|
initDB(config);
|
|
|
|
const app = express();
|
|
app.use(express.json({ limit: '10mb' }));
|
|
|
|
// Image upload config
|
|
const uploadDir = config.paths.uploads;
|
|
mkdirSync(uploadDir, { recursive: true });
|
|
const upload = multer({
|
|
storage: multer.diskStorage({
|
|
destination: uploadDir,
|
|
filename: (_req: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
|
|
const ext = file.originalname.split('.').pop() || 'png';
|
|
cb(null, `${randomUUID()}.${ext}`);
|
|
},
|
|
}),
|
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
|
fileFilter: (_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
|
if (file.mimetype.startsWith('image/')) cb(null, true);
|
|
else cb(new Error('Only images allowed'));
|
|
},
|
|
});
|
|
|
|
// Serve built frontend in production
|
|
const distPath: string = join(__dirname, '..', 'dist');
|
|
app.use(express.static(distPath));
|
|
|
|
// --- REST Routes ---
|
|
|
|
app.get('/health', (_req: Request, res: Response) => {
|
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
res.json({ status: 'ok', branch: config.gitBranch, version: pkg.version });
|
|
});
|
|
|
|
app.post('/api/auth/login', async (req: Request, res: Response) => {
|
|
const { password } = req.body as { password?: string };
|
|
if (!password) {
|
|
return res.status(400).json({ error: 'Password required' });
|
|
}
|
|
const ip: string = req.ip || (req as any).connection?.remoteAddress || 'unknown';
|
|
const result = await login(password, ip);
|
|
if ('error' in result) {
|
|
return res.status((result as { error: string; status: number }).status).json({ error: result.error });
|
|
}
|
|
res.json({ token: (result as { token: string }).token });
|
|
});
|
|
|
|
app.get('/api/sessions', authMiddleware, async (req: Request, res: Response) => {
|
|
try {
|
|
const { dir, limit } = req.query as { dir?: string; limit?: string };
|
|
const parsedLimit = limit ? parseInt(limit) : 0;
|
|
const adapters = getAllAdapters();
|
|
const results = await Promise.all(
|
|
[...adapters.entries()].map(([name, adapter]) =>
|
|
adapter.getSessions(dir, parsedLimit || undefined)
|
|
.then(sessions => sessions.map(s => ({ ...s, adapter: name })))
|
|
.catch(err => { console.warn(`[sessions] Failed to get sessions from ${name}:`, (err as Error).message); return [] as any[]; })
|
|
)
|
|
);
|
|
const allSessions = results.flat();
|
|
// Persist session→adapter mapping so server knows which adapter owns each session
|
|
for (const s of allSessions) {
|
|
if (s.sessionId && s.adapter) sessionAdapters.set(s.sessionId, s.adapter);
|
|
}
|
|
allSessions.sort((a, b) => {
|
|
const aTime = typeof a.lastModified === 'number' ? a.lastModified : new Date(a.lastModified || 0).getTime();
|
|
const bTime = typeof b.lastModified === 'number' ? b.lastModified : new Date(b.lastModified || 0).getTime();
|
|
return bTime - aTime;
|
|
});
|
|
if (parsedLimit > 0) allSessions.splice(parsedLimit);
|
|
const childIds = sessionReviews.getAllChildIds();
|
|
const filtered = allSessions.filter((s: any) => !childIds.has(s.sessionId));
|
|
res.json(filtered);
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/upload', authMiddleware, upload.single('image'), (req: Request, res: Response) => {
|
|
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
|
|
res.json({ path: req.file.path, filename: req.file.filename });
|
|
});
|
|
|
|
app.get('/api/sessions/:id/messages', authMiddleware, async (req: Request, res: Response) => {
|
|
try {
|
|
const { adapter: adapterName, dir } = req.query as { adapter?: string; dir?: string };
|
|
const adapter = getAdapter(adapterName || DEFAULT_ADAPTER);
|
|
const messages = await adapter!.getMessages(req.params.id as string, dir);
|
|
res.json(messages);
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/browse', authMiddleware, async (req: Request, res: Response) => {
|
|
try {
|
|
const { path: queryPath, adapter: adapterName } = req.query as { path?: string; adapter?: string };
|
|
const requestedPath = queryPath ? resolve(queryPath) : undefined;
|
|
const home = homedir();
|
|
if (requestedPath && !requestedPath.startsWith(home)) {
|
|
return res.status(403).json({ error: 'Browsing restricted to home directory' });
|
|
}
|
|
const adapter = getAdapter(adapterName || DEFAULT_ADAPTER);
|
|
const dirs = await adapter!.listDirectory(requestedPath);
|
|
res.json(dirs);
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/active-sessions', authMiddleware, (req: Request, res: Response) => {
|
|
try {
|
|
const { adapter: adapterName } = req.query as { adapter?: string };
|
|
|
|
let allActiveSessions: any[] = [];
|
|
|
|
if (adapterName) {
|
|
const adapter = getAdapter(adapterName);
|
|
if (adapter?.getActiveSessions) {
|
|
allActiveSessions = adapter.getActiveSessions();
|
|
}
|
|
} else {
|
|
// No adapter specified — aggregate all adapters
|
|
const adapters = getAllAdapters();
|
|
for (const [, adapter] of adapters) {
|
|
if (adapter.getActiveSessions) {
|
|
allActiveSessions.push(...adapter.getActiveSessions());
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const s of allActiveSessions) {
|
|
const count = getClientCount(s.sessionId);
|
|
const totalCount = count + (s.hasDesktop ? 1 : 0);
|
|
s.hasClients = totalCount > 0;
|
|
(s as any).clientCount = totalCount;
|
|
}
|
|
const childIds = sessionReviews.getAllChildIds();
|
|
const filteredActive = allActiveSessions.filter((s: any) => !childIds.has(s.sessionId));
|
|
res.json(filteredActive);
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
// --- Adapter Discovery Endpoints ---
|
|
|
|
app.get('/api/adapters', authMiddleware, (_req: Request, res: Response) => {
|
|
res.json(listAvailable());
|
|
});
|
|
|
|
app.get('/api/adapter/:name/config', authMiddleware, (req: Request, res: Response) => {
|
|
const adapter = getAdapter(req.params.name as string);
|
|
if (!adapter) return res.status(404).json({ error: 'Adapter not found' });
|
|
res.json({
|
|
models: adapter.getModels(),
|
|
permissionModes: adapter.getPermissionModes(),
|
|
effortLevels: adapter.getEffortLevels(),
|
|
effortLabel: adapter.getEffortLabel(),
|
|
capabilities: adapter.getCapabilities(),
|
|
});
|
|
});
|
|
|
|
// --- Session Management ---
|
|
|
|
app.delete('/api/active-sessions/:id', authMiddleware, async (req: Request, res: Response) => {
|
|
try {
|
|
const { adapter: adapterName } = req.query as { adapter?: string };
|
|
const adapter = getAdapter(adapterName || DEFAULT_ADAPTER);
|
|
await adapter!.destroySession(req.params.id as string);
|
|
res.json({ ok: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/sessions/start', authMiddleware, async (req: Request, res: Response) => {
|
|
try {
|
|
const { adapter: adapterName, cwd, model, permissionMode } = req.body;
|
|
if (!cwd) return res.status(400).json({ error: 'cwd required' });
|
|
|
|
const adapter = getAdapter(adapterName || DEFAULT_ADAPTER);
|
|
if (!adapter) return res.status(400).json({ error: `Unknown adapter: ${adapterName}` });
|
|
|
|
const handle = await adapter.startSession(cwd, { model, permissionMode });
|
|
registerSessionAdapter(handle.sessionId, adapterName || DEFAULT_ADAPTER);
|
|
|
|
res.json({ sessionId: handle.sessionId });
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/sessions/resume', authMiddleware, async (req: Request, res: Response) => {
|
|
try {
|
|
const { sessionId, adapter: adapterName, cwd } = req.body;
|
|
if (!sessionId) return res.status(400).json({ error: 'sessionId required' });
|
|
|
|
const resolvedAdapter = adapterName || DEFAULT_ADAPTER;
|
|
const adapter = getAdapter(resolvedAdapter);
|
|
if (!adapter) return res.status(400).json({ error: `Unknown adapter: ${resolvedAdapter}` });
|
|
|
|
const handle = await adapter.resumeSession(sessionId, cwd || process.cwd());
|
|
registerSessionAdapter(handle.sessionId, resolvedAdapter);
|
|
|
|
res.json({ sessionId: handle.sessionId });
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
// --- Review API ---
|
|
|
|
// Register a review after the child session is already created via QUERY
|
|
app.post('/api/reviews/register', authMiddleware, async (req: Request, res: Response) => {
|
|
try {
|
|
const { reviewId, parentCliSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title } = req.body;
|
|
if (!reviewId || !parentCliSessionId || !childSessionId) {
|
|
return res.status(400).json({ error: 'reviewId, parentCliSessionId and childSessionId required' });
|
|
}
|
|
|
|
// Find which adapter owns the parent session
|
|
let parentAdapterName = DEFAULT_ADAPTER;
|
|
for (const [name, a] of getAllAdapters()) {
|
|
if (a.getSession(parentCliSessionId)) { parentAdapterName = name; break; }
|
|
}
|
|
|
|
sessionReviews.create(reviewId, parentCliSessionId, childSessionId, targetAdapter, parentAdapterName, anchorMessageId, prompt, title);
|
|
|
|
// Ensure adapter mapping exists for the child session
|
|
registerSessionAdapter(childSessionId, targetAdapter);
|
|
|
|
broadcastReviewStarted(parentCliSessionId, {
|
|
reviewId,
|
|
childSessionId,
|
|
childCliSessionId: childSessionId,
|
|
childAdapter: targetAdapter,
|
|
anchorMessageId,
|
|
reviewTitle: title,
|
|
});
|
|
|
|
res.json({ reviewId });
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/reviews/:id', authMiddleware, async (req: Request, res: Response) => {
|
|
try {
|
|
const review = sessionReviews.getById(req.params.id);
|
|
if (!review) return res.status(404).json({ error: 'Review not found' });
|
|
|
|
const { endAnchorMessageId } = req.body || {};
|
|
sessionReviews.endReview(review.id, 0, endAnchorMessageId);
|
|
|
|
// Broadcast to parent WS clients
|
|
broadcastReviewEnded(review.parent_cli_session_id, review.id);
|
|
|
|
// Try to destroy child tmux session
|
|
const childAdapter = getAdapter(review.child_adapter);
|
|
if (childAdapter) {
|
|
try {
|
|
await childAdapter.destroySession(review.child_cli_session_id);
|
|
} catch (err) {
|
|
console.error('[review] Failed to destroy child session:', (err as Error).message);
|
|
}
|
|
}
|
|
|
|
res.json({ ok: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/reviews/:id/send-back', authMiddleware, async (req: Request, res: Response) => {
|
|
try {
|
|
const review = sessionReviews.getById(req.params.id);
|
|
if (!review) return res.status(404).json({ error: 'Review not found' });
|
|
|
|
const { message } = req.body;
|
|
if (!message) return res.status(400).json({ error: 'message required' });
|
|
|
|
// Find parent session from adapter's in-memory Map
|
|
const parentAdapter = getAdapter(review.parent_adapter || DEFAULT_ADAPTER);
|
|
if (!parentAdapter) return res.status(400).json({ error: 'Parent adapter not found' });
|
|
|
|
const parentSessionId = review.parent_cli_session_id;
|
|
if (!parentAdapter.getSession(parentSessionId)) {
|
|
return res.status(404).json({ error: 'Parent session not found' });
|
|
}
|
|
|
|
// Check if parent is busy
|
|
if (parentAdapter.isProcessing(parentSessionId)) {
|
|
return res.status(409).json({ error: 'Parent session is busy. Wait for the current turn to complete.' });
|
|
}
|
|
|
|
// Format and send
|
|
const formatted = `[Review feedback from ${review.child_adapter}]:\n${message}`;
|
|
await parentAdapter.sendMessage(parentSessionId, formatted);
|
|
|
|
res.json({ ok: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/reviews', authMiddleware, (req: Request, res: Response) => {
|
|
try {
|
|
const { parentCliSessionId } = req.query as { parentCliSessionId?: string };
|
|
if (!parentCliSessionId) return res.status(400).json({ error: 'parentCliSessionId required' });
|
|
|
|
const reviews = sessionReviews.getAllForParent(parentCliSessionId);
|
|
res.json(reviews);
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
// --- Saved Instructions API ---
|
|
|
|
app.get('/api/instructions', authMiddleware, (_req: Request, res: Response) => {
|
|
try {
|
|
res.json(savedInstructions.getAll());
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/instructions', authMiddleware, (req: Request, res: Response) => {
|
|
try {
|
|
const { label, instruction } = req.body;
|
|
if (!label || !instruction) return res.status(400).json({ error: 'label and instruction required' });
|
|
const id = randomUUID();
|
|
savedInstructions.create(id, label, instruction);
|
|
res.json({ id, label, instruction });
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/instructions/:id', authMiddleware, (req: Request, res: Response) => {
|
|
try {
|
|
savedInstructions.delete(req.params.id);
|
|
res.json({ ok: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
});
|
|
|
|
// --- Push Notification API ---
|
|
app.get('/api/push/vapid-public-key', authMiddleware, (_req: Request, res: Response) => {
|
|
res.json({ publicKey: getVapidPublicKey() });
|
|
});
|
|
|
|
app.post('/api/push/subscribe', authMiddleware, (req: Request, res: Response) => {
|
|
const { subscription } = req.body as { subscription?: { endpoint?: string } };
|
|
if (!subscription?.endpoint) return res.status(400).json({ error: 'Missing subscription' });
|
|
saveSubscription(subscription as any);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.post('/api/push/unsubscribe', authMiddleware, (req: Request, res: Response) => {
|
|
const { endpoint } = req.body as { endpoint?: string };
|
|
if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' });
|
|
removeSubscription(endpoint);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.get('/api/push/pending', authMiddleware, (_req: Request, res: Response) => {
|
|
res.json(getPendingSessions());
|
|
});
|
|
|
|
// SPA fallback
|
|
app.get('*path', (_req: Request, res: Response) => {
|
|
res.sendFile(join(distPath, 'index.html'));
|
|
});
|
|
|
|
// --- Server + WebSocket ---
|
|
|
|
let server: HttpServer | HttpsServer;
|
|
if (config.https) {
|
|
server = createHttpsServer({ cert: config.https.cert, key: config.https.key }, app);
|
|
} else {
|
|
server = createServer(app);
|
|
}
|
|
|
|
// --- WebSocket Transport ---
|
|
const wsTransport = new WebSocketTransport();
|
|
wsTransport.setup(server);
|
|
wsTransport.on('connection', (conn) => {
|
|
conn.send({ type: 'client-id', clientId: conn.clientId });
|
|
});
|
|
wsTransport.on('message', async (conn, msg) => {
|
|
try {
|
|
await handleIncomingMessage(conn, msg);
|
|
} catch (err) {
|
|
conn.send({ type: 'error', error: (err as Error).message });
|
|
}
|
|
});
|
|
|
|
// Register adapter routes (before listen — routes don't depend on port)
|
|
initAll(app);
|
|
|
|
setupSessionManager();
|
|
|
|
// --- Find available port and Listen ---
|
|
|
|
await initAuth(config);
|
|
initPush(config);
|
|
|
|
const protocol = config.https ? 'https' : 'http';
|
|
const actualPort = await new Promise<number>((resolve, reject) => {
|
|
const maxRetries = 10;
|
|
let attempt = 0;
|
|
function tryListen(port: number) {
|
|
const onError = (err: NodeJS.ErrnoException) => {
|
|
if (err.code === 'EADDRINUSE' && attempt < maxRetries) {
|
|
attempt++;
|
|
const nextPort = port + 1;
|
|
console.log(`Port ${port} in use, trying ${nextPort}...`);
|
|
server.close(() => tryListen(nextPort));
|
|
} else {
|
|
reject(err);
|
|
}
|
|
};
|
|
server.once('error', onError);
|
|
server.listen(port, '0.0.0.0', () => {
|
|
server.removeListener('error', onError);
|
|
resolve(port);
|
|
});
|
|
}
|
|
tryListen(config.port);
|
|
});
|
|
|
|
// Update config with actual port (may differ if fallback occurred)
|
|
config.port = actualPort;
|
|
|
|
// Install hooks AFTER port is confirmed (hooks embed the port in CLI configs)
|
|
installAllHooks(actualPort);
|
|
|
|
writeFileSync(config.paths.pid, String(process.pid));
|
|
console.log(`ClawTap running on ${protocol}://0.0.0.0:${actualPort}${config.https ? ' (HTTPS)' : ''}`);
|
|
|
|
// --- Graceful Shutdown ---
|
|
|
|
async function shutdown(signal: string): Promise<void> {
|
|
console.log(`\n[shutdown] ${signal} received, cleaning up...`);
|
|
await cleanupAll();
|
|
wsTransport.destroy();
|
|
closeDB();
|
|
try { unlinkSync(config.paths.pid); } catch {}
|
|
server.close(() => process.exit(0));
|
|
setTimeout(() => process.exit(0), 3000); // Force exit after 3s
|
|
}
|
|
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
process.on('uncaughtException', (err: Error) => {
|
|
console.error('[fatal] uncaught exception:', err);
|
|
cleanupAll().catch((e: unknown) => console.error('[cleanup]', e)).finally(() => process.exit(1));
|
|
});
|
|
process.on('unhandledRejection', (err: unknown) => {
|
|
console.error('[fatal] unhandled rejection:', err);
|
|
cleanupAll().catch((e: unknown) => console.error('[cleanup]', e)).finally(() => process.exit(1));
|
|
});
|
|
}
|
|
|
|
start().catch((err: unknown) => {
|
|
console.error('Failed to start:', err);
|
|
cleanupAll().catch((e: unknown) => console.error('[cleanup]', e)).finally(() => process.exit(1));
|
|
});
|