UNPKG

rigjs

Version:

A multi-repos dev tool based on yarn and git.Rigjs is intended to be the simplest way to develop,share and deliver codes between different developers or different projects.

154 lines (138 loc) 5.68 kB
import fs from 'fs'; import { buildClaudeHandoffPrompt } from './prompt'; import { copyToClipboard, notifyHandoff, requireMacOS } from './platform'; import { CodexLatestPointer, writeCodexLatestPointer } from './codex-pointer'; export interface CodexHookInput { hook_event_name?: string; session_id?: string; transcript_path?: string | null; cwd?: string; prompt?: string; model?: string; turn_id?: string; } export interface CodexHookDependencies { copy?: (text: string) => void; notify?: (message: string) => void; writePointer?: (pointer: CodexLatestPointer) => void; platform?: NodeJS.Platform; now?: () => Date; } export interface CodexHookResult { triggered: boolean; prompt?: string; output?: { decision: 'block'; reason: string }; pointerError?: string; } const CODEX_HANDOFF_PROMPTS = new Set([ '$handoff', '/handoff', 'Use $handoff to copy this session for the other coding agent to continue.', ]); export function parseCodexHookInput(raw: string): CodexHookInput { let value: unknown; try { value = JSON.parse(raw); } catch { throw new Error('Codex hook input is not valid JSON.'); } if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Codex hook input must be a JSON object.'); } return value as CodexHookInput; } export function isCodexHandoffPrompt(value: unknown): boolean { if (typeof value !== 'string') return false; const prompt = value.trim(); return CODEX_HANDOFF_PROMPTS.has(prompt); } export function handleCodexHook( input: CodexHookInput, pointerPath: string, deps: CodexHookDependencies = {}, ): CodexHookResult { const event = input.hook_event_name || ''; if (event !== 'UserPromptSubmit') throw new Error(`unsupported Codex hook event: ${event || '<missing>'}`); // Codex runs UserPromptSubmit hooks for subagents too, but its hook payload // has no root/subagent marker. Only an explicit user handoff trigger may // claim the shared latest pointer; ordinary child activity must not replace // the main task's session. if (!isCodexHandoffPrompt(input.prompt)) return { triggered: false }; let pointerError: string | undefined; try { const pointer = buildPointer(input, deps.now ? deps.now() : new Date()); (deps.writePointer || ((value: CodexLatestPointer) => writeCodexLatestPointer(pointerPath, value)))(pointer); } catch (error) { pointerError = error instanceof Error ? error.message : String(error); } requireMacOS(deps.platform || process.platform); const transcriptPath = requireString(input.transcript_path, 'transcript_path'); if (!transcriptPath.endsWith('.jsonl')) throw new Error('transcript_path must point to a .jsonl file.'); const context = { transcriptPath, cwd: requireString(input.cwd, 'cwd'), sessionId: requireString(input.session_id, 'session_id'), model: optionalString(input.model), }; const prompt = buildClaudeHandoffPrompt(context); (deps.copy || copyToClipboard)(prompt); try { (deps.notify || notifyHandoff)('Claude handoff copied to clipboard.'); } catch { /* notification is optional; the clipboard handoff already succeeded */ } const reason = 'Claude handoff copied to clipboard. Switch to Claude Code, paste, and send.'; return { triggered: true, prompt, pointerError, output: { decision: 'block', reason }, }; } function buildPointer(input: CodexHookInput, now: Date): CodexLatestPointer { const transcriptPath = requireString(input.transcript_path, 'transcript_path'); if (!transcriptPath.endsWith('.jsonl')) throw new Error('transcript_path must point to a .jsonl file.'); return { schemaVersion: 1, sessionId: requireString(input.session_id, 'session_id'), transcriptPath, cwd: requireString(input.cwd, 'cwd'), model: optionalString(input.model), turnId: optionalString(input.turn_id), updatedAt: now.toISOString(), }; } function requireString(value: unknown, field: string): string { if (typeof value !== 'string' || value.trim() === '') throw new Error(`Codex hook input is missing ${field}.`); return value; } function optionalString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value : undefined; } export function runCodexHookCli(raw: string, pointerPath: string): number { let input: CodexHookInput = {}; try { input = parseCodexHookInput(raw); const result = handleCodexHook(input, pointerPath); if (result.output) process.stdout.write(JSON.stringify(result.output) + '\n'); return 0; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!isCodexHandoffPrompt(input.prompt)) { // This hook runs for every user prompt. Unrelated Codex work must never // become unavailable because the hook received an unexpected payload. process.stderr.write(`rig handoff from-codex: ${message}\n`); return 0; } // Fail closed so a broken handoff trigger never falls through to a model. const reason = `Rig handoff failed before calling Codex: ${message}`; process.stdout.write(JSON.stringify({ decision: 'block', reason }) + '\n'); return 0; } } export function readCodexHookStdin(maxBytes = 1024 * 1024): string { const chunks: Buffer[] = []; let total = 0; const buffer = Buffer.alloc(64 * 1024); let bytes = 0; while ((bytes = fs.readSync(0, buffer, 0, buffer.length, null)) > 0) { total += bytes; if (total > maxBytes) throw new Error(`Codex hook input exceeds ${maxBytes} bytes.`); chunks.push(Buffer.from(buffer.subarray(0, bytes))); } return Buffer.concat(chunks).toString('utf8'); }