292 lines
8.7 KiB
JavaScript
292 lines
8.7 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
// codex-hook-wrapper.js
|
|||
|
|
// Codexバイナリにフックをかけて入出力を横取りするラッパー
|
|||
|
|
// 使い方: このファイルを codex として PATH に配置
|
|||
|
|
|
|||
|
|
const { spawn } = require('child_process');
|
|||
|
|
const path = require('path');
|
|||
|
|
let WebSocket;
|
|||
|
|
try {
|
|||
|
|
WebSocket = require('ws');
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('FATAL: Cannot find module "ws"');
|
|||
|
|
console.error('Hint: run "npm install" inside tools/codex-tmux-driver, or ensure the wrapper is symlinked to that directory.');
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
const fs = require('fs');
|
|||
|
|
|
|||
|
|
// 設定
|
|||
|
|
// 実バイナリは環境変数で上書き可能。未設定かつ存在しない場合はエラーにする。
|
|||
|
|
const REAL_CODEX = process.env.CODEX_REAL_BIN || '/home/tomoaki/.volta/tools/image/packages/@openai/codex/lib/node_modules/@openai/codex/bin/codex-x86_64-unknown-linux-musl';
|
|||
|
|
const HOOK_SERVER = process.env.CODEX_HOOK_SERVER || 'ws://localhost:8770';
|
|||
|
|
const LOG_FILE = process.env.CODEX_LOG_FILE || '/tmp/codex-hook.log';
|
|||
|
|
const ENTER_MODE = (process.env.CODEX_HOOK_ENTER || 'crlf').toLowerCase(); // lf|cr|crlf
|
|||
|
|
const ENABLE_HOOK = process.env.CODEX_HOOK_ENABLE !== 'false';
|
|||
|
|
const USE_SCRIPT_PTY = process.env.CODEX_USE_SCRIPT_PTY === 'true'; // default false
|
|||
|
|
const SHOW_BANNER = process.env.CODEX_HOOK_BANNER !== 'false';
|
|||
|
|
const ECHO_INJECT = process.env.CODEX_HOOK_ECHO_INJECT === 'true';
|
|||
|
|
const PRE_NEWLINE = process.env.CODEX_HOOK_PRENEWLINE === 'true';
|
|||
|
|
const INJECT_PREFIX = process.env.CODEX_HOOK_INJECT_PREFIX || '';
|
|||
|
|
const INJECT_SUFFIX = process.env.CODEX_HOOK_INJECT_SUFFIX || '';
|
|||
|
|
|
|||
|
|
// WebSocket接続(オプショナル)
|
|||
|
|
let ws = null;
|
|||
|
|
if (ENABLE_HOOK) {
|
|||
|
|
console.error(`[codex-hook] Attempting to connect to ${HOOK_SERVER}...`);
|
|||
|
|
try {
|
|||
|
|
ws = new WebSocket(HOOK_SERVER);
|
|||
|
|
ws.on('open', () => {
|
|||
|
|
// 目印ログ(接続先確認)
|
|||
|
|
log('hook-connect', { url: HOOK_SERVER });
|
|||
|
|
console.error(`[codex-hook] ✅ Successfully connected to ${HOOK_SERVER}`);
|
|||
|
|
});
|
|||
|
|
ws.on('error', (e) => {
|
|||
|
|
// 接続エラーは無視(フォールバック動作)
|
|||
|
|
console.error(`[codex-hook] ❌ Connection error: ${e?.message || e}`);
|
|||
|
|
});
|
|||
|
|
ws.on('close', () => {
|
|||
|
|
console.error(`[codex-hook] 🔌 Connection closed`);
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
// WebSocketサーバーが起動していない場合は通常動作
|
|||
|
|
console.error(`[codex-hook] ❌ Failed to create WebSocket: ${e}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ログ関数
|
|||
|
|
function log(type, data) {
|
|||
|
|
const timestamp = new Date().toISOString();
|
|||
|
|
const logEntry = { timestamp, type, data };
|
|||
|
|
|
|||
|
|
// ファイルログ
|
|||
|
|
fs.appendFileSync(LOG_FILE, JSON.stringify(logEntry) + '\n');
|
|||
|
|
|
|||
|
|
// WebSocket送信
|
|||
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|||
|
|
ws.send(JSON.stringify(logEntry));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Codexプロセス起動
|
|||
|
|
if (!fs.existsSync(REAL_CODEX)) {
|
|||
|
|
console.error(`FATAL: REAL_CODEX not found: ${REAL_CODEX}`);
|
|||
|
|
console.error('Set CODEX_REAL_BIN to the real Codex binary path.');
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 引数決定(無指定時は既定コマンドを許可)
|
|||
|
|
let userArgs = process.argv.slice(2);
|
|||
|
|
const DEFAULT_CMD = process.env.CODEX_WRAPPER_DEFAULT_CMD; // 例: "exec --ask-for-approval never"
|
|||
|
|
if (userArgs.length === 0 && DEFAULT_CMD) {
|
|||
|
|
try {
|
|||
|
|
userArgs = DEFAULT_CMD.split(' ').filter(Boolean);
|
|||
|
|
if (SHOW_BANNER) {
|
|||
|
|
console.error(`[codex-hook] using default cmd: ${DEFAULT_CMD}`);
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// script(1) を使って擬似TTY経由で起動(インジェクションを確実に通すため)
|
|||
|
|
function shEscape(s) { return `'${String(s).replace(/'/g, `'\''`)}'`; }
|
|||
|
|
let codexProcess;
|
|||
|
|
let usingPty = false;
|
|||
|
|
try {
|
|||
|
|
if (USE_SCRIPT_PTY) {
|
|||
|
|
const cmdStr = [REAL_CODEX, ...userArgs].map(shEscape).join(' ');
|
|||
|
|
// -q: quiet, -f: flush, -e: return child exit code, -c: command
|
|||
|
|
codexProcess = spawn('script', ['-qfec', cmdStr, '/dev/null'], {
|
|||
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|||
|
|
env: process.env
|
|||
|
|
});
|
|||
|
|
usingPty = true;
|
|||
|
|
log('start-info', { mode: 'pty(script)', cmd: cmdStr });
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
// フォールバック
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!codexProcess) {
|
|||
|
|
codexProcess = spawn(REAL_CODEX, userArgs, {
|
|||
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|||
|
|
env: process.env
|
|||
|
|
});
|
|||
|
|
log('start-info', { mode: 'pipe', bin: REAL_CODEX, args: process.argv.slice(2) });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 入力フック(標準入力 → Codex)
|
|||
|
|
let inputBuffer = '';
|
|||
|
|
process.stdin.on('data', (chunk) => {
|
|||
|
|
const data = chunk.toString();
|
|||
|
|
inputBuffer += data;
|
|||
|
|
|
|||
|
|
// 改行で区切って入力を記録
|
|||
|
|
if (data.includes('\n')) {
|
|||
|
|
const lines = inputBuffer.split('\n');
|
|||
|
|
inputBuffer = lines.pop() || '';
|
|||
|
|
|
|||
|
|
lines.forEach(line => {
|
|||
|
|
if (line.trim()) {
|
|||
|
|
log('input', line);
|
|||
|
|
|
|||
|
|
// 入力パターン検出
|
|||
|
|
if (ENABLE_HOOK) {
|
|||
|
|
detectInputPattern(line);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// そのままCodexに転送
|
|||
|
|
codexProcess.stdin.write(chunk);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 出力フック(Codex → 標準出力)
|
|||
|
|
let outputBuffer = '';
|
|||
|
|
codexProcess.stdout.on('data', (chunk) => {
|
|||
|
|
const data = chunk.toString();
|
|||
|
|
outputBuffer += data;
|
|||
|
|
|
|||
|
|
// バッファリングして意味のある単位で記録
|
|||
|
|
if (data.includes('\n') || data.includes('▌')) {
|
|||
|
|
log('output', outputBuffer);
|
|||
|
|
|
|||
|
|
// 出力パターン検出
|
|||
|
|
if (ENABLE_HOOK) {
|
|||
|
|
detectOutputPattern(outputBuffer);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
outputBuffer = '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// そのまま標準出力へ
|
|||
|
|
process.stdout.write(chunk);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// エラー出力
|
|||
|
|
codexProcess.stderr.on('data', (chunk) => {
|
|||
|
|
log('error', chunk.toString());
|
|||
|
|
process.stderr.write(chunk);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// プロセス終了
|
|||
|
|
codexProcess.on('exit', (code, signal) => {
|
|||
|
|
log('exit', { code, signal });
|
|||
|
|
|
|||
|
|
if (ws) {
|
|||
|
|
try {
|
|||
|
|
ws.send(JSON.stringify({
|
|||
|
|
type: 'hook-event',
|
|||
|
|
event: 'codex-exit',
|
|||
|
|
data: { code, signal }
|
|||
|
|
}));
|
|||
|
|
} catch {}
|
|||
|
|
ws.close();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
process.exit(typeof code === 'number' ? code : 0);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 入力パターン検出
|
|||
|
|
function detectInputPattern(input) {
|
|||
|
|
const patterns = {
|
|||
|
|
question: /\?$|どうしますか|どう思いますか/,
|
|||
|
|
command: /^(status|help|exit|clear)/,
|
|||
|
|
code: /^(function|box|if|for|while|return)/
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
for (const [type, pattern] of Object.entries(patterns)) {
|
|||
|
|
if (pattern.test(input)) {
|
|||
|
|
log('input-pattern', { type, input });
|
|||
|
|
|
|||
|
|
// 特定パターンでの自動介入
|
|||
|
|
if (type === 'question' && ws && ws.readyState === WebSocket.OPEN) {
|
|||
|
|
ws.send(JSON.stringify({
|
|||
|
|
type: 'hook-event',
|
|||
|
|
event: 'question-detected',
|
|||
|
|
data: input
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 出力パターン検出
|
|||
|
|
function detectOutputPattern(output) {
|
|||
|
|
const patterns = {
|
|||
|
|
thinking: /考え中|Processing|Thinking|分析中/,
|
|||
|
|
complete: /完了|Complete|Done|終了/,
|
|||
|
|
error: /エラー|Error|失敗|Failed/,
|
|||
|
|
waiting: /waiting|待機中|入力待ち|▌/
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
for (const [type, pattern] of Object.entries(patterns)) {
|
|||
|
|
if (pattern.test(output)) {
|
|||
|
|
log('output-pattern', { type, output: output.substring(0, 100) });
|
|||
|
|
|
|||
|
|
// 待機状態での介入ポイント
|
|||
|
|
if (type === 'waiting' && ws && ws.readyState === WebSocket.OPEN) {
|
|||
|
|
ws.send(JSON.stringify({
|
|||
|
|
type: 'hook-event',
|
|||
|
|
event: 'waiting-detected',
|
|||
|
|
data: output
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// シグナルハンドリング
|
|||
|
|
process.on('SIGINT', () => {
|
|||
|
|
codexProcess.kill('SIGINT');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
process.on('SIGTERM', () => {
|
|||
|
|
codexProcess.kill('SIGTERM');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// WebSocketからの介入コマンド受信
|
|||
|
|
if (ws) {
|
|||
|
|
ws.on('message', (data) => {
|
|||
|
|
try {
|
|||
|
|
const cmd = JSON.parse(data.toString());
|
|||
|
|
|
|||
|
|
if (cmd.type === 'inject-input') {
|
|||
|
|
// Codexに入力を注入
|
|||
|
|
log('inject', cmd.data);
|
|||
|
|
// Enterの扱いは環境依存のため、モードで切替(デフォルト: crlf)
|
|||
|
|
let eol = '\r\n';
|
|||
|
|
if (ENTER_MODE === 'lf') eol = '\n';
|
|||
|
|
else if (ENTER_MODE === 'cr') eol = '\r';
|
|||
|
|
try {
|
|||
|
|
const payload = `${INJECT_PREFIX}${cmd.data}${INJECT_SUFFIX}`;
|
|||
|
|
if (PRE_NEWLINE) {
|
|||
|
|
codexProcess.stdin.write('\n');
|
|||
|
|
}
|
|||
|
|
const written = codexProcess.stdin.write(payload + eol);
|
|||
|
|
if (ECHO_INJECT) {
|
|||
|
|
// メッセージだけをシンプルに表示
|
|||
|
|
process.stdout.write(`\n\n${payload}\n`);
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(`[codex-hook] ❌ Error writing to stdin: ${e}`);
|
|||
|
|
log('inject-error', e?.message || String(e));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(`[codex-hook] ❌ Error parsing message: ${e}`);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 起動ログ
|
|||
|
|
log('start', {
|
|||
|
|
args: process.argv.slice(2),
|
|||
|
|
pid: process.pid,
|
|||
|
|
hookEnabled: ENABLE_HOOK,
|
|||
|
|
usingPty
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (SHOW_BANNER) {
|
|||
|
|
console.error(`[codex-hook] active (pty=${usingPty ? 'on' : 'off'} enter=${ENTER_MODE}) REAL_CODEX=${REAL_CODEX}`);
|
|||
|
|
}
|