Skip to content
Automation
Hook

Hooks

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

From plugin
stagent
253 skills1 agent9 commands8 hooks
Install
> /plugin marketplace add jie-worldstatelabs/stagent
> /plugin install stagent@stagent

Ships with stagent. 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.

  • ${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh

SessionEnd

  • ${CLAUDE_PLUGIN_ROOT}/hooks/session-end.sh

Stop

  • ${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh

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.

  • ${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt-submit.sh

Notification

  • ${CLAUDE_PLUGIN_ROOT}/hooks/notification-hook.sh

PreToolUse

  • MatchesAgent${CLAUDE_PLUGIN_ROOT}/hooks/agent-guard.sh
  • MatchesBash${CLAUDE_PLUGIN_ROOT}/hooks/plugin-path-rewrite.sh
  • MatchesAskUserQuestion${CLAUDE_PLUGIN_ROOT}/hooks/ask-user-question-hook.sh
  • ${CLAUDE_PLUGIN_ROOT}/hooks/activity-hook.sh

PostToolUse

  • MatchesWrite|Edit|MultiEdit${CLAUDE_PLUGIN_ROOT}/hooks/postwrite-hook.sh
  • MatchesAgent${CLAUDE_PLUGIN_ROOT}/hooks/agent-ledger-add.sh
  • MatchesAskUserQuestion${CLAUDE_PLUGIN_ROOT}/hooks/ask-user-question-hook.sh
  • ${CLAUDE_PLUGIN_ROOT}/hooks/activity-hook.sh

SubagentStop

  • ${CLAUDE_PLUGIN_ROOT}/hooks/agent-ledger-remove.sh
Read hooks/hooks.json

In the plugin's words

How stagent describes its own hook set.

Dev workflow hooks — session-start caches session_id; stop-hook prevents exit during active workflow; session-end auto-interrupts the workflow on graceful exit so another Claude session can continue it; agent-guard steers subagent launches; postwrite mirrors cloud-mode shadow

Where it lives

  • hooks/activity-hook.shRunsGitHub
    Read the script
    #!/bin/bash
    # PreToolUse + PostToolUse hook (all tools) — cloud-mode activity log.
    #
    # Emits two events per tool call so the webapp can render a pending row
    # the moment a tool starts and upgrade it to "done" when it finishes:
    #   * PreToolUse  → cloud_post_activity ... event_kind=started
    #   * PostToolUse → cloud_post_activity ... event_kind=finished
    # The pair is correlated by tool_use_id (Claude Code provides it on
    # both events). For long-running tools (Bash sleeps, slow MCP calls)
    # this is the difference between a feed that looks dead and one that
    # looks alive.
    #
    # Always fire-and-forget (cloud_post_activity backgrounds the curl) —
    # zero latency impact on the agent.
    #
    # Skipped: non-cloud sessions, no active stage, terminal stages,
    #          and noisy internal tools (TodoWrite, TodoRead, LS).
    
    set -euo pipefail
    
    HOOK_INPUT=$(cat)
    
    HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
    source "$(dirname "$HOOK_DIR")/scripts/lib.sh"
    
    # ──────────────────────────────────────────────────────────────
    # Diagnostic log. Every invocation appends exactly one line so we
    # can reconstruct, after the fact, which Pre/PostToolUse pairs the
    # webapp never received. Keyed by tool_use_id (the pairing key) so a
    # missing `post=...` finished line for a given tuid pinpoints the
    # lost event. Best-effort: a logging failure must NEVER abort the
    # hook (that would make the diagnostics worse than the bug). The
    # `|| true` + subshell isolation guarantees set -e can't trip here.
    # ──────────────────────────────────────────────────────────────
    ACTIVITY_LOG="${HOME}/.cache/stagent/activity-hook.log"
    _alog() {
      { mkdir -p "$(dirname "$ACTIVITY_LOG")" 2>/dev/null &&
        printf '%s pid=%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)" "$$" "$*" \
          >> "$ACTIVITY_LOG"; } 2>/dev/null || true
    }
    # Log the final outcome no matter how the script exits (early-return
    # guards, set -e abort, or normal completion). Populated as we learn
    # the values; trap fires once on EXIT.
    _LOG_TOOL=""; _LOG_TUID=""; _LOG_EVENT=""; _LOG_STAGE=""; _LOG_REASON="enter"
    trap '_alog "exit=$? event=${_LOG_EVENT:-?} tool=${_LOG_TOOL:-?} tuid=${_LOG_TUID:-?} stage=${_LOG_STAGE:-?} reason=${_LOG_REASON}"' EXIT
    
    SID=$(echo "$HOOK_INPUT" | jq -r '.session_id // ""' 2>/dev/null || true)
    if [[ -z "$SID" ]]; then _LOG_REASON="no_sid"; exit 0; fi
    
    if ! is_cloud_session "$SID"; then _LOG_REASON="not_cloud"; exit 0; fi
    
    TOOL=$(echo "$HOOK_INPUT" | jq -r '.tool_name // ""' 2>/dev/null || true)
    _LOG_TOOL="$TOOL"
    if [[ -z "$TOOL" ]]; then _LOG_REASON="no_tool"; exit 0; fi
    
    # Skip internal / noisy tools
    case "$TOOL" in
      TodoWrite|TodoRead|LS) _LOG_REASON="skip_noisy"; exit 0 ;;
    esac
    
    EVENT_NAME=$(echo "$HOOK_INPUT" | jq -r '.hook_event_name // ""' 2>/dev/null || true)
    _LOG_EVENT="$EVENT_NAME"
    
    # Read current stage from shadow state.md
    SHADOW_DIR="$(cloud_registry_get "$SID" scratch_dir)"
    [[ -z "$SHADOW_DIR" ]] && SHADOW_DIR="${CLOUD_SCRATCH_BASE}/${SID}"
    STATE_FILE="${SHADOW_DIR}/state.md"
    if [[ ! -f "$STATE_FILE" ]]; then _LOG_REASON="no_state_file"; exit 0; fi
    
    STAGE=$(_read_fm_field "$STATE_FILE" status)
    _LOG_STAGE="$STAGE"
    if [[ -z "$STAGE" ]]; then _LOG_REASON="no_stage"; exit 0; fi
    
    EPOCH=$(_read_fm_field "$STATE_FILE" epoch)
    
    # Takeover aliasing: Claude Code's session_id ($SID) is the local CC
    # session, which in a takeover may differ from the cloud server-side
    # session_id. The cloud server keys rows by the server-side id, so
    # posting to /api/sessions/<local-SID>/activity 404s. state.md's
    # frontmatter always carries the canonical cloud session_id — use it.
    CLOUD_SID=$(_read_fm_field "$STATE_FILE" session_id)
    [[ -z "$CLOUD_SID" ]] && CLOUD_SID="$SID"
    
    # Skip known terminal statuses
    case "$STAGE" in
      complete|cancelled|archived|interrupted) _LOG_REASON="terminal_stage"; exit 0 ;;
    esac
    
    # Extract a one-line summary from tool_input — same shape regardless
    # of which hook fired (tool_input is present in both Pre/PostToolUse).
    INPUT=$(echo "$HOOK_INPUT" | jq -r '.tool_input // {}' 2>/dev/null || echo "{}")
    
    case "$TOOL" in
      Read)
        SUMMARY=$(echo "$INPUT" | jq -r '.file_path // ""' 2>/dev/null || true)
        ;;
      Write|Edit|MultiEdit)
        SUMMARY=$(echo "$INPUT" | jq -r '.file_path // ""' 2>/dev/null || true)
        ;;
      Bash)
        SUMMARY=$(echo "$INPUT" | jq -r '.command // ""' 2>/dev/null | cut -c1-120 || true)
        ;;
      Grep)
        PAT=$(echo "$INPUT" | jq -r '.pattern // ""' 2>/dev/null || true)
        PPATH=$(echo "$INPUT" | jq -r '.path // ""' 2>/dev/null || true)
        SUMMARY="${PAT}${PPATH:+ in ${PPATH}}"
        ;;
      Glob)
        SUMMARY=$(echo "$INPUT" | jq -r '.pattern // ""' 2>/dev/null || true)
        ;;
      Agent)
        SUMMARY=$(echo "$INPUT" | jq -r '.subagent_type // .description // ""' 2>/dev/null \
                  | cut -c1-80 || true)
        # Prompt capture happens in agent-guard.sh (PreToolUse) — posting
        # it here would be delayed until the subagent returns, which for
        # long stages can mean the webapp shows "No prompt captured" for
        # the entire run. Keep this branch to just emit the activity-feed
        # summary above.
        ;;
      WebSearch)
        SUMMARY=$(echo "$INPUT" | jq -r '.query // ""' 2>/dev/null || true)
        ;;
      WebFetch)
        SUMMARY=$(echo "$INPUT" | jq -r '.url // ""' 2>/dev/null || true)
        ;;
      *)
        SUMMARY=""
        ;;
    esac
    
    # tool_use_id is present on BOTH PreToolUse and PostToolUse payloads
    # in Claude Code; the webapp uses it to pair the started/finished
    # rows so the pending row gets upgraded in place rather than rendered
    # twice. Empty when CC didn't supply it (older versions) — webapp
    # degrades to inserting a fresh row.
    TOOL_USE_ID=$(echo "$HOOK_INPUT" | jq -r '.tool_use_id // ""' 2>/dev/null || true)
    _LOG_TUID="$TOOL_USE_ID"
    
    TOOL_INPUT_JSON=$(echo "$HOOK_INPUT" | jq -c '.tool_input // null' 2>/dev/null || echo "null")
    
    # Sidechain identity: when this hook fires inside a subagent, Claude
    # Code sets is_sidechain=true and adds agent_id / agent_type top-level
    # fields. Same shape on Pre and Post.
    IS_SIDECHAIN=$(echo "$HOOK_INPUT" | jq -r '
  • hooks/agent-guard.shRunsGitHub
    Read the script
    #!/bin/bash
    
    # Dev Workflow Agent Guard (PreToolUse hook for Agent tool)
    # When a stagent is active and Claude launches an Agent, this hook
    # injects guidance about what subagent_type / mode / prompt contents to use,
    # driven by workflow.json.
    
    set -euo pipefail
    
    HOOK_INPUT=$(cat)
    
    HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
    source "$(dirname "$HOOK_DIR")/scripts/lib.sh"
    
    # Fallback-derive CLAUDE_PLUGIN_ROOT if Claude Code didn't set it for this
    # hook invocation. Real hook subprocesses always get it set; this is a
    # safety net so manual invocations don't trip `set -u`.
    : "${CLAUDE_PLUGIN_ROOT:=$(dirname "$HOOK_DIR")}"
    
    # Session-keyed: resolve THIS session's workflow dir (from HOOK_INPUT).
    # If there's no workflow for this session, nothing to advise.
    DESIRED_SESSION=$(echo "$HOOK_INPUT" | jq -r '.session_id // ""' 2>/dev/null || true)
    if ! resolve_state; then
      exit 0
    fi
    resolve_workflow_dir_from_state
    
    if ! config_check; then
      exit 0
    fi
    
    STATUS=$(_read_fm_field "$STATE_FILE" status)
    EPOCH=$(_read_fm_field "$STATE_FILE" epoch)
    
    # Terminal / paused: nothing to advise
    if is_terminal_status "$STATUS" || [[ "$STATUS" == "interrupted" ]]; then
      exit 0
    fi
    
    # Must be a known active stage
    if ! config_is_stage "$STATUS"; then
      exit 0
    fi
    
    # Deny duplicate launch: if a workflow subagent for this stage+epoch
    # is already in flight (recorded in .async-ledger/, subagent hasn't
    # stopped yet), refuse this Agent call. Without this, a stop-hook
    # false-positive or any other path that prods the main agent into
    # another launch would otherwise produce two concurrent subagents
    # writing to the same project.
    #
    # Filter: only deny when the launch target IS the stagent workflow
    # subagent. Inline fan-out stages legitimately emit N parallel
    # general-purpose subagents — those must pass through. Other Agent
    # calls (unrelated skills, user-driven Task delegations) likewise
    # must pass through even while our workflow has an in-flight
    # subagent.
    INCOMING_SUBAGENT_TYPE=$(echo "$HOOK_INPUT" | jq -r '.tool_input.subagent_type // ""' 2>/dev/null || true)
    if [[ "$INCOMING_SUBAGENT_TYPE" == "stagent:workflow-subagent" ]]; then
      LEDGER_DIR="${TOPIC_DIR}/.async-ledger"
      if [[ -d "$LEDGER_DIR" ]]; then
        EXISTING_AGENT=""
        for f in "$LEDGER_DIR"/*.json; do
          [[ -f "$f" ]] || continue
          MATCH=$(jq -r --arg s "$STATUS" --arg e "$EPOCH" '
            select(
              (.subagent_type == "stagent:workflow-subagent")
              and (.stage == $s)
              and ((.epoch | tostring) == $e)
            ) | .agent_id // ""
          ' "$f" 2>/dev/null)
          if [[ -n "$MATCH" ]]; then
            EXISTING_AGENT="$MATCH"
            break
          fi
        done
        if [[ -n "$EXISTING_AGENT" ]]; then
          jq -n \
            --arg reason "[stagent] Refusing to launch a second workflow subagent for stage '$STATUS' (epoch $EPOCH) — one is already in flight (agent_id: $EXISTING_AGENT). Wait for it to complete; do not stop, the completion notification will arrive. To abort, run /stagent:interrupt or /stagent:cancel." \
            '{
              "decision": "block",
              "reason": $reason
            }'
          exit 0
        fi
      fi
    fi
    
    ARTIFACT="$(config_artifact_path "$STATUS" "$RUN_DIR_NAME" "$PROJECT_ROOT")"
    EXEC_TYPE="$(config_execution_type "$STATUS")"
    TRANSITION_KEYS="$(config_transition_keys "$STATUS")"
    INSTRUCTIONS_PATH="$(config_stage_instructions_path "$STATUS")"
    
    if [[ "$EXEC_TYPE" == "inline" ]]; then
      # Inline stages run in the main agent's own turn. Two sub-cases:
      #
      #   a) Main agent is launching `stagent:workflow-subagent` —
      #      that's wrong for an inline stage (workflow-subagent is for
      #      subagent-type stages). Warn explicitly so the agent
      #      doesn't end up doing nothing.
      #
      #   b) Main agent is launching some other subagent_type
      #      (e.g. general-purpose for parallel fan-out, per the stage
      #      protocol). That's intentional — pass through silently so
      #      the main agent can emit N parallel calls in a single
      #      response without per-call hook chatter cluttering its
      #      context and biasing it toward sequential dispatch.
      if [[ "$INCOMING_SUBAGENT_TYPE" == "stagent:workflow-subagent" ]]; then
        cat <<EOF
    [stagent] Active workflow (phase: $STATUS, epoch: $EPOCH).
    This stage is INLINE — the main agent runs it directly.
    Do NOT launch \`stagent:workflow-subagent\` here. If you really meant to advance, transition out of $STATUS first via ${CLAUDE_PLUGIN_ROOT}/scripts/update-status.sh.
    
    Stage instructions: $INSTRUCTIONS_PATH
    Expected output: $ARTIFACT
      ---
      epoch: $EPOCH
      result: <one of: $TRANSITION_KEYS>
      ---
    EOF
      fi
      exit 0
    fi
    
    # Subagent stage. The subagent self-resolves its stage context via
    # subagent-bootstrap.sh (see workflow-subagent.md system prompt), so
    # the main agent only needs the canonical Agent-tool parameters.
    SUBAGENT_TYPE="stagent:workflow-subagent"
    MODEL="$(config_model "$STATUS")"
    
    cat <<EOF
    [stagent] Phase: $STATUS (epoch $EPOCH)
    
    Agent tool parameters:
      - subagent_type: "$SUBAGENT_TYPE"$( [[ -n "$MODEL" ]] && printf '\n  - model: %s' "$MODEL" )
      - mode: bypassPermissions
      - prompt: "Execute the current workflow stage."
    EOF
    exit 0
    
  • hooks/agent-ledger-add.shRunsGitHub
    Read the script
    #!/bin/bash
    #
    # agent-ledger-add.sh — PostToolUse:Agent hook (canonical async dispatch tracker).
    #
    # When the main agent's `Agent(...)` tool call returns asynchronously
    # (`tool_response.isAsync: true`), CC has launched the subagent in
    # the background and returned an agentId handle to the parent. This
    # hook records the dispatch under <run-dir>/.async-ledger/<agent_id>.json
    # with enough metadata that:
    #
    #   • stop-hook.sh can detect "subagents in flight" and refuse to
    #     block the turn-end (the parent must be allowed to truly sleep
    #     so CC's native auto-wake — subagent completion → tool_result
    #     injection — can revive it).
    #   • agent-guard.sh can dedup re-dispatch for the workflow-subagent
    #     path (one in-flight workflow-subagent per stage+epoch).
    #   • interrupt-workflow.sh / cancel-workflow.sh can collect
    #     agent_ids to send STAGENT_STOP_AGENT_IDS for graceful kill.
    #   • agent-ledger-remove.sh (SubagentStop) can match completion
    #     via transcript_path identity (robust to CC version changes
    #     in agent_id field naming) with agent_id fallback.
    #
    # Records ALL async Agent dispatches, regardless of subagent_type —
    # both stagent:workflow-subagent (subagent-type stages) and
    # general-purpose / others (inline fan-out stages) are tracked the
    # same way. Only the agent-guard dedup path discriminates by type.
    #
    # Best-effort: any failure path silently exits 0 to avoid
    # disrupting unrelated Claude Code activity.
    
    set -uo pipefail
    
    HOOK_INPUT=$(cat)
    
    # Only async dispatches need ledger tracking. Synchronous Agent
    # calls finish before PostToolUse returns — no waiting period to
    # track.
    IS_ASYNC=$(echo "$HOOK_INPUT" | jq -r '.tool_response.isAsync // false' 2>/dev/null)
    [[ "$IS_ASYNC" != "true" ]] && exit 0
    
    AGENT_ID=$(echo "$HOOK_INPUT" | jq -r '
      .tool_response.agentId
      // .tool_response.agent_id
      // .toolUseResult.agentId
      // empty
    ' 2>/dev/null)
    [[ -z "$AGENT_ID" ]] && exit 0
    
    OUTPUT_FILE=$(echo "$HOOK_INPUT" | jq -r '
      .tool_response.outputFile
      // .tool_response.output_file
      // .toolUseResult.outputFile
      // empty
    ' 2>/dev/null)
    
    SUBAGENT_TYPE=$(echo "$HOOK_INPUT" | jq -r '.tool_input.subagent_type // ""' 2>/dev/null)
    TOOL_USE_ID=$(echo "$HOOK_INPUT" | jq -r '.tool_use_id // ""' 2>/dev/null)
    DESIRED_SESSION=$(echo "$HOOK_INPUT" | jq -r '.session_id // ""' 2>/dev/null)
    
    HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
    PLUGIN_ROOT="$(dirname "$HOOK_DIR")"
    # shellcheck disable=SC1091
    source "${PLUGIN_ROOT}/scripts/lib.sh" 2>/dev/null || exit 0
    
    if ! resolve_state 2>/dev/null; then
      exit 0
    fi
    resolve_workflow_dir_from_state >/dev/null 2>&1 || true
    
    STATUS=$(_read_fm_field "$STATE_FILE" status 2>/dev/null)
    EPOCH=$(_read_fm_field "$STATE_FILE" epoch 2>/dev/null)
    
    LEDGER_DIR="$(dirname "$STATE_FILE")/.async-ledger"
    mkdir -p "$LEDGER_DIR" 2>/dev/null || exit 0
    
    LEDGER_FILE="${LEDGER_DIR}/${AGENT_ID}.json"
    
    jq -n \
      --arg agent_id          "$AGENT_ID" \
      --arg subagent_type     "$SUBAGENT_TYPE" \
      --arg stage             "$STATUS" \
      --arg epoch             "$EPOCH" \
      --arg session_id        "$DESIRED_SESSION" \
      --arg host              "$(hostname 2>/dev/null || echo unknown)" \
      --arg transcript_output "${OUTPUT_FILE:-}" \
      --arg started           "$(date -u +%FT%TZ)" \
      --arg tool_use_id       "$TOOL_USE_ID" \
      '{
        agent_id:          $agent_id,
        subagent_type:     $subagent_type,
        stage:             $stage,
        epoch:             ($epoch | tonumber? // $epoch),
        session_id:        $session_id,
        host:              $host,
        transcript_output: $transcript_output,
        started:           $started,
        tool_use_id:       $tool_use_id
      }' > "${LEDGER_FILE}.tmp" 2>/dev/null \
      && mv "${LEDGER_FILE}.tmp" "$LEDGER_FILE" 2>/dev/null \
      || rm -f "${LEDGER_FILE}.tmp" 2>/dev/null
    
    exit 0
    
  • hooks/agent-ledger-remove.shRunsGitHub
    Read the script
    #!/bin/bash
    #
    # agent-ledger-remove.sh — SubagentStop hook (canonical completion tracker).
    #
    # Pairs with agent-ledger-add.sh. SubagentStop fires when an async
    # subagent terminates (success, failure, or cancellation). This hook
    # removes the matching ledger entry so stop-hook.sh's "subagents in
    # flight" count drops accordingly.
    #
    # CC fires SubagentStop for ALL subagents in this CC instance,
    # including ones unrelated to stagent — so a no-match here is normal
    # and silent. Removing nothing on a bystander event is the correct
    # behaviour.
    #
    # Match strategy (any ONE wins):
    #   1. transcript_path identity (PRIMARY) — readlink -f the ledger
    #      record's transcript_output AND the hook's transcript_path,
    #      compare canonical paths. transcript_path is a documented
    #      top-level hook input field, so this match survives CC version
    #      changes that have historically renamed agent_id sub-fields.
    #   2. agent_id identity (FALLBACK) — match against any of the
    #      .agent_id / .agentId / .subagent_id / .id field names.
    #
    # Best-effort: any failure path silently exits 0.
    
    set -uo pipefail
    
    HOOK_INPUT=$(cat)
    
    HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
    PLUGIN_ROOT="$(dirname "$HOOK_DIR")"
    # shellcheck disable=SC1091
    source "${PLUGIN_ROOT}/scripts/lib.sh" 2>/dev/null || exit 0
    
    DESIRED_SESSION=$(echo "$HOOK_INPUT" | jq -r '.session_id // ""' 2>/dev/null)
    if ! resolve_state 2>/dev/null; then
      exit 0
    fi
    resolve_workflow_dir_from_state >/dev/null 2>&1 || true
    
    LEDGER_DIR="$(dirname "$STATE_FILE")/.async-ledger"
    # Note: we no longer early-exit on a missing ledger dir. Sync subagents
    # never register an entry (agent-ledger-add.sh's isAsync gate skips
    # them), so the dir might not exist at all — but SubagentStop still
    # fires for them with `agent_id` in the hook input, and the fallback
    # POST below needs to run regardless of ledger state to flip the
    # webapp's badge from running → done.
    
    HOOK_TRANSCRIPT=$(echo "$HOOK_INPUT" | jq -r '.transcript_path // ""' 2>/dev/null)
    HOOK_AGENT_ID=$(echo "$HOOK_INPUT" | jq -r '
      .agent_id // .agentId // .subagent_id // .id // ""
    ' 2>/dev/null)
    
    HOOK_TRANSCRIPT_REAL=""
    if [[ -n "$HOOK_TRANSCRIPT" ]] && [[ "$HOOK_TRANSCRIPT" != "null" ]]; then
      HOOK_TRANSCRIPT_REAL=$(readlink -f "$HOOK_TRANSCRIPT" 2>/dev/null || true)
    fi
    
    if [[ -z "$HOOK_TRANSCRIPT_REAL" ]] && { [[ -z "$HOOK_AGENT_ID" ]] || [[ "$HOOK_AGENT_ID" == "null" ]]; }; then
      exit 0
    fi
    
    # Resolve cloud session id from state.md frontmatter (takeover-safe;
    # differs from the local CC session id during cross-machine resume).
    CLOUD_SID=$(_read_fm_field "$STATE_FILE" session_id 2>/dev/null)
    [[ -z "$CLOUD_SID" ]] && CLOUD_SID="$DESIRED_SESSION"
    
    # Helper: given a confirmed-match ledger file, read its metadata,
    # fire the cloud "subagent_stopped" event, then delete the file.
    # We capture metadata BEFORE rm so the cloud notification has the
    # stage / epoch / agent_type / started fields the webapp needs.
    _finalize_match() {
      local f="$1"
      local rec_agent_id rec_agent_type rec_stage rec_epoch rec_started
      rec_agent_id=$(jq -r '.agent_id // ""' "$f" 2>/dev/null)
      rec_agent_type=$(jq -r '.subagent_type // ""' "$f" 2>/dev/null)
      rec_stage=$(jq -r '.stage // ""' "$f" 2>/dev/null)
      rec_epoch=$(jq -r '.epoch // 0' "$f" 2>/dev/null)
      rec_started=$(jq -r '.started // ""' "$f" 2>/dev/null)
      rm -f "$f"
      if is_cloud_session "$RUN_DIR_NAME" 2>/dev/null; then
        cloud_post_subagent_stopped \
          "$CLOUD_SID" "$rec_stage" "$rec_epoch" \
          "$rec_agent_id" "$rec_agent_type" "$rec_started" \
          >/dev/null 2>&1 || true
      fi
      rmdir "$LEDGER_DIR" 2>/dev/null || true
    }
    
    # Ledger scan only runs when the dir exists. Sync subagents skip
    # this whole block and fall through to the fallback POST below.
    if [[ -d "$LEDGER_DIR" ]]; then
      # Fast path: ledger files are keyed by agent_id, so if we got an
      # agent_id from the hook we can attempt a direct file lookup. Only
      # finalize after also confirming transcript_path identity when both
      # signals are present (defense against stale entries with reused
      # ids). When only one signal is present, that one decides.
      if [[ -n "$HOOK_AGENT_ID" ]] && [[ "$HOOK_AGENT_ID" != "null" ]]; then
        CANDIDATE="${LEDGER_DIR}/${HOOK_AGENT_ID}.json"
        if [[ -f "$CANDIDATE" ]]; then
          if [[ -n "$HOOK_TRANSCRIPT_REAL" ]]; then
            REC_OUTPUT=$(jq -r '.transcript_output // ""' "$CANDIDATE" 2>/dev/null)
            REC_REAL=""
            if [[ -n "$REC_OUTPUT" ]] && [[ "$REC_OUTPUT" != "null" ]]; then
              REC_REAL=$(readlink -f "$REC_OUTPUT" 2>/dev/null || true)
            fi
            if [[ -n "$REC_REAL" ]] && [[ "$REC_REAL" != "$HOOK_TRANSCRIPT_REAL" ]]; then
              # ID matches but transcript differs — bystander collision,
              # do nothing. Fall through to slow-path scan in case the real
              # match lives elsewhere.
              :
            else
              _finalize_match "$CANDIDATE"
              exit 0
            fi
          else
            _finalize_match "$CANDIDATE"
            exit 0
          fi
        fi
      fi
    
      # Slow path: scan all entries and match by transcript_path identity
      # (the agent_id-keyed file may not exist if CC's id field name
      # changed and the hook recorded a different shape).
      for f in "$LEDGER_DIR"/*.json; do
        [[ -f "$f" ]] || continue
    
        match=0
    
        if [[ -n "$HOOK_TRANSCRIPT_REAL" ]]; then
          REC_OUTPUT=$(jq -r '.transcript_output // ""' "$f" 2>/dev/null)
          if [[ -n "$REC_OUTPUT" ]] && [[ "$REC_OUTPUT" != "null" ]]; then
            REC_REAL=$(readlink -f "$REC_OUTPUT" 2>/dev/null || true)
            if [[ -n "$REC_REAL" ]] && [[ "$REC_REAL" == "$HOOK_TRANSCRIPT_REAL" ]]; then
              match=1
            fi
          fi
        fi
    
        if [[ "$match" -eq 0 ]] && [[ -n "$HOOK_AGENT_ID" ]] && [[ "$HOOK_AGENT_ID" != "null" ]]; then
          REC_ID=$(jq -r '.agent_id // ""' "$f" 2>/dev/null)
          if [[ -n "$REC_ID" ]] && [[ "$REC_ID" == "$HOOK_AGENT_ID" ]]; then
            match=1
          fi
        fi
    
        if [[ "$match" -eq 1 ]]; then
          _finalize_match "$f"
          exit 0
       
  • hooks/ask-user-question-hook.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    #
    # AskUserQuestion hook — flip `awaiting_user` while the agent is
    # blocked on the AskUserQuestion picker.
    #
    # Why this hook exists
    # --------------------
    # stop-hook.sh sets awaiting_user=true only when an interruptible
    # stage's Stop event fires (the agent ended its turn without producing
    # a done artifact). But when the agent invokes the `AskUserQuestion`
    # tool — Claude Code's built-in multiple-choice picker — the agent is
    # in the middle of a tool call from CC's perspective:
    #
    #   • The Stop event does NOT fire (the turn isn't done).
    #   • UserPromptSubmit does NOT fire when the user picks an answer
    #     (the answer comes back as a tool_result, not a prompt).
    #
    # Result without this hook: the workflow is genuinely waiting on the
    # user (the picker is on screen, the agent is paused), but the webapp
    # never sees `awaiting_user=true`, so the "waiting for you" banner /
    # pill never lights up.
    #
    # This hook fills that gap with two events on the same script:
    #   • PreToolUse:AskUserQuestion  → set awaiting_user=true,
    #                                   also piggy-back cloud_reconcile_state
    #                                   so any pending <stage>-report.md
    #                                   that just landed locally is pushed
    #                                   to cloud before the user clicks
    #                                   the cloud link from the picker.
    #   • PostToolUse:AskUserQuestion → set awaiting_user=false
    # (UserPromptSubmit clears the flag too, so a stray "true" can't
    # strand the UI even if PostToolUse is missed.)
    #
    # Silent + best-effort: any failure exits 0 so we never disturb the
    # tool call itself.
    
    set -uo pipefail
    
    HOOK_INPUT=$(cat)
    
    HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
    : "${CLAUDE_PLUGIN_ROOT:=$(dirname "$HOOK_DIR")}"
    
    # shellcheck source=../scripts/lib.sh
    source "${CLAUDE_PLUGIN_ROOT}/scripts/lib.sh" 2>/dev/null || exit 0
    
    # Hook event tells us which direction to flip the flag. CC writes this
    # as `hook_event_name` in the JSON payload for every hook invocation.
    EVENT=$(echo "$HOOK_INPUT" | jq -r '.hook_event_name // ""' 2>/dev/null)
    
    case "$EVENT" in
      PreToolUse)  TARGET=true ;;
      PostToolUse) TARGET=false ;;
      *)           exit 0 ;;
    esac
    
    # Defensive: only fire on AskUserQuestion. The hooks.json matcher
    # already filters by tool name, but this guard makes the script safe
    # to invoke from anywhere (and survives matcher refactors).
    TOOL=$(echo "$HOOK_INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
    [[ "$TOOL" == "AskUserQuestion" ]] || exit 0
    
    DESIRED_SESSION=$(echo "$HOOK_INPUT" | jq -r '.session_id // ""' 2>/dev/null || true)
    
    if ! resolve_state 2>/dev/null; then
      exit 0
    fi
    
    [[ -f "${STATE_FILE:-}" ]] || exit 0
    
    # Skip churny writes: only POST when the flag would actually change.
    CURRENT="$(get_awaiting_user "$STATE_FILE")"
    if [[ "$CURRENT" == "$TARGET" ]]; then
      exit 0
    fi
    
    set_awaiting_user "$STATE_FILE" "$TARGET"
    # Pair the boolean with the reason tag so the webapp can show "agent
    # is waiting on a picker answer" instead of the generic copy. When
    # clearing (PostToolUse), pass "" so the reason is wiped too.
    if [[ "$TARGET" == "true" ]]; then
      set_awaiting_reason "$STATE_FILE" picker
    else
      set_awaiting_reason "$STATE_FILE" ""
    fi
    if is_cloud_session "$RUN_DIR_NAME" 2>/dev/null; then
      cloud_post_awaiting_user "$RUN_DIR_NAME" "$TARGET" \
        "$([[ "$TARGET" == "true" ]] && echo picker || echo "")" \
        >/dev/null 2>&1 || true
    
      # On PreToolUse only: piggy-back the artifact-reconcile that
      # stop-hook.sh runs at every turn-end (lib.sh: cloud_reconcile_state).
      # AskUserQuestion is an in-flight tool, so Stop never fires while the
      # picker is up — without this, a `<stage>-report.md` written just
      # before the picker would stay local-only until the next genuine turn
      # end, and the user clicking the cloud link from the picker prompt
      # would see no plan. PostToolUse skips this on purpose: by then the
      # picker is gone, the agent's about to do more work, and the next
      # Stop / next AskUserQuestion will catch up.
      if [[ "$EVENT" == "PreToolUse" ]]; then
        cloud_reconcile_state "$RUN_DIR_NAME" >/dev/null 2>&1 || true
      fi
    fi
    
    exit 0
    
  • hooks/notification-hook.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    #
    # Notification hook — capture CC-framework-initiated pauses.
    #
    # Why this hook exists
    # --------------------
    # stop-hook.sh and ask-user-question-hook.sh together cover the cases
    # where the AGENT itself decides to pause (typed a question and ended
    # its turn, or invoked the AskUserQuestion picker). Neither covers the
    # cases where Claude Code's framework pauses the agent without the
    # agent asking:
    #
    #   • Permission prompt — agent tried to call a tool that needs
    #     confirmation (e.g. an unwhitelisted Bash command). CC blocks the
    #     tool call and waits for the user to click Allow/Deny. From the
    #     agent's perspective it's mid-tool-call; Stop never fires.
    #   • Idle notification — CC has been waiting on the user's input long
    #     enough that it sends a native OS notification. Already covered
    #     by stop-hook in most cases (since Stop fires before idle), but
    #     it's a useful fallback for sessions where Stop didn't fire (e.g.
    #     subagent in-flight when the user walked away).
    #
    # Both surface as the `Notification` hook event, distinguished by the
    # `message` field in CC's payload. We map them to awaiting_reason so
    # the webapp banner can show *why* we're paused, not just *that* we
    # are. The boolean awaiting_user is set to true on either; clearing
    # happens via UserPromptSubmit (existing hook) or update-status.sh.
    #
    # Silent + best-effort: any failure exits 0 so we never disturb CC.
    
    set -uo pipefail
    
    HOOK_INPUT=$(cat)
    
    HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
    : "${CLAUDE_PLUGIN_ROOT:=$(dirname "$HOOK_DIR")}"
    
    # shellcheck source=../scripts/lib.sh
    source "${CLAUDE_PLUGIN_ROOT}/scripts/lib.sh" 2>/dev/null || exit 0
    
    if ! resolve_state 2>/dev/null; then
      exit 0
    fi
    [[ -f "${STATE_FILE:-}" ]] || exit 0
    
    # Extract the notification message. CC payload shape per
    # https://code.claude.com/docs/en/hooks.md#notification-input :
    #   { "session_id": "...", "transcript_path": "...",
    #     "hook_event_name": "Notification",
    #     "message": "Claude needs your permission to use Bash" }
    MESSAGE=$(echo "$HOOK_INPUT" | jq -r '.message // ""' 2>/dev/null || true)
    
    # Map the message to a stable reason tag the webapp can switch on.
    # We deliberately don't try to be clever about parsing the tool name
    # out of the message — the message text is CC's UX copy and may
    # change between CC versions. The tag is the contract.
    REASON=""
    case "$MESSAGE" in
      *"permission"*|*"Permission"*|*"allow"*|*"Allow"*)
        REASON=permission
        ;;
      *"waiting"*|*"idle"*|*"Idle"*)
        REASON=idle
        ;;
      *)
        # Unknown message shape — still surface the pause, just leave the
        # reason blank rather than guessing. The webapp falls back to the
        # generic "waiting for you" copy in that case.
        REASON=""
        ;;
    esac
    
    # Skip churny writes — only post when state actually changes.
    CURRENT="$(get_awaiting_user "$STATE_FILE")"
    CURRENT_REASON="$(get_awaiting_reason "$STATE_FILE")"
    if [[ "$CURRENT" == "true" && "$CURRENT_REASON" == "$REASON" ]]; then
      exit 0
    fi
    
    set_awaiting_user    "$STATE_FILE" true
    set_awaiting_reason  "$STATE_FILE" "$REASON"
    if is_cloud_session "$RUN_DIR_NAME" 2>/dev/null; then
      cloud_post_awaiting_user "$RUN_DIR_NAME" true "$REASON" >/dev/null 2>&1 || true
    fi
    
    exit 0
    
  • hooks/plugin-path-rewrite.shRunsGitHub
  • hooks/postwrite-hook.shRunsGitHub
  • hooks/session-end.shRunsGitHub
  • hooks/session-start.shRunsGitHub
  • hooks/stop-hook.shRunsGitHub
  • hooks/user-prompt-submit.shRunsGitHub

All 12 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 withstagent

A Claude Code plugin that runs config-driven development workflows as a state machine. You declare stages, transitions, and inputs in a single workflow.json; the plugin's hooks and scripts drive the loop.

Get the whole plugin
Stats
25
Stars
1
Forks
Maintained
Maintenance
Shell
Language
4mo ago
Last commit
5mo ago
Created

Repo: jie-worldstatelabs/stagent