367 lines
9.4 KiB
JavaScript
367 lines
9.4 KiB
JavaScript
|
|
// codex-tmux-driver.js
|
||
|
|
// tmux経由でCodexを管理し、イベントをWebSocket配信するドライバ
|
||
|
|
// 使い方:
|
||
|
|
// 1) npm install
|
||
|
|
// 2) node codex-tmux-driver.js [options]
|
||
|
|
// --session=codex-session (tmuxセッション名)
|
||
|
|
// --port=8766 (WebSocketポート)
|
||
|
|
// --log=/tmp/codex.log (ログファイルパス)
|
||
|
|
// 3) WebSocketで接続して操作
|
||
|
|
// {"op":"send","data":"質問内容"}
|
||
|
|
// {"op":"capture"}
|
||
|
|
// {"op":"status"}
|
||
|
|
|
||
|
|
const { spawn } = require('child_process');
|
||
|
|
const { WebSocketServer } = require('ws');
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
|
||
|
|
// --- 設定 ---
|
||
|
|
const argv = process.argv.slice(2).reduce((a, kv) => {
|
||
|
|
const [k, ...rest] = kv.split('=');
|
||
|
|
const v = rest.join('=');
|
||
|
|
a[k.replace(/^--/, '')] = v ?? true;
|
||
|
|
return a;
|
||
|
|
}, {});
|
||
|
|
|
||
|
|
const SESSION_NAME = argv.session || 'codex-session';
|
||
|
|
const PORT = Number(argv.port || 8766);
|
||
|
|
const LOG_FILE = argv.log || `/tmp/codex-${Date.now()}.log`;
|
||
|
|
const CODEX_CMD = argv.cmd || 'codex exec';
|
||
|
|
|
||
|
|
// --- 状態管理 ---
|
||
|
|
let clients = new Set();
|
||
|
|
let tailProcess = null;
|
||
|
|
let sessionActive = false;
|
||
|
|
let codexEventBuffer = [];
|
||
|
|
const MAX_BUFFER_SIZE = 100;
|
||
|
|
|
||
|
|
// --- ユーティリティ関数 ---
|
||
|
|
function broadcast(msg) {
|
||
|
|
const data = JSON.stringify(msg);
|
||
|
|
for (const ws of clients) {
|
||
|
|
try { ws.send(data); } catch {}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function executeCommand(cmd, args = []) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const proc = spawn(cmd, args);
|
||
|
|
let stdout = '';
|
||
|
|
let stderr = '';
|
||
|
|
|
||
|
|
proc.stdout.on('data', (data) => { stdout += data; });
|
||
|
|
proc.stderr.on('data', (data) => { stderr += data; });
|
||
|
|
|
||
|
|
proc.on('close', (code) => {
|
||
|
|
if (code === 0) {
|
||
|
|
resolve(stdout);
|
||
|
|
} else {
|
||
|
|
reject(new Error(`Command failed: ${stderr}`));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Codex出力パターン認識 ---
|
||
|
|
function parseCodexOutput(line) {
|
||
|
|
const patterns = {
|
||
|
|
// Codexの応答パターン
|
||
|
|
response: /^(Codex:|回答:|Answer:|Response:)/i,
|
||
|
|
thinking: /(考え中|Processing|Thinking|分析中)/i,
|
||
|
|
error: /(エラー|Error|失敗|Failed)/i,
|
||
|
|
complete: /(完了|Complete|Done|終了)/i,
|
||
|
|
question: /(質問:|Question:|相談:|Help:)/i,
|
||
|
|
};
|
||
|
|
|
||
|
|
for (const [event, pattern] of Object.entries(patterns)) {
|
||
|
|
if (pattern.test(line)) {
|
||
|
|
return {
|
||
|
|
type: 'codex-event',
|
||
|
|
event: event,
|
||
|
|
timestamp: new Date().toISOString(),
|
||
|
|
data: line.trim()
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// パターンに一致しない場合は生データとして返す
|
||
|
|
return {
|
||
|
|
type: 'codex-output',
|
||
|
|
timestamp: new Date().toISOString(),
|
||
|
|
data: line
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- tmuxセッション管理 ---
|
||
|
|
async function createTmuxSession() {
|
||
|
|
try {
|
||
|
|
// 既存セッションをチェック
|
||
|
|
try {
|
||
|
|
await executeCommand('tmux', ['has-session', '-t', SESSION_NAME]);
|
||
|
|
console.log(`[INFO] Session ${SESSION_NAME} already exists`);
|
||
|
|
sessionActive = true;
|
||
|
|
// 既存セッションでもパイプと監視を確実に有効化する
|
||
|
|
try {
|
||
|
|
await executeCommand('tmux', [
|
||
|
|
'pipe-pane', '-t', SESSION_NAME,
|
||
|
|
'-o', `cat >> ${LOG_FILE}`
|
||
|
|
]);
|
||
|
|
} catch (e) {
|
||
|
|
console.warn('[WARN] Failed to ensure pipe-pane on existing session:', e.message || e);
|
||
|
|
}
|
||
|
|
if (!tailProcess) {
|
||
|
|
startLogMonitoring();
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
} catch {
|
||
|
|
// セッションが存在しない場合、作成
|
||
|
|
}
|
||
|
|
|
||
|
|
// 新規セッション作成
|
||
|
|
await executeCommand('tmux', [
|
||
|
|
'new-session', '-d', '-s', SESSION_NAME,
|
||
|
|
CODEX_CMD
|
||
|
|
]);
|
||
|
|
|
||
|
|
// pipe-paneでログ出力を設定
|
||
|
|
await executeCommand('tmux', [
|
||
|
|
'pipe-pane', '-t', SESSION_NAME,
|
||
|
|
'-o', `cat >> ${LOG_FILE}`
|
||
|
|
]);
|
||
|
|
|
||
|
|
sessionActive = true;
|
||
|
|
console.log(`[INFO] Created tmux session: ${SESSION_NAME}`);
|
||
|
|
|
||
|
|
// ログファイル監視開始
|
||
|
|
startLogMonitoring();
|
||
|
|
|
||
|
|
} catch (err) {
|
||
|
|
console.error('[ERROR] Failed to create tmux session:', err);
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- ログ監視 ---
|
||
|
|
function startLogMonitoring() {
|
||
|
|
// ログファイルが存在しない場合は作成
|
||
|
|
if (!fs.existsSync(LOG_FILE)) {
|
||
|
|
fs.writeFileSync(LOG_FILE, '');
|
||
|
|
}
|
||
|
|
|
||
|
|
// tail -fで監視
|
||
|
|
tailProcess = spawn('tail', ['-f', '-n', '0', LOG_FILE]);
|
||
|
|
|
||
|
|
tailProcess.stdout.on('data', (data) => {
|
||
|
|
const lines = data.toString('utf8').split('\n').filter(Boolean);
|
||
|
|
|
||
|
|
for (const line of lines) {
|
||
|
|
const event = parseCodexOutput(line);
|
||
|
|
|
||
|
|
// イベントバッファに追加
|
||
|
|
codexEventBuffer.push(event);
|
||
|
|
if (codexEventBuffer.length > MAX_BUFFER_SIZE) {
|
||
|
|
codexEventBuffer.shift();
|
||
|
|
}
|
||
|
|
|
||
|
|
// クライアントに配信
|
||
|
|
broadcast(event);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
tailProcess.on('error', (err) => {
|
||
|
|
console.error('[ERROR] Tail process error:', err);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- WebSocketサーバ ---
|
||
|
|
const wss = new WebSocketServer({ port: PORT });
|
||
|
|
|
||
|
|
wss.on('connection', (ws) => {
|
||
|
|
clients.add(ws);
|
||
|
|
|
||
|
|
// 接続時にステータスと最近のイベントを送信
|
||
|
|
ws.send(JSON.stringify({
|
||
|
|
type: 'status',
|
||
|
|
data: {
|
||
|
|
session: SESSION_NAME,
|
||
|
|
active: sessionActive,
|
||
|
|
logFile: LOG_FILE,
|
||
|
|
recentEvents: codexEventBuffer.slice(-10)
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
|
||
|
|
ws.on('message', async (raw) => {
|
||
|
|
let msg;
|
||
|
|
try {
|
||
|
|
msg = JSON.parse(raw.toString());
|
||
|
|
} catch {
|
||
|
|
ws.send(JSON.stringify({ type: 'error', data: 'Invalid JSON' }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
switch (msg.op) {
|
||
|
|
case 'send': {
|
||
|
|
// Codexに入力を送信
|
||
|
|
if (!sessionActive) {
|
||
|
|
ws.send(JSON.stringify({ type: 'error', data: 'Session not active' }));
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
const input = String(msg.data || '').trim();
|
||
|
|
if (!input) break;
|
||
|
|
|
||
|
|
try {
|
||
|
|
await executeCommand('tmux', [
|
||
|
|
'send-keys', '-t', SESSION_NAME,
|
||
|
|
input, 'Enter'
|
||
|
|
]);
|
||
|
|
|
||
|
|
broadcast({
|
||
|
|
type: 'input-sent',
|
||
|
|
timestamp: new Date().toISOString(),
|
||
|
|
data: input
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
ws.send(JSON.stringify({
|
||
|
|
type: 'error',
|
||
|
|
data: 'Failed to send input'
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'capture': {
|
||
|
|
// 現在の画面をキャプチャ
|
||
|
|
if (!sessionActive) {
|
||
|
|
ws.send(JSON.stringify({ type: 'error', data: 'Session not active' }));
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const output = await executeCommand('tmux', [
|
||
|
|
'capture-pane', '-t', SESSION_NAME, '-p'
|
||
|
|
]);
|
||
|
|
|
||
|
|
ws.send(JSON.stringify({
|
||
|
|
type: 'screen-capture',
|
||
|
|
timestamp: new Date().toISOString(),
|
||
|
|
data: output
|
||
|
|
}));
|
||
|
|
} catch (err) {
|
||
|
|
ws.send(JSON.stringify({
|
||
|
|
type: 'error',
|
||
|
|
data: 'Failed to capture screen'
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'status': {
|
||
|
|
// ステータス確認
|
||
|
|
ws.send(JSON.stringify({
|
||
|
|
type: 'status',
|
||
|
|
data: {
|
||
|
|
session: SESSION_NAME,
|
||
|
|
active: sessionActive,
|
||
|
|
logFile: LOG_FILE,
|
||
|
|
clientCount: clients.size,
|
||
|
|
bufferSize: codexEventBuffer.length
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'history': {
|
||
|
|
// イベント履歴取得
|
||
|
|
const count = Number(msg.count || 20);
|
||
|
|
const history = codexEventBuffer.slice(-count);
|
||
|
|
|
||
|
|
ws.send(JSON.stringify({
|
||
|
|
type: 'history',
|
||
|
|
data: history
|
||
|
|
}));
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'filter': {
|
||
|
|
// 特定のイベントタイプのみフィルタ
|
||
|
|
const eventType = msg.event || 'all';
|
||
|
|
const filtered = eventType === 'all'
|
||
|
|
? codexEventBuffer
|
||
|
|
: codexEventBuffer.filter(e => e.event === eventType);
|
||
|
|
|
||
|
|
ws.send(JSON.stringify({
|
||
|
|
type: 'filtered-events',
|
||
|
|
filter: eventType,
|
||
|
|
data: filtered
|
||
|
|
}));
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'kill': {
|
||
|
|
// セッション終了
|
||
|
|
if (!sessionActive) break;
|
||
|
|
|
||
|
|
try {
|
||
|
|
await executeCommand('tmux', ['kill-session', '-t', SESSION_NAME]);
|
||
|
|
sessionActive = false;
|
||
|
|
if (tailProcess) {
|
||
|
|
try { tailProcess.kill(); } catch {}
|
||
|
|
tailProcess = null;
|
||
|
|
}
|
||
|
|
broadcast({ type: 'session-killed' });
|
||
|
|
} catch (err) {
|
||
|
|
ws.send(JSON.stringify({
|
||
|
|
type: 'error',
|
||
|
|
data: 'Failed to kill session'
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
default: {
|
||
|
|
ws.send(JSON.stringify({
|
||
|
|
type: 'error',
|
||
|
|
data: `Unknown operation: ${msg.op}`
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
ws.on('close', () => {
|
||
|
|
clients.delete(ws);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- 起動処理 ---
|
||
|
|
async function start() {
|
||
|
|
console.log('=== Codex tmux Driver ===');
|
||
|
|
console.log(`WebSocket: ws://localhost:${PORT}`);
|
||
|
|
console.log(`Session: ${SESSION_NAME}`);
|
||
|
|
console.log(`Log file: ${LOG_FILE}`);
|
||
|
|
|
||
|
|
try {
|
||
|
|
await createTmuxSession();
|
||
|
|
|
||
|
|
wss.on('listening', () => {
|
||
|
|
console.log(`[INFO] WebSocket server listening on port ${PORT}`);
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
console.error('[FATAL] Failed to start:', err);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- クリーンアップ ---
|
||
|
|
process.on('SIGINT', () => {
|
||
|
|
console.log('\n[INFO] Shutting down...');
|
||
|
|
if (tailProcess) {
|
||
|
|
tailProcess.kill();
|
||
|
|
}
|
||
|
|
process.exit(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
// 起動
|
||
|
|
start();
|