Skip to content
Productivity
Hook

Hooks

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

From plugin
crisandrews-agent
6221 skills10 hooks1 MCP
Install
> /plugin marketplace add crisandrews/ClawCode
> /plugin install agent@clawcode

Ships with crisandrews-agent. 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.

  • bash ${CLAUDE_PLUGIN_ROOT}/hooks/reconcile-crons.shbash ${CLAUDE_PLUGIN_ROOT}/hooks/scope-trust-legacy-warn.sh
  • node "${CLAUDE_PLUGIN_ROOT}/hooks/live-observe.mjs"

PreToolUse

  • node "${CLAUDE_PLUGIN_ROOT}/hooks/live-leader-pretool.mjs"
  • MatchesCronCreatebash ${CLAUDE_PLUGIN_ROOT}/hooks/cron-pretool.sh
  • MatchesBash|Edit|Write|NotebookEdit|MultiEdit|Task|Agent|mcp__.*bash ${CLAUDE_PLUGIN_ROOT}/hooks/exec-gate-pretool.sh
  • node "${CLAUDE_PLUGIN_ROOT}/hooks/live-observe.mjs"

PostToolUse

  • MatchesCronCreate|CronDeletebash ${CLAUDE_PLUGIN_ROOT}/hooks/cron-posttool.sh
  • node "${CLAUDE_PLUGIN_ROOT}/hooks/live-observe.mjs"

PreCompact

  • echo '[clawcode] Context nearing compaction. Save important information from this conversation to memory/'$(date +%Y-%m-%d)'.md — APPEND only, do not overwrite existing entries. If nothing to save, skip.'

Stop

  • echo '[clawcode] Session ending. If this was a significant conversation, write a brief summary to memory/'$(date +%Y-%m-%d)'.md before closing. Include: what was discussed, decisions made, open items.'
  • node "${CLAUDE_PLUGIN_ROOT}/hooks/live-observe.mjs"

SessionEnd

  • DIR="${CLAUDE_PROJECT_DIR:-$PWD}"; mkdir -p "$DIR/memory/.dreams" 2>/dev/null; echo '{"type":"session.end","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' >> "$DIR/memory/.dreams/events.jsonl" 2>/dev/null; exit 0
  • node "${CLAUDE_PLUGIN_ROOT}/hooks/live-observe.mjs"

SubagentStart

  • node "${CLAUDE_PLUGIN_ROOT}/hooks/live-observe.mjs"

SubagentStop

  • node "${CLAUDE_PLUGIN_ROOT}/hooks/live-observe.mjs"

PostToolUseFailure

  • node "${CLAUDE_PLUGIN_ROOT}/hooks/live-observe.mjs"

PostModelSwitch

  • node "${CLAUDE_PLUGIN_ROOT}/hooks/live-observe.mjs"
Read hooks/hooks.json

In the plugin's words

How crisandrews-agent describes its own hook set.

Agent lifecycle hooks — identity injection, cron reconcile, PostToolUse cron capture, memory flush, session summary.

