#!/usr/bin/env node // hook-server.js // Codexフックからのイベントを受信してClaude連携するサーバー const WebSocket = require('ws'); const fs = require('fs').promises; const PORT = process.env.HOOK_SERVER_PORT || 8770; const STRIP_ANSI = process.env.HOOK_STRIP_ANSI !== 'false'; const AUTO_BRIDGE = process.env.AUTO_BRIDGE === 'true'; const AUTO_EXIT = process.env.HOOK_SERVER_AUTO_EXIT === 'true'; const IDLE_EXIT_MS = Number(process.env.HOOK_IDLE_EXIT_MS || 2000); // WebSocketサーバー const wss = new WebSocket.Server({ port: PORT }); // 状態管理 const state = { lastInput: '', lastOutput: '', waitingCount: 0, questionQueue: [], // 接続クライアント: Map clients: new Map() }; console.log(`🪝 Codex Hook Server listening on ws://localhost:${PORT}`); wss.on('connection', (ws, req) => { const clientType = req.url === '/control' ? 'control' : 'hook'; console.log(`📌 New ${clientType} connection`); state.clients.set(ws, clientType); ws.on('message', async (data) => { try { const msg = JSON.parse(data.toString()); if (clientType === 'hook') { // Codexフックからのメッセージ await handleHookMessage(msg, ws); } else { // 制御クライアントからのメッセージ await handleControlMessage(ws, msg); } } catch (e) { console.error('Message error:', e); } }); ws.on('close', () => { state.clients.delete(ws); maybeAutoExit(); }); }); // ANSIエスケープ除去 function stripAnsi(s) { if (!STRIP_ANSI) return s; if (typeof s !== 'string') return s; // Robust ANSI/CSI/OSC sequences removal const ansiPattern = /[\u001B\u009B][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><]/g; return s.replace(ansiPattern, ''); } // フックメッセージ処理 async function handleHookMessage(msg, senderWs) { const preview = typeof msg.data === 'string' ? stripAnsi(msg.data).substring(0, 80) : JSON.stringify(msg.data); console.log(`[${msg.type}] ${preview}`); // 全制御クライアントに転送 broadcast('control', msg); switch (msg.type) { case 'input': state.lastInput = msg.data; break; case 'output': state.lastOutput = typeof msg.data === 'string' ? stripAnsi(msg.data) : msg.data; break; case 'hook-event': await handleHookEvent(msg); break; case 'inject-input': // フッククライアントからの入力注入リクエスト console.log('🔄 Relaying inject-input from hook client'); // 明示的なターゲットがあればそれを最優先(tmuxセッション名を想定) if (msg.target && typeof msg.target === 'string') { const { spawn } = require('child_process'); const text = String(msg.data ?? ''); const targetSession = msg.target; console.log(`📤 Sending to explicit target via tmux: ${targetSession}`); // 文字列を通常の方法で送信 const { exec } = require('child_process'); const messageEscaped = text.replace(/'/g, "'\\''"); await new Promise((resolve) => { exec(`tmux send-keys -t ${targetSession} '${messageEscaped}' Enter`, (error) => { if (error) console.error(`❌ tmux error: ${error.message}`); resolve(); }); }); if (process.env.HOOK_SEND_CTRL_J === 'true') { await new Promise((resolve) => { const p = spawn('tmux', ['send-keys', '-t', targetSession, 'C-j']); p.on('close', () => resolve()); }); } console.log(`✅ Message + Enter sent to ${targetSession}`); break; } // 互換ルーティング(source から推測) let targetSession = 'claude'; if (msg.source === 'codex') { targetSession = 'claude'; } else if (msg.source === 'claude') { targetSession = 'codex'; } console.log(`📤 Forwarding to ${targetSession}`); if (targetSession === 'claude') { // Claude想定:WebSocket経由でstdinに直接送信(注意: 全hookに送られる) console.log('🎯 Sending to Claude via WebSocket (stdin)'); broadcast('hook', { type: 'inject-input', data: msg.data, target: 'claude' }); } else { // Codex想定:tmux send-keys const { exec } = require('child_process'); const text = String(msg.data ?? ''); const messageEscaped = text.replace(/'/g, "'\\''"); console.log(`📤 Sending to ${targetSession} via tmux`); await new Promise((resolve) => { exec(`tmux send-keys -t ${targetSession} '${messageEscaped}' Enter`, (error) => { if (error) console.error(`❌ tmux error: ${error.message}`); resolve(); }); }); if (process.env.HOOK_SEND_CTRL_J === 'true') { await new Promise((resolve) => { const p = spawn('tmux', ['send-keys', '-t', targetSession, 'C-j']); p.on('close', () => resolve()); }); } console.log(`✅ Message + Enter sent to ${targetSession}`); } break; } } // フックイベント処理 async function handleHookEvent(msg) { switch (msg.event) { case 'question-detected': console.log('❓ Question detected:', msg.data); state.questionQueue.push({ question: msg.data, timestamp: Date.now() }); if (AUTO_BRIDGE) { // 自動ブリッジが有効なら応答を生成 setTimeout(() => { injectResponse('考えさせてください...'); }, 1000); } break; case 'waiting-detected': state.waitingCount++; console.log(`⏳ Waiting detected (count: ${state.waitingCount})`); // 3回連続で待機状態なら介入 if (state.waitingCount >= 3 && AUTO_BRIDGE) { console.log('🚨 Auto-intervention triggered'); injectResponse('続けてください'); state.waitingCount = 0; } break; case 'codex-exit': console.log('🛑 Codex process exited'); maybeAutoExit(); break; } } // 制御メッセージ処理 async function handleControlMessage(ws, msg) { switch (msg.op) { case 'inject': injectResponse(msg.data); ws.send(JSON.stringify({ type: 'injected', data: msg.data })); break; case 'status': ws.send(JSON.stringify({ type: 'status', state: { lastInput: state.lastInput, lastOutput: state.lastOutput.substring(0, 100), waitingCount: state.waitingCount, questionCount: state.questionQueue.length, clients: state.clients.size } })); break; case 'questions': ws.send(JSON.stringify({ type: 'questions', data: state.questionQueue })); break; } } // Codexに応答を注入 function injectResponse(response) { console.log('💉 Injecting response:', response); // フッククライアントに注入コマンドを送信 broadcast('hook', { type: 'inject-input', data: response }); } // ブロードキャスト function broadcast(clientType, message, excludeWs = null) { const data = JSON.stringify(message); let sentCount = 0; for (const [clientWs, type] of state.clients.entries()) { if (type === clientType && clientWs.readyState === WebSocket.OPEN) { if (excludeWs && clientWs === excludeWs) continue; // 送信元を除外 clientWs.send(data); sentCount++; } } console.log(`📡 Broadcast to ${sentCount} ${clientType} clients`); } // フッククライアントがいなければ自動終了 let exitTimer = null; function maybeAutoExit() { if (!AUTO_EXIT) return; const hasHook = Array.from(state.clients.values()).some(t => t === 'hook'); if (hasHook) return; if (exitTimer) clearTimeout(exitTimer); exitTimer = setTimeout(() => { const hasHookNow = Array.from(state.clients.values()).some(t => t === 'hook'); if (!hasHookNow) { console.log(`\n👋 No hook clients. Auto-exiting hook server (port ${PORT}).`); wss.close(); process.exit(0); } }, IDLE_EXIT_MS); } // 統計情報の定期出力 setInterval(() => { console.log(`📊 Stats: Questions: ${state.questionQueue.length}, Waiting: ${state.waitingCount}, Clients: ${state.clients.size}`); }, 60000); // グレースフルシャットダウン process.on('SIGINT', () => { console.log('\n👋 Shutting down hook server...'); wss.close(); process.exit(0); });