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
text/typescript
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');
}