Where it lives

  • hooks/cron-posttool.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # cron-posttool.sh — PostToolUse hook for CronCreate and CronDelete.
    # Captures ad-hoc cron creations into the registry automatically; tombstones
    # user-initiated deletes. Runs in strict guard mode:
    #
    #   - Recursion guard: skip if memory/.reconciling marker is fresh (<10 min).
    #   - Idempotency: skip if harnessTaskId already tracked.
    #   - Non-blocking: any failure exits 0 silently.
    #
    # See docs/crons.md for the full rationale.
    set -uo pipefail
    
    # See reconcile-crons.sh for the full rationale. Same PATH prefix so jq
    # installed to ~/.local/bin is visible to this hook too.
    export PATH="$HOME/.local/bin:$HOME/bin:/usr/local/bin:/opt/homebrew/bin:$PATH"
    
    AGENT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
    HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$HOOK_DIR")}"
    WRITEBACK="$PLUGIN_ROOT/skills/crons/writeback.sh"
    
    MEMORY_DIR="$AGENT_ROOT/memory"
    REGISTRY="$MEMORY_DIR/crons.json"
    RECONCILING_MARKER="$MEMORY_DIR/.reconciling"
    PENDING_LOG="$MEMORY_DIR/crons-pending.jsonl"
    MAX_MARKER_AGE_SEC=600  # 10 minutes — stale marker is ignored
    
    # Silent exit on any unexpected condition — hooks must never block.
    command -v jq >/dev/null 2>&1 || exit 0
    
    PAYLOAD=$(cat 2>/dev/null || true)
    [[ -z "$PAYLOAD" ]] && exit 0
    
    TOOL_NAME=$(printf '%s' "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null || true)
    case "$TOOL_NAME" in
      CronCreate|CronDelete) ;;
      *) exit 0 ;;
    esac
    
    # --- Recursion guard: suppress capture during SessionStart reconcile. ---
    if [[ -f "$RECONCILING_MARKER" ]]; then
      marker_mtime=$(stat -f %m "$RECONCILING_MARKER" 2>/dev/null || stat -c %Y "$RECONCILING_MARKER" 2>/dev/null || echo 0)
      now=$(date +%s)
      age=$((now - marker_mtime))
      if [[ $age -ge 0 && $age -lt $MAX_MARKER_AGE_SEC ]]; then
        exit 0
      fi
      # Stale: clean up and fall through.
      rm -f "$RECONCILING_MARKER" 2>/dev/null || true
    fi
    
    # --- Dispatch ---
    if [[ "$TOOL_NAME" == "CronCreate" ]]; then
      CRON=$(printf '%s' "$PAYLOAD"       | jq -r '.tool_input.cron // empty'      2>/dev/null || true)
      PROMPT=$(printf '%s' "$PAYLOAD"     | jq -r '.tool_input.prompt // empty'    2>/dev/null || true)
      # NOT `.tool_input.recurring // true`: jq's `//` swallows boolean false, so
      # that idiom stored every one-shot as recurring=true — which made fired
      # one-shots invisible to writeback.sh prune-expired (it requires
      # recurring==false) and resurrected them on every reconcile.
      RECURRING=$(printf '%s' "$PAYLOAD"  | jq -r 'if (.tool_input | has("recurring")) then (.tool_input.recurring | tostring) else "true" end' 2>/dev/null || echo "true")
    
      [[ -z "$CRON" || -z "$PROMPT" ]] && exit 0
    
      # Extract 8hex task_id. The harness response shape changed across versions:
      #   v2.1.114+: tool_response is an object: {"id":"abc12345","humanSchedule":...,"durable":false}
      #   v2.1.113-: tool_response is a string: "Scheduled <id> (<cron>)" or
      #              "Scheduled recurring|one-shot job <id> ..."
      # Try object form first (modern), fall back to string regex (legacy).
      TASK_ID=$(printf '%s' "$PAYLOAD" | jq -r '.tool_response.id // empty' 2>/dev/null || true)
      if [[ -z "$TASK_ID" ]]; then
        RESPONSE=$(printf '%s' "$PAYLOAD" | jq -r '.tool_response // empty' 2>/dev/null || true)
        if [[ "$RESPONSE" =~ Scheduled[[:space:]]+((recurring|one-shot)[[:space:]]+job[[:space:]]+)?([0-9a-f]{8}) ]]; then
          TASK_ID="${BASH_REMATCH[3]}"
        fi
      fi
      [[ -z "$TASK_ID" ]] && exit 0  # No task_id found → tool may have failed; do nothing.
    
      # Idempotency check: skip if harnessTaskId already tracked under any key.
      if [[ -f "$REGISTRY" ]]; then
        if jq -e --arg id "$TASK_ID" '.entries | any(.harnessTaskId == $id)' "$REGISTRY" >/dev/null 2>&1; then
          exit 0
        fi
      fi
    
      # Audit trail.
      mkdir -p "$MEMORY_DIR" 2>/dev/null || true
      printf '{"ts":"%s","tool":"CronCreate","task_id":"%s","cron":%s,"prompt":%s}\n' \
        "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$TASK_ID" \
        "$(printf '%s' "$CRON"   | jq -Rs .)" \
        "$(printf '%s' "$PROMPT" | jq -Rs .)" \
        >> "$PENDING_LOG" 2>/dev/null || true
    
      # Explicit expiry metadata: bin/cron-from.sh stamps line 3 of
      # memory/.cron-last-stamp with the one-shot's target epoch (empty for
      # recurring). Trust it only when the stamp's cron matches the captured
      # cron — the same binding rule the pretool gate enforces. This is what
      # lets writeback.sh prune-expired retire fired one-shots instead of
      # reconcile resurrecting them a year later.
      TARGET_EPOCH=""
      STAMP_FILE="$MEMORY_DIR/.cron-last-stamp"
      if [[ -f "$STAMP_FILE" ]]; then
        STAMP_CRON=$(sed -n 1p "$STAMP_FILE" 2>/dev/null || true)
        STAMP_TARGET=$(sed -n 3p "$STAMP_FILE" 2>/dev/null || true)
        if [[ "$STAMP_CRON" == "$CRON" && "$STAMP_TARGET" =~ ^[0-9]+$ ]]; then
          TARGET_EPOCH="$STAMP_TARGET"
        fi
      fi
    
      # Two explicit invocations instead of a ${VAR:+...} conditional expansion:
      # bash word-splits that correctly (two argv words) but zsh would pass a
      # single "--target-epoch <n>" word — this hook is bash, but the explicit
      # form costs nothing and can't be mis-run or mis-read.
      if [[ -n "$TARGET_EPOCH" ]]; then
        bash "$WRITEBACK" upsert \
          --harness-task-id "$TASK_ID" \
          --source ad-hoc \
          --cron "$CRON" \
          --prompt "$PROMPT" \
          --recurring "$RECURRING" \
          --target-epoch "$TARGET_EPOCH" >/dev/null 2>&1 || exit 0
      else
        bash "$WRITEBACK" upsert \
          --harness-task-id "$TASK_ID" \
          --source ad-hoc \
          --cron "$CRON" \
          --prompt "$PROMPT" \
          --recurring "$RECURRING" >/dev/null 2>&1 || exit 0
      fi
    
    elif [[ "$TOOL_NAME" == "CronDelete" ]]; then
      TASK_ID=$(printf '%s' "$PAYLOAD" | jq -r '.tool_input.id // empty' 2>/dev/null || true)
      [[ -z "$TASK_ID" ]] && exit 0
    
      # Tombstone only on successful delete. Same response-shape evolution as
      # CronCreate: modern is object {"cancelled":true,...}, legacy is text
      # containing the word "Cancelled". Accept either as success signal.
      CANCELLED_FLAG=$(print
  • hooks/cron-pretool.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # cron-pretool.sh — PreToolUse hook for CronCreate.
    # Blocks the tool call unless the cron expression matches a recent
    # bin/cron-from.sh output, turning skill rule #1 (never compute cron
    # expressions yourself) from doctrine into an enforced invariant.
    #
    #   - Reads memory/.cron-last-stamp (three lines: cron, epoch seconds,
    #     target epoch for one-shots; this gate consumes the first two).
    #   - Accepts if stamp age < 120s AND stamp cron == tool_input.cron.
    #   - Exits 0 silently for unrelated tools, empty payloads, or when
    #     memory/.reconciling marker is fresh (<10 min since its last
    #     refresh — writeback.sh set-alive refreshes it as the reconcile
    #     progresses) — SessionStart reconcile recreates crons from the
    #     registry and must bypass this check, same pattern as
    #     hooks/cron-posttool.sh.
    #   - On rejection: exit 2 with a stderr message that teaches the
    #     agent the fix (run cron-from.sh first).
    #
    # See docs/crons.md for the full rationale.
    set -uo pipefail
    
    # Same PATH prefix as the other crons hooks so jq installed to
    # ~/.local/bin is visible under a stripped launchd/systemd PATH.
    export PATH="$HOME/.local/bin:$HOME/bin:/usr/local/bin:/opt/homebrew/bin:$PATH"
    
    AGENT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
    MEMORY_DIR="$AGENT_ROOT/memory"
    STAMP="$MEMORY_DIR/.cron-last-stamp"
    RECONCILING_MARKER="$MEMORY_DIR/.reconciling"
    MAX_STAMP_AGE_SEC=120
    MAX_RECONCILE_AGE_SEC=600
    
    # Silent exit on any unexpected condition — hooks must not block unrelated
    # work. If jq is missing we let the call through (posttool has the same
    # fallback; the user is already degraded in visible ways elsewhere).
    command -v jq >/dev/null 2>&1 || exit 0
    
    PAYLOAD=$(cat 2>/dev/null || true)
    [[ -z "$PAYLOAD" ]] && exit 0
    
    TOOL_NAME=$(printf '%s' "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
    [[ "$TOOL_NAME" == "CronCreate" ]] || exit 0
    
    # Reconcile bypass: SessionStart touches the marker before replaying the
    # registry's crons through CronCreate. Those calls re-use stored crons
    # and must not be gated.
    if [[ -f "$RECONCILING_MARKER" ]]; then
      marker_mtime=$(stat -f %m "$RECONCILING_MARKER" 2>/dev/null || stat -c %Y "$RECONCILING_MARKER" 2>/dev/null || echo 0)
      now=$(date +%s)
      age=$((now - marker_mtime))
      if [[ $age -ge 0 && $age -lt $MAX_RECONCILE_AGE_SEC ]]; then
        exit 0
      fi
      rm -f "$RECONCILING_MARKER" 2>/dev/null || true
    fi
    
    INPUT_CRON=$(printf '%s' "$PAYLOAD" | jq -r '.tool_input.cron // empty' 2>/dev/null)
    # No cron in the input → let the harness handle the validation error
    # (not our job to double-check schemas).
    [[ -z "$INPUT_CRON" ]] && exit 0
    
    # Appended to every rejection: a long reconcile interleaved with chat can
    # outlive the .reconciling marker (it expires 10 min after its last refresh;
    # writeback.sh set-alive refreshes it on every recreated entry). Without
    # this hint the agent gets a stamp error that points at cron-from.sh, which
    # is the wrong fix mid-reconcile (registry crons are replayed verbatim).
    RECONCILE_HINT="If you are mid-reconcile (recreating registry entries from memory/crons.json after SessionStart), the memory/.reconciling marker has expired — run:  touch \"$MEMORY_DIR/.reconciling\"  and retry. It auto-expires 10 minutes after its last refresh."
    
    if [[ ! -f "$STAMP" ]]; then
      >&2 cat <<EOF
    ❌ CronCreate blocked: no cron-from.sh stamp found.
    Skill rule #1 (skills/crons/SKILL.md) requires every cron expression to
    come from the deterministic helper. Run it first, then re-issue CronCreate
    with its .cron field verbatim:
    
      bash \$CLAUDE_PLUGIN_ROOT/bin/cron-from.sh relative 5 minutes
      bash \$CLAUDE_PLUGIN_ROOT/bin/cron-from.sh absolute "14:30"
      bash \$CLAUDE_PLUGIN_ROOT/bin/cron-from.sh recurring daily "09:00"
      bash \$CLAUDE_PLUGIN_ROOT/bin/cron-from.sh passthrough "0 0 * * 0-3"
    EOF
      >&2 printf '\n%s\n' "$RECONCILE_HINT"
      exit 2
    fi
    
    STAMP_CRON=$(sed -n 1p "$STAMP" 2>/dev/null || true)
    STAMP_TS=$(sed -n 2p "$STAMP" 2>/dev/null || true)
    
    # Defensive: if the stamp file is malformed, treat as missing rather than
    # silently passing. Same guidance as the missing-stamp case.
    if [[ -z "$STAMP_CRON" || -z "$STAMP_TS" || ! "$STAMP_TS" =~ ^[0-9]+$ ]]; then
      >&2 echo "❌ CronCreate blocked: cron-from.sh stamp at '$STAMP' is malformed. Re-run the helper immediately before CronCreate."
      >&2 printf '\n%s\n' "$RECONCILE_HINT"
      exit 2
    fi
    
    NOW=$(date +%s)
    AGE=$((NOW - STAMP_TS))
    
    if (( AGE < 0 )); then
      # Clock went backwards — treat as fresh (avoid blocking legit work on
      # NTP corrections). No block.
      AGE=0
    fi
    
    if (( AGE >= MAX_STAMP_AGE_SEC )); then
      >&2 echo "❌ CronCreate blocked: cron-from.sh stamp is ${AGE}s old (max ${MAX_STAMP_AGE_SEC}s). Re-run the helper immediately before CronCreate so the cron reflects current time."
      >&2 printf '\n%s\n' "$RECONCILE_HINT"
      exit 2
    fi
    
    if [[ "$INPUT_CRON" != "$STAMP_CRON" ]]; then
      >&2 cat <<EOF
    ❌ CronCreate blocked: cron expression does not match the last cron-from.sh output.
      your CronCreate.cron : $INPUT_CRON
      last helper output   : $STAMP_CRON
    Use the helper's .cron field verbatim, or run the helper again (e.g.
    'passthrough "<cron>"') to register the expression you actually want.
    EOF
      >&2 printf '\n%s\n' "$RECONCILE_HINT"
      exit 2
    fi
    
    exit 0
    
  • hooks/exec-gate-pretool.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # exec-gate-pretool.sh — PreToolUse hook for the execution gate.
    #
    # Architecture (Codex Step 2 pre-impl, Option E + pre-built CJS):
    #
    #   1. Hot path (mode=off everywhere AND tool is NOT a write tool):
    #      bash + jq probe of agent-config.json + early exit. Target <15ms.
    #      This is the 99% case for users who never opt into the gate.
    #
    #   2. Write-tool path (Write/Edit/MultiEdit/NotebookEdit):
    #      ALWAYS invoke the CJS resolver. Protected-paths must fire
    #      regardless of mode (mode=off users still get plugin-hooks /
    #      ~/.ssh / agent-config protection).
    #
    #   3. Armed path (any channel's execGate.mode != "off"):
    #      Invoke dist/exec-gate-resolver.cjs via node. Target <50ms.
    #
    # Exit codes:
    #   - 0   = allow. Tool call proceeds.
    #   - 2   = block. Stderr surfaces the reason to the user.
    #
    # Fail-soft: any unexpected condition (jq missing, node missing, CJS
    # bundle missing, malformed stdin, resolver crash) → exit 0. Hooks MUST
    # NEVER block legitimate work due to plugin internals. Same posture as
    # hooks/cron-pretool.sh.
    
    set -uo pipefail
    
    # PATH prefix for ~/.local/bin so jq installed via Homebrew/asdf is
    # visible under stripped launchd/systemd PATH (mirror of cron-pretool.sh).
    export PATH="$HOME/.local/bin:$HOME/bin:/usr/local/bin:/opt/homebrew/bin:$PATH"
    
    WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
    PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
    CONFIG_FILE="$WORKSPACE_ROOT/agent-config.json"
    CJS_BUNDLE="$PLUGIN_ROOT/dist/exec-gate-resolver.cjs"
    
    # Fail-soft preflight: required tools.
    command -v jq >/dev/null 2>&1 || exit 0
    command -v node >/dev/null 2>&1 || exit 0
    
    # Read stdin payload (drained once — we'll re-feed it to node if needed).
    PAYLOAD=$(cat 2>/dev/null || true)
    [[ -z "$PAYLOAD" ]] && exit 0
    
    # Extract tool_name. Empty → unrelated event, exit silently.
    TOOL_NAME=$(printf '%s' "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
    [[ -z "$TOOL_NAME" ]] && exit 0
    
    # Write-tool short-circuit: ALWAYS invoke the resolver because the
    # always-on protected-paths check must fire regardless of mode.
    case "$TOOL_NAME" in
      Write|Edit|MultiEdit|NotebookEdit)
        INVOKE_RESOLVER=1
        ;;
      *)
        INVOKE_RESOLVER=0
        ;;
    esac
    
    # If we don't already need the resolver, check whether ANY scope channel
    # has a non-off execGate. File-absent is a zero-cost skip — loadConfig
    # returns defaults (mode=off everywhere) when agent-config.json is
    # missing.
    #
    # Codex Step 2 post-impl round-1 FAIL B: the probe MUST be conservative.
    # The previous version counted only entries where `.execGate.mode` was
    # the literal string `"shadow"` or `"enforce"`. That misses every
    # malformed shape: `execGate: null`, `execGate: "string"`, `execGate: []`,
    # `execGate: {mode: "weird"}`, etc. The TS coercion treats all of those
    # as fail-closed (enforce + denylist). The shell probe must agree, or a
    # malformed config silently re-opens the gate.
    #
    # Classification rule (matches `coerceExecGateConfig` in
    # lib/scope/exec-gate.ts):
    #   - channel-key value not an object         → off (channel itself ill-shaped, skip)
    #   - .execGate absent                         → off
    #   - .execGate == null                        → ARMED (enforce fallback)
    #   - .execGate is not an object               → ARMED (enforce fallback)
    #   - .execGate has no .mode field             → off (TS coerces undefined→off)
    #   - .execGate.mode == "off"                  → off
    #   - .execGate.mode anything else             → ARMED
    #
    # Plus: if jq itself errors (malformed JSON, missing binary), we fail
    # CLOSED and invoke the resolver. The previous `|| echo "0"` swallowed
    # jq errors as "no armed channels"; that bypass is now removed.
    if [[ $INVOKE_RESOLVER -eq 0 ]]; then
      if [[ ! -f "$CONFIG_FILE" ]]; then
        exit 0
      fi
      # Codex round-2 HIGH 1 closure: when execGate.mode == "off" but the
      # block has malformed sub-fields (policy, tools, lookbackMs), the TS
      # coercion in `coerceExecGateConfig` escalates the whole block to
      # enforce (fail-closed). The jq probe must agree — checking ONLY the
      # mode field would let `{mode:"off", policy:"weird"}` exit the hot
      # path while the resolver would have armed. Defensive rule: a strict
      # off requires mode=="off" AND every other present sub-field is the
      # exact type the coercion accepts. Anything else → armed.
      # Also: a non-object `scope.<channel>` value is a configuration error
      # too (Codex HIGH 2 mirror), but the TS entry script catches that
      # case post-merge. The jq fast path stays "off" for those because
      # the resolver still has to be invoked for protected-paths-write
      # tools anyway, and for non-write tools the entry's malformed-
      # channel synthesis is what surfaces the gate.
      ARMED_COUNT=$(jq -r '
        [
          (.scope // {}) | to_entries[] |
          .value as $v |
          if $v == null or ($v | type) != "object" then
            # Codex round-2 HIGH 2 (jq side): non-object channel values
            # silently dropped by mergeScopeConfig — they MUST route to the
            # resolver so the entry script can synthesize an unresolved
            # sentinel and the gate fires. Without this, the bash hot path
            # exits 0 before the entry has any chance to inspect the raw
            # JSON.
            "armed"
          elif ($v | has("execGate") | not) then
            "off"
          elif $v.execGate == null then
            "armed"
          elif ($v.execGate | type) != "object" then
            "armed"
          elif ($v.execGate | has("mode") | not) then
            "off"
          elif $v.execGate.mode != "off" then
            "armed"
          else
            # mode == "off". Check sub-field validity. Any invalid sub-field
            # escalates to armed (matches TS coerce).
            ($v.execGate | (
              (if has("policy") then
                (if (.policy == "denylist" or .policy == "allowlist") then "ok" else "bad" end)
               else "ok" end)
              + "/" +
              (if has("tools") then
                (if (.tools | type) == "array" and ((.tools | all(type == "string"))) then "ok" 
  • hooks/live-leader-policy.mjsGitHub
  • hooks/live-leader-pretool.mjsRunsGitHub
    Read the script
    #!/usr/bin/env node
    /** Synchronous native hook; no network request or bridge availability dependency. */
    import fs from "node:fs";
    import path from "node:path";
    import { evaluateLeaderTool, normalizeHostLeaderPolicy, denyLeaderTool } from "./live-leader-policy.mjs";
    
    const workspace = process.env.CLAUDE_PROJECT_DIR || process.cwd();
    let enabled = false;
    try {
      let live;
      try { live = JSON.parse(fs.readFileSync(path.join(workspace, "agent-config.json"), "utf8")).liveBridge; }
      catch (error) {
        if (process.env.CLAWCODE_LIVE_LEADER_POLICY !== "1") process.exit(0);
        // A configured launcher pins policy activation for malformed/missing config.
        // Unconfigured installations remain silent, including their legacy errors.
        throw new Error("Cannot read valid leader configuration");
      }
      if (live?.enabled !== true || live.leaderPolicy === undefined || live.leaderPolicy?.enabled === false) process.exit(0);
      const policy = normalizeHostLeaderPolicy(live.leaderPolicy);
      if (!policy.enabled) process.exit(0);
      enabled = true;
      const raw = fs.readFileSync(0, "utf8");
      if (Buffer.byteLength(raw) > 1024 * 1024) throw new Error("Hook payload exceeds 1 MiB");
      const result = evaluateLeaderTool(JSON.parse(raw), policy, process.env);
      process.stdout.write(JSON.stringify(result));
    } catch {
      process.stdout.write(JSON.stringify(denyLeaderTool(enabled ? "Invalid native hook input; no tool permission was granted." : "Invalid leader policy configuration; repair it before operational work.")));
    }
    
  • hooks/live-observe.mjsRunsGitHub
    Read the script
    #!/usr/bin/env node
    /** Opt-in, metadata-only hook collector. Never reads transcripts or prints input. */
    import fs from "node:fs";
    import path from "node:path";
    import http from "node:http";
    import { randomUUID } from "node:crypto";
    import { pathToFileURL } from "node:url";
    import { readLiveToken } from "../lib/live-credentials.mjs";
    
    export function sanitizeHook(payload) {
      if (!payload || typeof payload !== "object" || typeof payload.session_id !== "string") return null;
      const allowed = new Set(["SessionStart", "PostModelSwitch", "SubagentStart", "SubagentStop", "PreToolUse", "PostToolUse", "PostToolUseFailure", "Stop", "SessionEnd"]);
      if (!allowed.has(payload.hook_event_name)) return null;
      const result = { id: randomUUID(), event: payload.hook_event_name, sessionId: payload.session_id };
      if (["SessionStart", "PostModelSwitch"].includes(payload.hook_event_name)) {
        const model = payload.hook_event_name === "PostModelSwitch" ? payload.to_model : payload.model;
        if (typeof model !== "string" || !model.trim() || model.length > 180) return null;
        result.model = model;
      }
      if (typeof payload.agent_id === "string") result.agentId = payload.agent_id;
      const safeIdentity = value => typeof value === "string" && value.length <= 180 && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value);
      // The documented agent type is a declared name, never its prompt or task.
      // A main session's --agent type alone is not evidence of a subagent.
      if (result.agentId && ["SubagentStart", "PreToolUse"].includes(payload.hook_event_name) && safeIdentity(payload.agent_type)) result.agentType = payload.agent_type;
      if (["PreToolUse", "PostToolUse", "PostToolUseFailure"].includes(payload.hook_event_name)) {
        // Identity metadata lets the UI observe the principal's current tool without
        // turning every tool call into a task or copying its command and arguments.
        if (safeIdentity(payload.tool_name)) result.toolName = payload.tool_name;
        if (safeIdentity(payload.tool_use_id)) result.toolUseId = payload.tool_use_id;
      }
      // Only the probe's PostToolUse can bind the host session. No other tool args,
      // tool results, paths, commands, message text, or channel tokens are retained.
      if (payload.hook_event_name === "PostToolUse" && /^mcp__.+__live_ack$/.test(payload.tool_name ?? "") && typeof payload.tool_input?.probe === "string") result.probe = payload.tool_input.probe;
      if (result.sessionId.length > 180 || result.agentId?.length > 180 || result.probe?.length > 180) return null;
      return result;
    }
    
    async function main() {
      const workspace = process.env.CLAUDE_PROJECT_DIR || process.cwd();
      let config;
      try { config = JSON.parse(fs.readFileSync(path.join(workspace, "agent-config.json"), "utf8")).liveBridge; } catch { return; }
      if (config?.enabled !== true || config.observeHooks !== true) return;
      const token = readLiveToken(workspace, config);
      if (!token) return;
      const port = config.port ?? 18791;
      if (!Number.isInteger(port) || port < 1 || port > 65535) return;
      process.stdin.setEncoding("utf8");
      let raw = "";
      for await (const chunk of process.stdin) { raw += chunk; if (Buffer.byteLength(raw) > 1048576) return; }
      let payload;
      try { payload = sanitizeHook(JSON.parse(raw)); } catch { return; }
      if (!payload) return;
      await new Promise(resolve => {
        const req = http.request({ hostname: "127.0.0.1", port, path: "/v1/live/hooks", method: "POST", timeout: 750,
          headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } }, res => { res.resume(); res.on("end", resolve); });
        req.on("timeout", () => req.destroy()); req.on("error", resolve); req.end(JSON.stringify(payload));
      });
    }
    if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch(() => {}).finally(() => { process.exitCode = 0; });
    
  • hooks/reconcile-crons.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    # reconcile-crons.sh — SessionStart hook for ClawCode.
    # Injects identity, seeds cron registry, cleans up legacy marker, and emits a
    # deterministic reconcile envelope for the agent to execute (CronList →
    # CronCreate missing → adopt unknown → report).
    #
    # Failure-mode contract: NEVER blocks session start. Any error path exits 0
    # with a warning on stderr. See docs/crons.md.
    set -uo pipefail
    
    # User-local bindirs first so jq installed to ~/.local/bin (pip --user,
    # Homebrew on Linuxbrew, manual installs) is visible inside systemd user
    # services, launchd LaunchAgents, and any other context that spawns the
    # hook with a minimal inherited PATH. Without this, `command -v jq`
    # returns empty and the hook silently drops into degraded mode.
    export PATH="$HOME/.local/bin:$HOME/bin:/usr/local/bin:/opt/homebrew/bin:$PATH"
    
    AGENT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
    HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$HOOK_DIR")}"
    WRITEBACK="$PLUGIN_ROOT/skills/crons/writeback.sh"
    
    MEMORY_DIR="$AGENT_ROOT/memory"
    REGISTRY="$MEMORY_DIR/crons.json"
    LEGACY_MARKER="$AGENT_ROOT/.crons-created"
    RECONCILING_MARKER="$MEMORY_DIR/.reconciling"
    OPENCLAW_CRON="${CLAWCODE_OPENCLAW_CRON:-$HOME/.openclaw/cron/jobs.json}"
    IMPORT_BACKLOG="$AGENT_ROOT/IMPORT_BACKLOG.md"
    
    fallback_warn() {
      echo "[clawcode] Reconcile hook failed (${1:-unknown}). Run /agent:crons reconcile manually once the REPL is up." >&2
      exit 0
    }
    
    # --- 0. SESSION BANNER (every session, English, additive) ---
    # Emits a 4-line header so the agent knows which ClawCode version it is
    # running and where to find docs / issues. Version is read at runtime
    # from plugin.json; never hardcoded (same pattern as skills/about/SKILL.md).
    # Wording is purely functional support copy — no engagement asks — to
    # stay clear of Anthropic's Software Directory Policy §4.C on
    # promotional content.
    CLAWCODE_VERSION=""
    if command -v jq >/dev/null 2>&1; then
      CLAWCODE_VERSION=$(jq -r '.version // empty' "$PLUGIN_ROOT/.claude-plugin/plugin.json" 2>/dev/null || true)
    fi
    if [[ -z "$CLAWCODE_VERSION" ]]; then
      CLAWCODE_VERSION=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_ROOT/.claude-plugin/plugin.json" 2>/dev/null | head -1)
    fi
    CLAWCODE_VERSION="${CLAWCODE_VERSION:-unknown}"
    cat <<BANNER
    === CLAWCODE v${CLAWCODE_VERSION} · MIT License ===
    Docs + advanced config: https://github.com/crisandrews/ClawCode
    If something misbehaves: run /agent:doctor.
    Report issues or share feedback at the same link.
    
    BANNER
    
    # --- 1. BOOTSTRAP path (first run, no identity files yet) ---
    if [[ -f "$AGENT_ROOT/BOOTSTRAP.md" ]]; then
      echo '[clawcode] BOOTSTRAP.md detected — this is a first run. Follow the BOOTSTRAP.md instructions to discover your identity.'
      exit 0
    fi
    
    # --- 2. IDENTITY INJECTION (preserve existing behavior) ---
    echo '=== YOUR IDENTITY (you MUST embody this) ==='
    cat "$AGENT_ROOT/SOUL.md" 2>/dev/null || true
    echo ''
    cat "$AGENT_ROOT/IDENTITY.md" 2>/dev/null || true
    echo ''
    echo '=== USER CONTEXT ==='
    cat "$AGENT_ROOT/USER.md" 2>/dev/null || true
    echo ''
    
    # --- 3. JQ PRESENCE CHECK (degraded mode if jq absent) ---
    if ! command -v jq >/dev/null 2>&1; then
      echo '[clawcode] WARNING: jq not installed. Cron persistence runs in degraded defaults-only mode.' >&2
      echo 'Install jq (macOS: brew install jq | Linux: apt install jq) for full registry behavior.' >&2
      cat <<'DEGRADED'
    
    === CLAWCODE RECONCILE (degraded — jq missing) ===
    STEP 1: ToolSearch with query "select:CronList,CronCreate,CronDelete".
    STEP 2: Call CronList. Scan output for the heartbeat and dreaming cron expressions.
    STEP 3: If heartbeat is not present, call CronCreate with cron "*/30 * * * *", prompt "Run /agent:heartbeat", durable true, recurring true.
    STEP 4: If dreaming is not present, call CronCreate with cron "0 3 * * *", prompt "Use the dream tool: dream(action=run)", durable true, recurring true.
    STEP 5: Print "[clawcode] Reconcile done (degraded)".
    DEGRADED
      exit 0
    fi
    
    # --- 4. SEED DEFAULTS / VALIDATE REGISTRY ---
    # seed-defaults is idempotent when valid; when corrupt, writeback.sh quarantines
    # to crons.json.corrupt-<ts> and rebuilds from defaults. Run unconditionally so
    # every SessionStart guarantees a valid registry on exit.
    if ! bash "$WRITEBACK" seed-defaults 2>&1; then
      fallback_warn "seed-defaults returned non-zero"
    fi
    
    # --- 4b. PRUNE EXPIRED ONE-SHOTS (best-effort; never blocks the reconcile) ---
    # Tombstones recurring=false entries whose explicit targetEpoch already
    # passed, so fired dated reminders stop being resurrected every session, and
    # reports legacy date-shaped entries (created before targetEpoch existed) as
    # suspects for the user to verify. Failures here must NOT route through
    # fallback_warn — that would abort the envelope and skip the whole reconcile,
    # strictly worse than reconciling with unpruned entries.
    PRUNE_OUT=$(bash "$WRITEBACK" prune-expired 2>&1) || {
      echo "[clawcode] prune-expired failed (non-fatal): $PRUNE_OUT" >&2
      PRUNE_OUT=""
    }
    if [[ -n "$PRUNE_OUT" ]]; then
      PRUNED_LINES=$(printf '%s\n' "$PRUNE_OUT" | grep '^pruned key=' || true)
      SUSPECT_LINES=$(printf '%s\n' "$PRUNE_OUT" | grep '^suspect key=' || true)
      if [[ -n "$PRUNED_LINES" ]]; then
        echo "[clawcode] Pruned expired one-shot reminder(s) from the registry:"
        printf '%s\n' "$PRUNED_LINES" | sed 's/^/  /'
      fi
      if [[ -n "$SUSPECT_LINES" ]]; then
        echo "[clawcode] Date-shaped reminders that look already expired (NOT auto-removed — verify with the user, then /agent:crons delete <key>):"
        printf '%s\n' "$SUSPECT_LINES" | sed 's/^/  /'
      fi
    fi
    
    # --- 5. LEGACY MARKER CLEANUP (after we know registry exists) ---
    if [[ -f "$REGISTRY" && -f "$LEGACY_MARKER" ]]; then
      rm -f "$LEGACY_MARKER" 2>/dev/null || true
    fi
    
    # --- 6. DETECT MIGRATION (progressive enhancement — silent unless evidence) ---
    MIGRATION_NEEDED=0
    MIGRATION_AGENT=""
    if [[ -f "$IMPORT_BACKLOG" && -f "$OPENCLAW_CRON" ]]; then
      MIGRATION_A
  • hooks/scope-trust-legacy-warn.shRunsGitHub

All 8 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.

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 withcrisandrews-agent

Persistent agents for Claude Code as a plugin, not a harness. Memory, personality, messaging across WhatsApp, Telegram, and Discord, plus a service mode for 24/7 runs. Imports from OpenClaw.

Get the whole plugin
Stats
62
Stars
14
Forks
Active
Maintenance
TypeScript
Language
MIT
License
23h ago
Last commit
5mo ago
Created

Repo: crisandrews/ClawCode