Skip to content
Agent Orchestration
Hook

Hooks

What cc-plugin-codex runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
cc-plugin-codex
1617 skills3 hooks
Install
$ npx -y skills add sendbird/cc-plugin-codex --agent claude-code

Ships with cc-plugin-codex. Installing the plugin gets these hooks.

What fires, and when

SessionStart

Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.

  • node "$PLUGIN_ROOT/hooks/session-lifecycle-hook.mjs"

Stop

  • node "$PLUGIN_ROOT/hooks/stop-review-gate-hook.mjs"

UserPromptSubmit

Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.

  • node "$PLUGIN_ROOT/hooks/unread-result-hook.mjs"
Read hooks/hooks.json

Where it lives

  • hooks/session-lifecycle-hook.mjsRunsGitHub
    Read the script
    #!/usr/bin/env node
    
    /**
     * Copyright 2026 Sendbird, Inc.
     * SPDX-License-Identifier: Apache-2.0
     */
    
    /**
     * Session lifecycle hook for Codex — Claude Code bridge.
     *
     * SessionStart: Exports CLAUDE_COMPANION_SESSION_ID via CLAUDE_ENV_FILE.
     *
     * No broker lifecycle — Claude Code uses direct CLI invocation.
     */
    
    import fs from "node:fs";
    import path from "node:path";
    import process from "node:process";
    import { fileURLToPath } from "node:url";
    
    import { readHookInput } from "./lib/hook-input.mjs";
    import { detectExternalHostOrigin } from "./lib/host-origin.mjs";
    import { cleanupAfterOfficialUninstall } from "./lib/plugin-install-guard.mjs";
    import { setCurrentSession } from "../scripts/lib/state.mjs";
    import { SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs";
    
    export { SESSION_ID_ENV };
    const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
    const SKIP_INTERACTIVE_HOOKS_ENV = "CLAUDE_COMPANION_SKIP_INTERACTIVE_HOOKS";
    const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
    
    function shellEscape(value) {
      return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
    }
    
    function appendEnvVar(name, value) {
      if (!process.env.CLAUDE_ENV_FILE || value == null || value === "") {
        return;
      }
      fs.appendFileSync(
        process.env.CLAUDE_ENV_FILE,
        `export ${name}=${shellEscape(value)}\n`,
        "utf8"
      );
    }
    
    function isNestedCodexSession(inputSessionId) {
      const inheritedSessionId = process.env[SESSION_ID_ENV] || null;
      return Boolean(
        inputSessionId &&
          inheritedSessionId &&
          inheritedSessionId !== inputSessionId
      );
    }
    
    function handleSessionStart(input) {
      const cwd = input.cwd || process.cwd();
      const nestedSession = isNestedCodexSession(input.session_id);
      // Export session ID so companion scripts can correlate jobs
      appendEnvVar(SESSION_ID_ENV, input.session_id);
      appendEnvVar(SKIP_INTERACTIVE_HOOKS_ENV, nestedSession ? "1" : "0");
      // Forward plugin data dir if set
      appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]);
      if (input.session_id && !nestedSession) {
        setCurrentSession(cwd, input.session_id, {
          hostOrigin: detectExternalHostOrigin(),
        });
      }
    }
    
    // ---------------------------------------------------------------------------
    // Main
    // ---------------------------------------------------------------------------
    
    async function main() {
      const input = readHookInput();
      if (cleanupAfterOfficialUninstall(ROOT_DIR)) {
        return;
      }
      const eventName = process.argv[2] ?? input.hook_event_name ?? "";
    
      if (eventName === "SessionStart" || !eventName) {
        // Default to SessionStart (Codex invokes this on session start)
        handleSessionStart(input);
      }
    }
    
    main().catch((error) => {
      process.stderr.write(
        `${error instanceof Error ? error.message : String(error)}\n`
      );
      process.exit(1);
    });
    
  • hooks/stop-review-gate-hook.mjsRunsGitHub
    Read the script
    #!/usr/bin/env node
    
    /**
     * Copyright 2026 Sendbird, Inc.
     * SPDX-License-Identifier: Apache-2.0
     */
    
    /**
     * Turn-end review gate hook for Codex — Claude Code bridge.
     *
     * Flow:
     * 1. Check config.stopReviewGate — if disabled -> exit 0.
     * 2. If Claude Code is not ready, log setup guidance and allow stop to continue.
     * 3. Run a targeted turn-end review of the previous Codex response.
     * 4. Parse ALLOW:/BLOCK: from Claude's output.
     * 5. If the review returns BLOCK, keep the Codex turn active.
     */
    
    import process from "node:process";
    import path from "node:path";
    import { fileURLToPath } from "node:url";
    
    import { readHookInput } from "./lib/hook-input.mjs";
    import { cleanupAfterOfficialUninstall } from "./lib/plugin-install-guard.mjs";
    import { loadPromptTemplate, interpolateTemplate } from "../scripts/lib/prompts.mjs";
    import {
      appendStopReviewHistory,
      generateJobId,
      getCurrentSession,
      getConfig,
      listJobs,
      nowIso,
      readTurnBaseline,
      writeStopReviewSnapshot
    } from "../scripts/lib/state.mjs";
    import {
      getClaudeAvailability,
      getClaudeAuthStatus,
      cleanupReviewMcpConfig,
      cleanupSandboxSettings,
      createReviewMcpConfig,
      createSandboxSettings,
      runClaudeReview,
      SANDBOX_STOP_REVIEW_TOOLS,
    } from "../scripts/lib/claude-cli.mjs";
    import { getWorkingTreeFingerprint } from "../scripts/lib/git.mjs";
    import { SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs";
    import { resolveWorkspaceRoot } from "../scripts/lib/workspace.mjs";
    
    const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
    const ROOT_DIR = path.resolve(SCRIPT_DIR, "..");
    const SKIP_INTERACTIVE_HOOKS_ENV = "CLAUDE_COMPANION_SKIP_INTERACTIVE_HOOKS";
    const STOP_REVIEW_SUCCESS_NOTE = "Claude Code turn-end review passed.";
    const STOP_REVIEW_NO_EDIT_NOTE =
      "Claude Code turn-end review skipped: the most recent turn made no net edits.";
    const STOP_REVIEW_NO_BASELINE_NOTE =
      "Claude Code turn-end review skipped: no user turn was recorded for this Codex session.";
    const MAX_INLINE_REASON_CHARS = 1_500;
    
    function emitDecision(payload) {
      process.stdout.write(`${JSON.stringify(payload)}\n`);
    }
    
    function logNote(message) {
      if (!message) {
        return;
      }
      process.stderr.write(`${message}\n`);
    }
    
    function boundReasonForHookOutput(reason, runId) {
      const text = String(reason ?? "");
      if (text.length <= MAX_INLINE_REASON_CHARS) {
        return text;
      }
      const suffix = [
        "",
        "",
        `Full stop-review output was saved in the ${runId} snapshot.`
      ].join("\n");
      return `${text.slice(0, MAX_INLINE_REASON_CHARS).trimEnd()}…${suffix}`;
    }
    
    function buildSetupNote(cwd) {
      const availability = getClaudeAvailability(cwd);
      if (!availability.available) {
        return `Claude Code is not set up for the review gate. ${availability.detail}. Run $cc:setup.`;
      }
    
      const authStatus = getClaudeAuthStatus(cwd);
      if (!authStatus.loggedIn) {
        const detail = authStatus.detail ? ` ${authStatus.detail}.` : "";
        return `Claude Code is not set up for the review gate.${detail} Run $cc:setup and, if needed, \`claude auth login\`.`;
      }
    
      return null;
    }
    
    // ---------------------------------------------------------------------------
    // Prompt building
    // ---------------------------------------------------------------------------
    
    function buildStopReviewPrompt(input = {}) {
      const lastAssistantMessage = String(input.last_assistant_message ?? "").trim();
      const template = loadPromptTemplate(ROOT_DIR, "stop-review-gate");
      const previousResponseBlock = lastAssistantMessage
        ? [
            "<previous_codex_response>",
            lastAssistantMessage,
            "</previous_codex_response>",
          ].join("\n")
        : "";
      return interpolateTemplate(template, {
        PREVIOUS_RESPONSE_BLOCK: previousResponseBlock
      });
    }
    
    // ---------------------------------------------------------------------------
    // Output parsing
    // ---------------------------------------------------------------------------
    
    function parseStopReviewOutput(rawOutput) {
      const text = String(rawOutput ?? "").trim();
      if (!text) {
        return {
          ok: false,
          rawOutput: text,
          firstLine: "",
          reason:
            "The turn-end Claude Code review returned no output. Run $cc:review --wait manually or bypass the gate."
        };
      }
    
      const firstLine = text.split(/\r?\n/, 1)[0].trim();
      const allowIndex = text.indexOf("ALLOW:");
      const blockIndex = text.indexOf("BLOCK:");
      const markerIndex =
        allowIndex === -1
          ? blockIndex
          : blockIndex === -1
            ? allowIndex
            : Math.min(allowIndex, blockIndex);
      const contractText = markerIndex >= 0 ? text.slice(markerIndex).trim() : text;
      const contractFirstLine = contractText.split(/\r?\n/, 1)[0].trim();
    
      if (firstLine.startsWith("ALLOW:")) {
        return { ok: true, reason: null, rawOutput: text, firstLine };
      }
      if (firstLine.startsWith("BLOCK:")) {
        const reason = firstLine.slice("BLOCK:".length).trim() || text;
        return {
          ok: false,
          rawOutput: text,
          firstLine,
          reason: `Claude Code turn-end review found issues that still need fixes before ending this Codex turn: ${reason}`
        };
      }
      if (contractFirstLine.startsWith("ALLOW:")) {
        return { ok: true, reason: null, rawOutput: text, firstLine: contractFirstLine };
      }
      if (contractFirstLine.startsWith("BLOCK:")) {
        const reason =
          contractFirstLine.slice("BLOCK:".length).trim() || contractText;
        return {
          ok: false,
          rawOutput: text,
          firstLine: contractFirstLine,
          reason: `Claude Code turn-end review found issues that still need fixes before ending this Codex turn: ${reason}`
        };
      }
    
      return {
        ok: false,
        rawOutput: text,
        firstLine,
        reason:
          "The turn-end Claude Code review returned an unexpected answer. Run $cc:review --wait manually or bypass the gate."
      };
    }
    
    // ---------------------------------------------------------------------------
    // Review execution via Claude CLI
    // --------------------------------------------------------------
  • hooks/unread-result-hook.mjsRunsGitHub
    Read the script
    #!/usr/bin/env node
    
    /**
     * Copyright 2026 Sendbird, Inc.
     * SPDX-License-Identifier: Apache-2.0
     */
    
    import process from "node:process";
    import path from "node:path";
    import { fileURLToPath } from "node:url";
    
    import { readHookInput } from "./lib/hook-input.mjs";
    import { detectExternalHostOrigin } from "./lib/host-origin.mjs";
    import { cleanupAfterOfficialUninstall } from "./lib/plugin-install-guard.mjs";
    import {
      getConfig,
      listJobs,
      patchJob,
      setCurrentSession,
      writeTurnBaseline,
    } from "../scripts/lib/state.mjs";
    import { getWorkingTreeFingerprint } from "../scripts/lib/git.mjs";
    import { nowIso, SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs";
    import { resolveWorkspaceRoot } from "../scripts/lib/workspace.mjs";
    
    const MAX_LISTED_JOBS = 3;
    const SKIP_INTERACTIVE_HOOKS_ENV = "CLAUDE_COMPANION_SKIP_INTERACTIVE_HOOKS";
    const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
    
    function isExplicitClaudeStatusRequest(prompt) {
      const text = String(prompt ?? "").toLowerCase();
      return text.includes("$cc:status") || text.includes("$cc:result");
    }
    
    function summarizeJob(job) {
      const parts = [job.id];
      if (job.kindLabel) parts.push(job.kindLabel);
      if (job.summary) parts.push(job.summary);
      return parts.join(" | ");
    }
    
    function buildAdditionalContext(jobs) {
      const listed = jobs.slice(0, MAX_LISTED_JOBS).map((job) => `- ${summarizeJob(job)}`);
      const remaining = jobs.length - listed.length;
      const intro =
        jobs.length === 1
          ? "A Claude Code background job from this session has finished and has not been surfaced yet."
          : `${jobs.length} Claude Code background jobs from this session have finished and have not been surfaced yet.`;
    
      const guidance =
        jobs.length === 1
          ? `Before handling the new request, briefly mention that ${jobs[0].id} finished and ask whether the user wants to inspect its result first or continue with the new request. If they want the result, direct them to \`$cc:result ${jobs[0].id}\`. If the user is clearly asking about this finished work already, answer that directly instead of asking again. Do not bring this completion up again automatically after this turn.`
          : "Before handling the new request, briefly mention that these Claude Code jobs finished and ask whether the user wants to inspect them first or continue with the new request. If they want to inspect them, direct them to `$cc:status` first, then `$cc:result <job-id>` for a specific finished job. If the user is clearly asking about this finished work already, answer that directly instead of asking again. Do not bring these completions up again automatically after this turn.";
    
      return [
        intro,
        "",
        "Finished jobs:",
        ...listed,
        ...(remaining > 0 ? [`- and ${remaining} more finished Claude Code job(s)`] : []),
        "",
        guidance,
      ].join("\n");
    }
    
    function selectUnreadCompletedJobs(jobs, sessionId) {
      if (!sessionId) {
        return [];
      }
    
      return jobs
        .filter((job) => job.sessionId === sessionId)
        .filter((job) => job.status === "completed")
        .filter((job) => !job.resultViewedAt)
        .filter((job) => !job.notifiedAt)
        .sort((left, right) =>
          String(right.updatedAt ?? right.completedAt ?? "").localeCompare(
            String(left.updatedAt ?? left.completedAt ?? "")
          )
        );
    }
    
    function markJobsNotified(workspaceRoot, jobs) {
      const timestamp = nowIso();
      for (const job of jobs) {
        patchJob(workspaceRoot, job.id, {
          notifiedAt: timestamp,
        });
      }
    }
    
    function listJobsSafely(workspaceRoot) {
      try {
        // listJobs() triggers the PID-reuse-safe stale job reaper.
        return listJobs(workspaceRoot);
      } catch {
        // Best effort only: unread-result steering should not fail a user prompt
        // because stale job reaping hit a filesystem or process-inspection race.
        return [];
      }
    }
    
    function captureTurnBaseline(workspaceRoot, sessionId, cwd) {
      if (!sessionId) {
        return;
      }
      try {
        const fingerprint = getWorkingTreeFingerprint(cwd);
        writeTurnBaseline(workspaceRoot, sessionId, {
          cwd,
          workspaceRoot,
          capturedAt: nowIso(),
          fingerprint,
        });
      } catch {
        // Baseline capture is best-effort. If it fails, Stop skips the review for
        // this turn rather than reviewing a turn it cannot delimit.
      }
    }
    
    async function main() {
      const input = readHookInput();
      if (cleanupAfterOfficialUninstall(ROOT_DIR)) {
        return;
      }
      const cwd = input.cwd || process.cwd();
      const workspaceRoot = resolveWorkspaceRoot(cwd);
      const sessionId = input.session_id || process.env[SESSION_ID_ENV] || null;
      const prompt = String(input.prompt ?? "");
    
      if (
        process.env[SKIP_INTERACTIVE_HOOKS_ENV] === "1" ||
        !sessionId
      ) {
        return;
      }
    
      try {
        setCurrentSession(workspaceRoot, sessionId, {
          hostOrigin: detectExternalHostOrigin(),
        });
      } catch {
        // Best effort only: an invalid session id should not fail a user prompt.
      }
      const config = getConfig(workspaceRoot);
      if (config.stopReviewGate) {
        captureTurnBaseline(workspaceRoot, sessionId, cwd);
      }
      const jobs = listJobsSafely(workspaceRoot);
    
      if (isExplicitClaudeStatusRequest(prompt)) {
        return;
      }
    
      const unreadJobs = selectUnreadCompletedJobs(jobs, sessionId);
      if (unreadJobs.length === 0) {
        return;
      }
    
      markJobsNotified(workspaceRoot, unreadJobs);
      process.stdout.write(`${buildAdditionalContext(unreadJobs)}\n`);
    }
    
    main().catch((error) => {
      process.stderr.write(
        `${error instanceof Error ? error.message : String(error)}\n`
      );
      process.exit(1);
    });
    

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withcc-plugin-codex

An open-source plugin that runs inside Codex and lets you use Claude Code and Claude models for review, rescue, and tracked background workflows.

Get the whole plugin
Stats
172
Stars
31
Forks
Active
Maintenance
JavaScript
Language
Apache-2.0
License
1d ago
Last commit
4mo ago
Created

Repo: sendbird/cc-plugin-codex