Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 363 additions & 0 deletions hooks/lib/loop-bg-tasks.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
#!/usr/bin/env bash
#
# Background-task helpers for the RLCR stop hook.
#
# Owns all logic that inspects the Claude Code transcript to decide
# whether the hook should short-circuit (the main session is still
# waiting on an asynchronous Agent/Bash dispatch), plus the four guard
# blocks that the stop hook runs before its normal gate logic:
#
# 1. Ambiguous-caller marker guard
# 2. Cross-session parked-loop guard
# 3. Early exit: pending background tasks
# 4. Same-session stale-marker cleanup
#
# Depends on loop-common.sh (FIELD_SESSION_ID, resolve_active_state_file)
# being sourced first.
#

# Source guard.
[[ -n "${_LOOP_BG_TASKS_LOADED:-}" ]] && return 0 2>/dev/null || true
_LOOP_BG_TASKS_LOADED=1

# Expand a leading "~" or "~/" in a path to "$HOME" without using eval.
# Only the bare "~" and "~/..." forms are expanded; "~user/..." and every
# other input (absolute path, relative path, empty string) is returned verbatim.
#
# Usage: expand_leading_tilde "$path"
# Prints the normalized path to stdout.
expand_leading_tilde() {
local path="$1"
case "$path" in
'~') printf '%s' "${HOME:-}" ;;
'~/'*) printf '%s/%s' "${HOME:-}" "${path#'~/'}" ;;
*) printf '%s' "$path" ;;
esac
}

# Extract transcript_path from hook JSON input and expand any leading tilde.
# Usage: extract_transcript_path "$json_input"
# Outputs the transcript_path to stdout, or empty string if not available.
extract_transcript_path() {
local input="$1"
local raw
raw=$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null || echo "")
expand_leading_tilde "$raw"
}

# Convert an RLCR loop dir basename to a lexically-comparable ISO-8601
# UTC timestamp suitable for filtering transcript events.
#
# `setup-rlcr-loop.sh` creates loop dirs named `YYYY-MM-DD_HH-MM-SS` in
# the system's LOCAL wall clock (it calls `date +%Y-%m-%d_%H-%M-%S`
# without `-u`). Claude transcript events carry actual UTC timestamps
# like `2026-04-16T13:19:26.819Z`. To compare them correctly, this
# helper converts the local wall-clock parse back to a real UTC moment
# via a two-step: parse local -> epoch seconds -> format in UTC.
#
# The `.000Z` suffix keeps sub-second transcript timestamps in the same
# second compared greater via lexical string ordering.
#
# Usage: derive_loop_start_iso_ts "$loop_dir"
# Prints the ISO-8601 UTC timestamp, or empty string when the
# basename does not match the expected format or the local `date`
# binary cannot parse it.
derive_loop_start_iso_ts() {
local loop_dir="$1"
local base
base=$(basename "$loop_dir" 2>/dev/null || echo "")
if [[ ! "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})$ ]]; then
return
fi
local local_datetime
local_datetime="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}:${BASH_REMATCH[3]}:${BASH_REMATCH[4]}"

# Local wall-clock -> epoch seconds. GNU `date -d` first,
# BSD/macOS `date -j -f ...` second. Both honour the caller's TZ
# for interpretation, matching setup-rlcr-loop.sh's behaviour at
# loop-dir creation time.
local epoch
epoch=$(date -d "$local_datetime" +%s 2>/dev/null) || epoch=""
if [[ -z "$epoch" ]]; then
epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$local_datetime" +%s 2>/dev/null) || epoch=""
fi
if [[ -z "$epoch" ]]; then
return
fi

# Epoch -> UTC ISO-8601. Try GNU then BSD.
local utc_iso
utc_iso=$(date -u -d "@$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
if [[ -z "$utc_iso" ]]; then
utc_iso=$(date -u -r "$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
fi
printf '%s' "$utc_iso"
}

# Enumerate background-task ids that have been launched but not yet marked
# completed in a Claude Code transcript.jsonl.
#
# Launch events (inspected in tool_result "user" messages):
# - Background subagent: toolUseResult.isAsync == true
# -> id is toolUseResult.agentId
# - Background shell: toolUseResult.backgroundTaskId non-empty
# -> id is toolUseResult.backgroundTaskId
#
# Completion events are recognised from two Claude Code transcript forms:
#
# 1. Structured SDK record
# (see SDKTaskNotificationMessage in docs/typescript.md):
# `type == "system"`, `subtype == "task_notification"`,
# `task_id` is the completed id. Any `status` value
# (completed, failed, stopped, ...) is treated as terminal.
#
# 2. Legacy queue-operation enqueue whose `content` embeds a
# `<task-notification>` XML block with `<task-id>...</task-id>`;
# kept for transcripts produced by older Claude Code versions.
#
# pending := launched \ completed
#
# Optional second argument `since_ts` (ISO-8601 string, e.g. the value
# returned by `derive_loop_start_iso_ts`): when provided, only launch
# events whose top-level `.timestamp` field is >= `since_ts` count as
# candidate launches. Events without a `.timestamp` are included (keeps
# fixture transcripts and older record formats working). This keeps
# pre-loop session-wide background work from pinning an RLCR loop that
# has no pending work of its own.
#
# Usage: list_pending_background_task_ids "$transcript_path" [since_ts]
# - Outputs one id per line on stdout (possibly empty).
# - Returns 0 when the transcript is readable (including when there are
# no pending tasks). Returns 1 when the transcript path is empty, not
# a regular file, or jq is unavailable, so callers must treat non-zero
# as "unknown -> do not short-circuit".
list_pending_background_task_ids() {
local transcript_path="$1"
local since_ts="${2:-}"

# Normalize a leading tilde so direct callers (tests, ad-hoc scripts)
# work correctly even when transcript_path was not routed through
# extract_transcript_path.
transcript_path=$(expand_leading_tilde "$transcript_path")

if [[ -z "$transcript_path" ]] || [[ ! -f "$transcript_path" ]]; then
return 1
fi
if ! command -v jq >/dev/null 2>&1; then
return 1
fi

local launched completed
launched=$(jq -r --arg since_ts "$since_ts" '
select(.toolUseResult != null)
| select(
($since_ts == ""
or ((.timestamp // "") == "")
or ((.timestamp // "") >= $since_ts))
)
| select(
(.toolUseResult.isAsync == true and (.toolUseResult.agentId // "") != "")
or ((.toolUseResult.backgroundTaskId // "") != "")
)
| (.toolUseResult.agentId // .toolUseResult.backgroundTaskId)
' "$transcript_path" 2>/dev/null | sort -u) || return 1

# Union of both completion formats. Either source alone is enough to
# mark a launched id terminal.
#
# The `grep -oE || true` guard on the legacy branch keeps `set -o
# pipefail` from poisoning the combined pipeline when no legacy
# queue-operation records exist in the transcript (grep with `-o`
# exits 1 on no matches, which would otherwise wipe out any SDK
# task_notification results collected above).
completed=$(
{
jq -r '
select(.type == "system" and .subtype == "task_notification")
| (.task_id // empty)
' "$transcript_path" 2>/dev/null
jq -r '
select(.type == "queue-operation" and .operation == "enqueue")
| (.content // "" | tostring)
| select(contains("<task-notification>"))
' "$transcript_path" 2>/dev/null \
| { grep -oE '<task-id>[^<]+</task-id>' || true; } \
| sed -E 's|</?task-id>||g'
} | sort -u | sed '/^$/d'
) || completed=""

# Emit launched ids that have no matching completion notification.
comm -23 \
<(printf '%s\n' "$launched" | sed '/^$/d') \
<(printf '%s\n' "$completed" | sed '/^$/d')
}

# Returns 0 when the transcript shows at least one pending background task.
# Returns 1 when no pending tasks are detected (including fail-closed cases
# like missing transcript, non-file path, or jq unavailable).
#
# Usage: has_pending_background_tasks "$transcript_path" [since_ts]
has_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || return 1
[[ -n "$pending" ]]
}

# Prints the count of pending background tasks to stdout. Prints 0 for any
# error case so callers can still format messages safely.
#
# Usage: count_pending_background_tasks "$transcript_path" [since_ts]
count_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || {
echo 0
return 0
}
if [[ -z "$pending" ]]; then
echo 0
else
printf '%s\n' "$pending" | sed '/^$/d' | wc -l | tr -d ' '
fi
}

# Single entry point for the stop hook: runs the four guard blocks
# (ambiguous-caller, cross-session parked, pending-bg short-circuit,
# same-session stale-marker cleanup) in order. When a guard decides to
# short-circuit the stop hook, it emits the appropriate JSON on stdout
# and `exit 0`s directly; the caller (sourcing the hook script) never
# returns. When no guard fires, this function returns 0 and the stop
# hook continues into its normal gate logic.
#
# Depends on FIELD_SESSION_ID and resolve_active_state_file from
# loop-common.sh.
#
# Usage: handle_bg_task_short_circuit "$LOOP_DIR" "$HOOK_INPUT" "$HOOK_SESSION_ID"
handle_bg_task_short_circuit() {
local loop_dir="$1" hook_input="$2" hook_session_id="$3"

# Shared state used by the guard blocks below.
# Loop-start boundary: derived from the loop dir basename
# (`YYYY-MM-DD_HH-MM-SS`). Empty means derivation failed; helpers
# treat empty since_ts as no boundary.
local loop_start_ts transcript_path
loop_start_ts=$(derive_loop_start_iso_ts "$loop_dir")
transcript_path=$(extract_transcript_path "$hook_input")

# ----------------------------------------
# Ambiguous-Caller Marker Guard
# ----------------------------------------
# If a bg-pending.marker is present but we have no session_id on
# this hook invocation (typical of scripts/rlcr-stop-gate.sh
# invoked without --session-id, or any other caller that doesn't
# forward session_id), we cannot tell whether this caller owns the
# parked loop. Taking either branch (foreign-session guard below,
# or same-session cleanup further down) would be wrong in one of
# the two possible realities. Exit 0 silently: the real Claude
# hook will arrive with session_id populated and drive parking /
# cleanup from an authoritative context.
if [[ -f "$loop_dir/bg-pending.marker" ]] && [[ -z "$hook_session_id" ]]; then
exit 0
fi

# ----------------------------------------
# Cross-Session Parked-Loop Guard
# ----------------------------------------
# If find_active_loop handed this dir over via the marker fallback,
# the loop is parked by a different session waiting on a background
# task. The current session has no authority to inspect or advance
# that loop - its transcript sees none of the foreign bg activity -
# so the only safe response is to exit 0 with a distinct
# systemMessage and leave every on-disk artifact (state file,
# stored session_id, marker) untouched.
#
# Both sides of the session-id comparison must be non-empty for
# this branch to trigger: an empty hook_session_id has already
# exited above via the ambiguous-caller guard, and an empty stored
# session_id keeps the backward-compat "matches any" semantics
# from find_active_loop.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local guard_state_file guard_stored_sid
guard_state_file=$(resolve_active_state_file "$loop_dir")
if [[ -n "$guard_state_file" ]]; then
guard_stored_sid=$(sed -n '/^---$/,/^---$/{ /^'"${FIELD_SESSION_ID}"':/{ s/^'"${FIELD_SESSION_ID}"': *//; p; } }' "$guard_state_file" 2>/dev/null | tr -d ' ')
if [[ -n "$guard_stored_sid" ]] \
&& [[ -n "$hook_session_id" ]] \
&& [[ "$guard_stored_sid" != "$hook_session_id" ]]; then
jq -n \
'{systemMessage: "RLCR loop in this repo is parked by another Claude session waiting for background work. Stop allowed; your session leaves the loop untouched. If that session ended, run /humanize:cancel-rlcr-loop to clean up."}'
exit 0
fi
fi
fi

# ----------------------------------------
# Early Exit: Pending Background Tasks
# ----------------------------------------
# When the main Claude Code session has dispatched background work
# (Agent with run_in_background=true, or Bash with
# run_in_background=true) whose completion notifications have not
# yet arrived, the natural "stop" is simply "I am waiting for the
# background task". Running git/summary/BitLesson/Codex gates in
# that state wastes Codex tokens and produces low-signal reviews.
#
# Allow the stop (exit 0) and emit a user-visible systemMessage so
# nobody mistakes the pause for loop completion. The on-disk loop
# state is left untouched -- the next natural stop (after
# background work finishes) will re-enter this hook with no
# pending tasks and run the normal flow.
#
# loop_start_ts confines the transcript scan to launches that
# actually happened during this loop; earlier session-wide bg
# activity cannot pin the loop.
#
# This check MUST run before any other gate (phase detection,
# state parsing, branch / plan / git-clean / summary / max-iter
# checks, Codex review).
local pending_bg_ids
pending_bg_ids=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) || true
if [[ -n "$pending_bg_ids" ]]; then
local pending_bg_count
pending_bg_count=$(printf '%s\n' "$pending_bg_ids" | sed '/^$/d' | wc -l | tr -d ' ')
# Mark the loop as parked; allows the same session to resume
# later and makes the cross-session guard above reachable if
# the user opens a different Claude session in this repo
# before the bg task completes.
: > "$loop_dir/bg-pending.marker" 2>/dev/null || true
jq -n --arg count "$pending_bg_count" \
'{systemMessage: ("RLCR loop active. " + $count + " background task(s) still running - stop allowed naturally; loop has NOT terminated and will resume on completion.")}'
exit 0
fi

# ----------------------------------------
# Same-Session Stale-Marker Cleanup
# ----------------------------------------
# The cross-session guard above already exited for every foreign
# session, so reaching here with the marker present means the
# CURRENT session parked the loop and has now come back with a
# transcript showing no pending bg events. Remove the stale marker
# before the normal flow takes over.
#
# Two-part guard to make sure we never drop the parked-state
# signal without evidence:
# (a) list_pending_background_task_ids returned exit 0 -- the
# transcript was present, readable, AND parsed successfully.
# The helper is fail-closed on missing files, empty paths,
# jq parse failure, and truncation, so a non-zero exit
# blocks cleanup here even when the transcript "file"
# exists.
# (b) its output is empty -- proves "no pending" was
# authoritatively verified, not inferred from a failure.
# The check uses a single fresh call so we capture both the exit
# code and the emptiness without double-running jq.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local pending_bg_check
if pending_bg_check=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) \
&& [[ -z "$pending_bg_check" ]]; then
rm -f "$loop_dir/bg-pending.marker" 2>/dev/null || true
fi
fi
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Allow natural stop while background tasks are in flight by SihaoLiu · Pull Request #91 · PolyArch/humanize · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 363 additions & 0 deletions hooks/lib/loop-bg-tasks.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
#!/usr/bin/env bash
#
# Background-task helpers for the RLCR stop hook.
#
# Owns all logic that inspects the Claude Code transcript to decide
# whether the hook should short-circuit (the main session is still
# waiting on an asynchronous Agent/Bash dispatch), plus the four guard
# blocks that the stop hook runs before its normal gate logic:
#
# 1. Ambiguous-caller marker guard
# 2. Cross-session parked-loop guard
# 3. Early exit: pending background tasks
# 4. Same-session stale-marker cleanup
#
# Depends on loop-common.sh (FIELD_SESSION_ID, resolve_active_state_file)
# being sourced first.
#

# Source guard.
[[ -n "${_LOOP_BG_TASKS_LOADED:-}" ]] && return 0 2>/dev/null || true
_LOOP_BG_TASKS_LOADED=1

# Expand a leading "~" or "~/" in a path to "$HOME" without using eval.
# Only the bare "~" and "~/..." forms are expanded; "~user/..." and every
# other input (absolute path, relative path, empty string) is returned verbatim.
#
# Usage: expand_leading_tilde "$path"
# Prints the normalized path to stdout.
expand_leading_tilde() {
local path="$1"
case "$path" in
'~') printf '%s' "${HOME:-}" ;;
'~/'*) printf '%s/%s' "${HOME:-}" "${path#'~/'}" ;;
*) printf '%s' "$path" ;;
esac
}

# Extract transcript_path from hook JSON input and expand any leading tilde.
# Usage: extract_transcript_path "$json_input"
# Outputs the transcript_path to stdout, or empty string if not available.
extract_transcript_path() {
local input="$1"
local raw
raw=$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null || echo "")
expand_leading_tilde "$raw"
}

# Convert an RLCR loop dir basename to a lexically-comparable ISO-8601
# UTC timestamp suitable for filtering transcript events.
#
# `setup-rlcr-loop.sh` creates loop dirs named `YYYY-MM-DD_HH-MM-SS` in
# the system's LOCAL wall clock (it calls `date +%Y-%m-%d_%H-%M-%S`
# without `-u`). Claude transcript events carry actual UTC timestamps
# like `2026-04-16T13:19:26.819Z`. To compare them correctly, this
# helper converts the local wall-clock parse back to a real UTC moment
# via a two-step: parse local -> epoch seconds -> format in UTC.
#
# The `.000Z` suffix keeps sub-second transcript timestamps in the same
# second compared greater via lexical string ordering.
#
# Usage: derive_loop_start_iso_ts "$loop_dir"
# Prints the ISO-8601 UTC timestamp, or empty string when the
# basename does not match the expected format or the local `date`
# binary cannot parse it.
derive_loop_start_iso_ts() {
local loop_dir="$1"
local base
base=$(basename "$loop_dir" 2>/dev/null || echo "")
if [[ ! "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})$ ]]; then
return
fi
local local_datetime
local_datetime="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}:${BASH_REMATCH[3]}:${BASH_REMATCH[4]}"

# Local wall-clock -> epoch seconds. GNU `date -d` first,
# BSD/macOS `date -j -f ...` second. Both honour the caller's TZ
# for interpretation, matching setup-rlcr-loop.sh's behaviour at
# loop-dir creation time.
local epoch
epoch=$(date -d "$local_datetime" +%s 2>/dev/null) || epoch=""
if [[ -z "$epoch" ]]; then
epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$local_datetime" +%s 2>/dev/null) || epoch=""
fi
if [[ -z "$epoch" ]]; then
return
fi

# Epoch -> UTC ISO-8601. Try GNU then BSD.
local utc_iso
utc_iso=$(date -u -d "@$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
if [[ -z "$utc_iso" ]]; then
utc_iso=$(date -u -r "$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
fi
printf '%s' "$utc_iso"
}

# Enumerate background-task ids that have been launched but not yet marked
# completed in a Claude Code transcript.jsonl.
#
# Launch events (inspected in tool_result "user" messages):
# - Background subagent: toolUseResult.isAsync == true
# -> id is toolUseResult.agentId
# - Background shell: toolUseResult.backgroundTaskId non-empty
# -> id is toolUseResult.backgroundTaskId
#
# Completion events are recognised from two Claude Code transcript forms:
#
# 1. Structured SDK record
# (see SDKTaskNotificationMessage in docs/typescript.md):
# `type == "system"`, `subtype == "task_notification"`,
# `task_id` is the completed id. Any `status` value
# (completed, failed, stopped, ...) is treated as terminal.
#
# 2. Legacy queue-operation enqueue whose `content` embeds a
# `<task-notification>` XML block with `<task-id>...</task-id>`;
# kept for transcripts produced by older Claude Code versions.
#
# pending := launched \ completed
#
# Optional second argument `since_ts` (ISO-8601 string, e.g. the value
# returned by `derive_loop_start_iso_ts`): when provided, only launch
# events whose top-level `.timestamp` field is >= `since_ts` count as
# candidate launches. Events without a `.timestamp` are included (keeps
# fixture transcripts and older record formats working). This keeps
# pre-loop session-wide background work from pinning an RLCR loop that
# has no pending work of its own.
#
# Usage: list_pending_background_task_ids "$transcript_path" [since_ts]
# - Outputs one id per line on stdout (possibly empty).
# - Returns 0 when the transcript is readable (including when there are
# no pending tasks). Returns 1 when the transcript path is empty, not
# a regular file, or jq is unavailable, so callers must treat non-zero
# as "unknown -> do not short-circuit".
list_pending_background_task_ids() {
local transcript_path="$1"
local since_ts="${2:-}"

# Normalize a leading tilde so direct callers (tests, ad-hoc scripts)
# work correctly even when transcript_path was not routed through
# extract_transcript_path.
transcript_path=$(expand_leading_tilde "$transcript_path")

if [[ -z "$transcript_path" ]] || [[ ! -f "$transcript_path" ]]; then
return 1
fi
if ! command -v jq >/dev/null 2>&1; then
return 1
fi

local launched completed
launched=$(jq -r --arg since_ts "$since_ts" '
select(.toolUseResult != null)
| select(
($since_ts == ""
or ((.timestamp // "") == "")
or ((.timestamp // "") >= $since_ts))
)
| select(
(.toolUseResult.isAsync == true and (.toolUseResult.agentId // "") != "")
or ((.toolUseResult.backgroundTaskId // "") != "")
)
| (.toolUseResult.agentId // .toolUseResult.backgroundTaskId)
' "$transcript_path" 2>/dev/null | sort -u) || return 1

# Union of both completion formats. Either source alone is enough to
# mark a launched id terminal.
#
# The `grep -oE || true` guard on the legacy branch keeps `set -o
# pipefail` from poisoning the combined pipeline when no legacy
# queue-operation records exist in the transcript (grep with `-o`
# exits 1 on no matches, which would otherwise wipe out any SDK
# task_notification results collected above).
completed=$(
{
jq -r '
select(.type == "system" and .subtype == "task_notification")
| (.task_id // empty)
' "$transcript_path" 2>/dev/null
jq -r '
select(.type == "queue-operation" and .operation == "enqueue")
| (.content // "" | tostring)
| select(contains("<task-notification>"))
' "$transcript_path" 2>/dev/null \
| { grep -oE '<task-id>[^<]+</task-id>' || true; } \
| sed -E 's|</?task-id>||g'
} | sort -u | sed '/^$/d'
) || completed=""

# Emit launched ids that have no matching completion notification.
comm -23 \
<(printf '%s\n' "$launched" | sed '/^$/d') \
<(printf '%s\n' "$completed" | sed '/^$/d')
}

# Returns 0 when the transcript shows at least one pending background task.
# Returns 1 when no pending tasks are detected (including fail-closed cases
# like missing transcript, non-file path, or jq unavailable).
#
# Usage: has_pending_background_tasks "$transcript_path" [since_ts]
has_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || return 1
[[ -n "$pending" ]]
}

# Prints the count of pending background tasks to stdout. Prints 0 for any
# error case so callers can still format messages safely.
#
# Usage: count_pending_background_tasks "$transcript_path" [since_ts]
count_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || {
echo 0
return 0
}
if [[ -z "$pending" ]]; then
echo 0
else
printf '%s\n' "$pending" | sed '/^$/d' | wc -l | tr -d ' '
fi
}

# Single entry point for the stop hook: runs the four guard blocks
# (ambiguous-caller, cross-session parked, pending-bg short-circuit,
# same-session stale-marker cleanup) in order. When a guard decides to
# short-circuit the stop hook, it emits the appropriate JSON on stdout
# and `exit 0`s directly; the caller (sourcing the hook script) never
# returns. When no guard fires, this function returns 0 and the stop
# hook continues into its normal gate logic.
#
# Depends on FIELD_SESSION_ID and resolve_active_state_file from
# loop-common.sh.
#
# Usage: handle_bg_task_short_circuit "$LOOP_DIR" "$HOOK_INPUT" "$HOOK_SESSION_ID"
handle_bg_task_short_circuit() {
local loop_dir="$1" hook_input="$2" hook_session_id="$3"

# Shared state used by the guard blocks below.
# Loop-start boundary: derived from the loop dir basename
# (`YYYY-MM-DD_HH-MM-SS`). Empty means derivation failed; helpers
# treat empty since_ts as no boundary.
local loop_start_ts transcript_path
loop_start_ts=$(derive_loop_start_iso_ts "$loop_dir")
transcript_path=$(extract_transcript_path "$hook_input")

# ----------------------------------------
# Ambiguous-Caller Marker Guard
# ----------------------------------------
# If a bg-pending.marker is present but we have no session_id on
# this hook invocation (typical of scripts/rlcr-stop-gate.sh
# invoked without --session-id, or any other caller that doesn't
# forward session_id), we cannot tell whether this caller owns the
# parked loop. Taking either branch (foreign-session guard below,
# or same-session cleanup further down) would be wrong in one of
# the two possible realities. Exit 0 silently: the real Claude
# hook will arrive with session_id populated and drive parking /
# cleanup from an authoritative context.
if [[ -f "$loop_dir/bg-pending.marker" ]] && [[ -z "$hook_session_id" ]]; then
exit 0
fi

# ----------------------------------------
# Cross-Session Parked-Loop Guard
# ----------------------------------------
# If find_active_loop handed this dir over via the marker fallback,
# the loop is parked by a different session waiting on a background
# task. The current session has no authority to inspect or advance
# that loop - its transcript sees none of the foreign bg activity -
# so the only safe response is to exit 0 with a distinct
# systemMessage and leave every on-disk artifact (state file,
# stored session_id, marker) untouched.
#
# Both sides of the session-id comparison must be non-empty for
# this branch to trigger: an empty hook_session_id has already
# exited above via the ambiguous-caller guard, and an empty stored
# session_id keeps the backward-compat "matches any" semantics
# from find_active_loop.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local guard_state_file guard_stored_sid
guard_state_file=$(resolve_active_state_file "$loop_dir")
if [[ -n "$guard_state_file" ]]; then
guard_stored_sid=$(sed -n '/^---$/,/^---$/{ /^'"${FIELD_SESSION_ID}"':/{ s/^'"${FIELD_SESSION_ID}"': *//; p; } }' "$guard_state_file" 2>/dev/null | tr -d ' ')
if [[ -n "$guard_stored_sid" ]] \
&& [[ -n "$hook_session_id" ]] \
&& [[ "$guard_stored_sid" != "$hook_session_id" ]]; then
jq -n \
'{systemMessage: "RLCR loop in this repo is parked by another Claude session waiting for background work. Stop allowed; your session leaves the loop untouched. If that session ended, run /humanize:cancel-rlcr-loop to clean up."}'
exit 0
fi
fi
fi

# ----------------------------------------
# Early Exit: Pending Background Tasks
# ----------------------------------------
# When the main Claude Code session has dispatched background work
# (Agent with run_in_background=true, or Bash with
# run_in_background=true) whose completion notifications have not
# yet arrived, the natural "stop" is simply "I am waiting for the
# background task". Running git/summary/BitLesson/Codex gates in
# that state wastes Codex tokens and produces low-signal reviews.
#
# Allow the stop (exit 0) and emit a user-visible systemMessage so
# nobody mistakes the pause for loop completion. The on-disk loop
# state is left untouched -- the next natural stop (after
# background work finishes) will re-enter this hook with no
# pending tasks and run the normal flow.
#
# loop_start_ts confines the transcript scan to launches that
# actually happened during this loop; earlier session-wide bg
# activity cannot pin the loop.
#
# This check MUST run before any other gate (phase detection,
# state parsing, branch / plan / git-clean / summary / max-iter
# checks, Codex review).
local pending_bg_ids
pending_bg_ids=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) || true
if [[ -n "$pending_bg_ids" ]]; then
local pending_bg_count
pending_bg_count=$(printf '%s\n' "$pending_bg_ids" | sed '/^$/d' | wc -l | tr -d ' ')
# Mark the loop as parked; allows the same session to resume
# later and makes the cross-session guard above reachable if
# the user opens a different Claude session in this repo
# before the bg task completes.
: > "$loop_dir/bg-pending.marker" 2>/dev/null || true
jq -n --arg count "$pending_bg_count" \
'{systemMessage: ("RLCR loop active. " + $count + " background task(s) still running - stop allowed naturally; loop has NOT terminated and will resume on completion.")}'
exit 0
fi

# ----------------------------------------
# Same-Session Stale-Marker Cleanup
# ----------------------------------------
# The cross-session guard above already exited for every foreign
# session, so reaching here with the marker present means the
# CURRENT session parked the loop and has now come back with a
# transcript showing no pending bg events. Remove the stale marker
# before the normal flow takes over.
#
# Two-part guard to make sure we never drop the parked-state
# signal without evidence:
# (a) list_pending_background_task_ids returned exit 0 -- the
# transcript was present, readable, AND parsed successfully.
# The helper is fail-closed on missing files, empty paths,
# jq parse failure, and truncation, so a non-zero exit
# blocks cleanup here even when the transcript "file"
# exists.
# (b) its output is empty -- proves "no pending" was
# authoritatively verified, not inferred from a failure.
# The check uses a single fresh call so we capture both the exit
# code and the emptiness without double-running jq.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local pending_bg_check
if pending_bg_check=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) \
&& [[ -z "$pending_bg_check" ]]; then
rm -f "$loop_dir/bg-pending.marker" 2>/dev/null || true
fi
fi
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Allow natural stop while background tasks are in flight by SihaoLiu · Pull Request #91 · PolyArch/humanize · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 363 additions & 0 deletions hooks/lib/loop-bg-tasks.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
#!/usr/bin/env bash
#
# Background-task helpers for the RLCR stop hook.
#
# Owns all logic that inspects the Claude Code transcript to decide
# whether the hook should short-circuit (the main session is still
# waiting on an asynchronous Agent/Bash dispatch), plus the four guard
# blocks that the stop hook runs before its normal gate logic:
#
# 1. Ambiguous-caller marker guard
# 2. Cross-session parked-loop guard
# 3. Early exit: pending background tasks
# 4. Same-session stale-marker cleanup
#
# Depends on loop-common.sh (FIELD_SESSION_ID, resolve_active_state_file)
# being sourced first.
#

# Source guard.
[[ -n "${_LOOP_BG_TASKS_LOADED:-}" ]] && return 0 2>/dev/null || true
_LOOP_BG_TASKS_LOADED=1

# Expand a leading "~" or "~/" in a path to "$HOME" without using eval.
# Only the bare "~" and "~/..." forms are expanded; "~user/..." and every
# other input (absolute path, relative path, empty string) is returned verbatim.
#
# Usage: expand_leading_tilde "$path"
# Prints the normalized path to stdout.
expand_leading_tilde() {
local path="$1"
case "$path" in
'~') printf '%s' "${HOME:-}" ;;
'~/'*) printf '%s/%s' "${HOME:-}" "${path#'~/'}" ;;
*) printf '%s' "$path" ;;
esac
}

# Extract transcript_path from hook JSON input and expand any leading tilde.
# Usage: extract_transcript_path "$json_input"
# Outputs the transcript_path to stdout, or empty string if not available.
extract_transcript_path() {
local input="$1"
local raw
raw=$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null || echo "")
expand_leading_tilde "$raw"
}

# Convert an RLCR loop dir basename to a lexically-comparable ISO-8601
# UTC timestamp suitable for filtering transcript events.
#
# `setup-rlcr-loop.sh` creates loop dirs named `YYYY-MM-DD_HH-MM-SS` in
# the system's LOCAL wall clock (it calls `date +%Y-%m-%d_%H-%M-%S`
# without `-u`). Claude transcript events carry actual UTC timestamps
# like `2026-04-16T13:19:26.819Z`. To compare them correctly, this
# helper converts the local wall-clock parse back to a real UTC moment
# via a two-step: parse local -> epoch seconds -> format in UTC.
#
# The `.000Z` suffix keeps sub-second transcript timestamps in the same
# second compared greater via lexical string ordering.
#
# Usage: derive_loop_start_iso_ts "$loop_dir"
# Prints the ISO-8601 UTC timestamp, or empty string when the
# basename does not match the expected format or the local `date`
# binary cannot parse it.
derive_loop_start_iso_ts() {
local loop_dir="$1"
local base
base=$(basename "$loop_dir" 2>/dev/null || echo "")
if [[ ! "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})$ ]]; then
return
fi
local local_datetime
local_datetime="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}:${BASH_REMATCH[3]}:${BASH_REMATCH[4]}"

# Local wall-clock -> epoch seconds. GNU `date -d` first,
# BSD/macOS `date -j -f ...` second. Both honour the caller's TZ
# for interpretation, matching setup-rlcr-loop.sh's behaviour at
# loop-dir creation time.
local epoch
epoch=$(date -d "$local_datetime" +%s 2>/dev/null) || epoch=""
if [[ -z "$epoch" ]]; then
epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$local_datetime" +%s 2>/dev/null) || epoch=""
fi
if [[ -z "$epoch" ]]; then
return
fi

# Epoch -> UTC ISO-8601. Try GNU then BSD.
local utc_iso
utc_iso=$(date -u -d "@$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
if [[ -z "$utc_iso" ]]; then
utc_iso=$(date -u -r "$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
fi
printf '%s' "$utc_iso"
}

# Enumerate background-task ids that have been launched but not yet marked
# completed in a Claude Code transcript.jsonl.
#
# Launch events (inspected in tool_result "user" messages):
# - Background subagent: toolUseResult.isAsync == true
# -> id is toolUseResult.agentId
# - Background shell: toolUseResult.backgroundTaskId non-empty
# -> id is toolUseResult.backgroundTaskId
#
# Completion events are recognised from two Claude Code transcript forms:
#
# 1. Structured SDK record
# (see SDKTaskNotificationMessage in docs/typescript.md):
# `type == "system"`, `subtype == "task_notification"`,
# `task_id` is the completed id. Any `status` value
# (completed, failed, stopped, ...) is treated as terminal.
#
# 2. Legacy queue-operation enqueue whose `content` embeds a
# `<task-notification>` XML block with `<task-id>...</task-id>`;
# kept for transcripts produced by older Claude Code versions.
#
# pending := launched \ completed
#
# Optional second argument `since_ts` (ISO-8601 string, e.g. the value
# returned by `derive_loop_start_iso_ts`): when provided, only launch
# events whose top-level `.timestamp` field is >= `since_ts` count as
# candidate launches. Events without a `.timestamp` are included (keeps
# fixture transcripts and older record formats working). This keeps
# pre-loop session-wide background work from pinning an RLCR loop that
# has no pending work of its own.
#
# Usage: list_pending_background_task_ids "$transcript_path" [since_ts]
# - Outputs one id per line on stdout (possibly empty).
# - Returns 0 when the transcript is readable (including when there are
# no pending tasks). Returns 1 when the transcript path is empty, not
# a regular file, or jq is unavailable, so callers must treat non-zero
# as "unknown -> do not short-circuit".
list_pending_background_task_ids() {
local transcript_path="$1"
local since_ts="${2:-}"

# Normalize a leading tilde so direct callers (tests, ad-hoc scripts)
# work correctly even when transcript_path was not routed through
# extract_transcript_path.
transcript_path=$(expand_leading_tilde "$transcript_path")

if [[ -z "$transcript_path" ]] || [[ ! -f "$transcript_path" ]]; then
return 1
fi
if ! command -v jq >/dev/null 2>&1; then
return 1
fi

local launched completed
launched=$(jq -r --arg since_ts "$since_ts" '
select(.toolUseResult != null)
| select(
($since_ts == ""
or ((.timestamp // "") == "")
or ((.timestamp // "") >= $since_ts))
)
| select(
(.toolUseResult.isAsync == true and (.toolUseResult.agentId // "") != "")
or ((.toolUseResult.backgroundTaskId // "") != "")
)
| (.toolUseResult.agentId // .toolUseResult.backgroundTaskId)
' "$transcript_path" 2>/dev/null | sort -u) || return 1

# Union of both completion formats. Either source alone is enough to
# mark a launched id terminal.
#
# The `grep -oE || true` guard on the legacy branch keeps `set -o
# pipefail` from poisoning the combined pipeline when no legacy
# queue-operation records exist in the transcript (grep with `-o`
# exits 1 on no matches, which would otherwise wipe out any SDK
# task_notification results collected above).
completed=$(
{
jq -r '
select(.type == "system" and .subtype == "task_notification")
| (.task_id // empty)
' "$transcript_path" 2>/dev/null
jq -r '
select(.type == "queue-operation" and .operation == "enqueue")
| (.content // "" | tostring)
| select(contains("<task-notification>"))
' "$transcript_path" 2>/dev/null \
| { grep -oE '<task-id>[^<]+</task-id>' || true; } \
| sed -E 's|</?task-id>||g'
} | sort -u | sed '/^$/d'
) || completed=""

# Emit launched ids that have no matching completion notification.
comm -23 \
<(printf '%s\n' "$launched" | sed '/^$/d') \
<(printf '%s\n' "$completed" | sed '/^$/d')
}

# Returns 0 when the transcript shows at least one pending background task.
# Returns 1 when no pending tasks are detected (including fail-closed cases
# like missing transcript, non-file path, or jq unavailable).
#
# Usage: has_pending_background_tasks "$transcript_path" [since_ts]
has_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || return 1
[[ -n "$pending" ]]
}

# Prints the count of pending background tasks to stdout. Prints 0 for any
# error case so callers can still format messages safely.
#
# Usage: count_pending_background_tasks "$transcript_path" [since_ts]
count_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || {
echo 0
return 0
}
if [[ -z "$pending" ]]; then
echo 0
else
printf '%s\n' "$pending" | sed '/^$/d' | wc -l | tr -d ' '
fi
}

# Single entry point for the stop hook: runs the four guard blocks
# (ambiguous-caller, cross-session parked, pending-bg short-circuit,
# same-session stale-marker cleanup) in order. When a guard decides to
# short-circuit the stop hook, it emits the appropriate JSON on stdout
# and `exit 0`s directly; the caller (sourcing the hook script) never
# returns. When no guard fires, this function returns 0 and the stop
# hook continues into its normal gate logic.
#
# Depends on FIELD_SESSION_ID and resolve_active_state_file from
# loop-common.sh.
#
# Usage: handle_bg_task_short_circuit "$LOOP_DIR" "$HOOK_INPUT" "$HOOK_SESSION_ID"
handle_bg_task_short_circuit() {
local loop_dir="$1" hook_input="$2" hook_session_id="$3"

# Shared state used by the guard blocks below.
# Loop-start boundary: derived from the loop dir basename
# (`YYYY-MM-DD_HH-MM-SS`). Empty means derivation failed; helpers
# treat empty since_ts as no boundary.
local loop_start_ts transcript_path
loop_start_ts=$(derive_loop_start_iso_ts "$loop_dir")
transcript_path=$(extract_transcript_path "$hook_input")

# ----------------------------------------
# Ambiguous-Caller Marker Guard
# ----------------------------------------
# If a bg-pending.marker is present but we have no session_id on
# this hook invocation (typical of scripts/rlcr-stop-gate.sh
# invoked without --session-id, or any other caller that doesn't
# forward session_id), we cannot tell whether this caller owns the
# parked loop. Taking either branch (foreign-session guard below,
# or same-session cleanup further down) would be wrong in one of
# the two possible realities. Exit 0 silently: the real Claude
# hook will arrive with session_id populated and drive parking /
# cleanup from an authoritative context.
if [[ -f "$loop_dir/bg-pending.marker" ]] && [[ -z "$hook_session_id" ]]; then
exit 0
fi

# ----------------------------------------
# Cross-Session Parked-Loop Guard
# ----------------------------------------
# If find_active_loop handed this dir over via the marker fallback,
# the loop is parked by a different session waiting on a background
# task. The current session has no authority to inspect or advance
# that loop - its transcript sees none of the foreign bg activity -
# so the only safe response is to exit 0 with a distinct
# systemMessage and leave every on-disk artifact (state file,
# stored session_id, marker) untouched.
#
# Both sides of the session-id comparison must be non-empty for
# this branch to trigger: an empty hook_session_id has already
# exited above via the ambiguous-caller guard, and an empty stored
# session_id keeps the backward-compat "matches any" semantics
# from find_active_loop.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local guard_state_file guard_stored_sid
guard_state_file=$(resolve_active_state_file "$loop_dir")
if [[ -n "$guard_state_file" ]]; then
guard_stored_sid=$(sed -n '/^---$/,/^---$/{ /^'"${FIELD_SESSION_ID}"':/{ s/^'"${FIELD_SESSION_ID}"': *//; p; } }' "$guard_state_file" 2>/dev/null | tr -d ' ')
if [[ -n "$guard_stored_sid" ]] \
&& [[ -n "$hook_session_id" ]] \
&& [[ "$guard_stored_sid" != "$hook_session_id" ]]; then
jq -n \
'{systemMessage: "RLCR loop in this repo is parked by another Claude session waiting for background work. Stop allowed; your session leaves the loop untouched. If that session ended, run /humanize:cancel-rlcr-loop to clean up."}'
exit 0
fi
fi
fi

# ----------------------------------------
# Early Exit: Pending Background Tasks
# ----------------------------------------
# When the main Claude Code session has dispatched background work
# (Agent with run_in_background=true, or Bash with
# run_in_background=true) whose completion notifications have not
# yet arrived, the natural "stop" is simply "I am waiting for the
# background task". Running git/summary/BitLesson/Codex gates in
# that state wastes Codex tokens and produces low-signal reviews.
#
# Allow the stop (exit 0) and emit a user-visible systemMessage so
# nobody mistakes the pause for loop completion. The on-disk loop
# state is left untouched -- the next natural stop (after
# background work finishes) will re-enter this hook with no
# pending tasks and run the normal flow.
#
# loop_start_ts confines the transcript scan to launches that
# actually happened during this loop; earlier session-wide bg
# activity cannot pin the loop.
#
# This check MUST run before any other gate (phase detection,
# state parsing, branch / plan / git-clean / summary / max-iter
# checks, Codex review).
local pending_bg_ids
pending_bg_ids=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) || true
if [[ -n "$pending_bg_ids" ]]; then
local pending_bg_count
pending_bg_count=$(printf '%s\n' "$pending_bg_ids" | sed '/^$/d' | wc -l | tr -d ' ')
# Mark the loop as parked; allows the same session to resume
# later and makes the cross-session guard above reachable if
# the user opens a different Claude session in this repo
# before the bg task completes.
: > "$loop_dir/bg-pending.marker" 2>/dev/null || true
jq -n --arg count "$pending_bg_count" \
'{systemMessage: ("RLCR loop active. " + $count + " background task(s) still running - stop allowed naturally; loop has NOT terminated and will resume on completion.")}'
exit 0
fi

# ----------------------------------------
# Same-Session Stale-Marker Cleanup
# ----------------------------------------
# The cross-session guard above already exited for every foreign
# session, so reaching here with the marker present means the
# CURRENT session parked the loop and has now come back with a
# transcript showing no pending bg events. Remove the stale marker
# before the normal flow takes over.
#
# Two-part guard to make sure we never drop the parked-state
# signal without evidence:
# (a) list_pending_background_task_ids returned exit 0 -- the
# transcript was present, readable, AND parsed successfully.
# The helper is fail-closed on missing files, empty paths,
# jq parse failure, and truncation, so a non-zero exit
# blocks cleanup here even when the transcript "file"
# exists.
# (b) its output is empty -- proves "no pending" was
# authoritatively verified, not inferred from a failure.
# The check uses a single fresh call so we capture both the exit
# code and the emptiness without double-running jq.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local pending_bg_check
if pending_bg_check=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) \
&& [[ -z "$pending_bg_check" ]]; then
rm -f "$loop_dir/bg-pending.marker" 2>/dev/null || true
fi
fi
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Allow natural stop while background tasks are in flight by SihaoLiu · Pull Request #91 · PolyArch/humanize · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 363 additions & 0 deletions hooks/lib/loop-bg-tasks.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
#!/usr/bin/env bash
#
# Background-task helpers for the RLCR stop hook.
#
# Owns all logic that inspects the Claude Code transcript to decide
# whether the hook should short-circuit (the main session is still
# waiting on an asynchronous Agent/Bash dispatch), plus the four guard
# blocks that the stop hook runs before its normal gate logic:
#
# 1. Ambiguous-caller marker guard
# 2. Cross-session parked-loop guard
# 3. Early exit: pending background tasks
# 4. Same-session stale-marker cleanup
#
# Depends on loop-common.sh (FIELD_SESSION_ID, resolve_active_state_file)
# being sourced first.
#

# Source guard.
[[ -n "${_LOOP_BG_TASKS_LOADED:-}" ]] && return 0 2>/dev/null || true
_LOOP_BG_TASKS_LOADED=1

# Expand a leading "~" or "~/" in a path to "$HOME" without using eval.
# Only the bare "~" and "~/..." forms are expanded; "~user/..." and every
# other input (absolute path, relative path, empty string) is returned verbatim.
#
# Usage: expand_leading_tilde "$path"
# Prints the normalized path to stdout.
expand_leading_tilde() {
local path="$1"
case "$path" in
'~') printf '%s' "${HOME:-}" ;;
'~/'*) printf '%s/%s' "${HOME:-}" "${path#'~/'}" ;;
*) printf '%s' "$path" ;;
esac
}

# Extract transcript_path from hook JSON input and expand any leading tilde.
# Usage: extract_transcript_path "$json_input"
# Outputs the transcript_path to stdout, or empty string if not available.
extract_transcript_path() {
local input="$1"
local raw
raw=$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null || echo "")
expand_leading_tilde "$raw"
}

# Convert an RLCR loop dir basename to a lexically-comparable ISO-8601
# UTC timestamp suitable for filtering transcript events.
#
# `setup-rlcr-loop.sh` creates loop dirs named `YYYY-MM-DD_HH-MM-SS` in
# the system's LOCAL wall clock (it calls `date +%Y-%m-%d_%H-%M-%S`
# without `-u`). Claude transcript events carry actual UTC timestamps
# like `2026-04-16T13:19:26.819Z`. To compare them correctly, this
# helper converts the local wall-clock parse back to a real UTC moment
# via a two-step: parse local -> epoch seconds -> format in UTC.
#
# The `.000Z` suffix keeps sub-second transcript timestamps in the same
# second compared greater via lexical string ordering.
#
# Usage: derive_loop_start_iso_ts "$loop_dir"
# Prints the ISO-8601 UTC timestamp, or empty string when the
# basename does not match the expected format or the local `date`
# binary cannot parse it.
derive_loop_start_iso_ts() {
local loop_dir="$1"
local base
base=$(basename "$loop_dir" 2>/dev/null || echo "")
if [[ ! "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})$ ]]; then
return
fi
local local_datetime
local_datetime="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}:${BASH_REMATCH[3]}:${BASH_REMATCH[4]}"

# Local wall-clock -> epoch seconds. GNU `date -d` first,
# BSD/macOS `date -j -f ...` second. Both honour the caller's TZ
# for interpretation, matching setup-rlcr-loop.sh's behaviour at
# loop-dir creation time.
local epoch
epoch=$(date -d "$local_datetime" +%s 2>/dev/null) || epoch=""
if [[ -z "$epoch" ]]; then
epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$local_datetime" +%s 2>/dev/null) || epoch=""
fi
if [[ -z "$epoch" ]]; then
return
fi

# Epoch -> UTC ISO-8601. Try GNU then BSD.
local utc_iso
utc_iso=$(date -u -d "@$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
if [[ -z "$utc_iso" ]]; then
utc_iso=$(date -u -r "$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
fi
printf '%s' "$utc_iso"
}

# Enumerate background-task ids that have been launched but not yet marked
# completed in a Claude Code transcript.jsonl.
#
# Launch events (inspected in tool_result "user" messages):
# - Background subagent: toolUseResult.isAsync == true
# -> id is toolUseResult.agentId
# - Background shell: toolUseResult.backgroundTaskId non-empty
# -> id is toolUseResult.backgroundTaskId
#
# Completion events are recognised from two Claude Code transcript forms:
#
# 1. Structured SDK record
# (see SDKTaskNotificationMessage in docs/typescript.md):
# `type == "system"`, `subtype == "task_notification"`,
# `task_id` is the completed id. Any `status` value
# (completed, failed, stopped, ...) is treated as terminal.
#
# 2. Legacy queue-operation enqueue whose `content` embeds a
# `<task-notification>` XML block with `<task-id>...</task-id>`;
# kept for transcripts produced by older Claude Code versions.
#
# pending := launched \ completed
#
# Optional second argument `since_ts` (ISO-8601 string, e.g. the value
# returned by `derive_loop_start_iso_ts`): when provided, only launch
# events whose top-level `.timestamp` field is >= `since_ts` count as
# candidate launches. Events without a `.timestamp` are included (keeps
# fixture transcripts and older record formats working). This keeps
# pre-loop session-wide background work from pinning an RLCR loop that
# has no pending work of its own.
#
# Usage: list_pending_background_task_ids "$transcript_path" [since_ts]
# - Outputs one id per line on stdout (possibly empty).
# - Returns 0 when the transcript is readable (including when there are
# no pending tasks). Returns 1 when the transcript path is empty, not
# a regular file, or jq is unavailable, so callers must treat non-zero
# as "unknown -> do not short-circuit".
list_pending_background_task_ids() {
local transcript_path="$1"
local since_ts="${2:-}"

# Normalize a leading tilde so direct callers (tests, ad-hoc scripts)
# work correctly even when transcript_path was not routed through
# extract_transcript_path.
transcript_path=$(expand_leading_tilde "$transcript_path")

if [[ -z "$transcript_path" ]] || [[ ! -f "$transcript_path" ]]; then
return 1
fi
if ! command -v jq >/dev/null 2>&1; then
return 1
fi

local launched completed
launched=$(jq -r --arg since_ts "$since_ts" '
select(.toolUseResult != null)
| select(
($since_ts == ""
or ((.timestamp // "") == "")
or ((.timestamp // "") >= $since_ts))
)
| select(
(.toolUseResult.isAsync == true and (.toolUseResult.agentId // "") != "")
or ((.toolUseResult.backgroundTaskId // "") != "")
)
| (.toolUseResult.agentId // .toolUseResult.backgroundTaskId)
' "$transcript_path" 2>/dev/null | sort -u) || return 1

# Union of both completion formats. Either source alone is enough to
# mark a launched id terminal.
#
# The `grep -oE || true` guard on the legacy branch keeps `set -o
# pipefail` from poisoning the combined pipeline when no legacy
# queue-operation records exist in the transcript (grep with `-o`
# exits 1 on no matches, which would otherwise wipe out any SDK
# task_notification results collected above).
completed=$(
{
jq -r '
select(.type == "system" and .subtype == "task_notification")
| (.task_id // empty)
' "$transcript_path" 2>/dev/null
jq -r '
select(.type == "queue-operation" and .operation == "enqueue")
| (.content // "" | tostring)
| select(contains("<task-notification>"))
' "$transcript_path" 2>/dev/null \
| { grep -oE '<task-id>[^<]+</task-id>' || true; } \
| sed -E 's|</?task-id>||g'
} | sort -u | sed '/^$/d'
) || completed=""

# Emit launched ids that have no matching completion notification.
comm -23 \
<(printf '%s\n' "$launched" | sed '/^$/d') \
<(printf '%s\n' "$completed" | sed '/^$/d')
}

# Returns 0 when the transcript shows at least one pending background task.
# Returns 1 when no pending tasks are detected (including fail-closed cases
# like missing transcript, non-file path, or jq unavailable).
#
# Usage: has_pending_background_tasks "$transcript_path" [since_ts]
has_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || return 1
[[ -n "$pending" ]]
}

# Prints the count of pending background tasks to stdout. Prints 0 for any
# error case so callers can still format messages safely.
#
# Usage: count_pending_background_tasks "$transcript_path" [since_ts]
count_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || {
echo 0
return 0
}
if [[ -z "$pending" ]]; then
echo 0
else
printf '%s\n' "$pending" | sed '/^$/d' | wc -l | tr -d ' '
fi
}

# Single entry point for the stop hook: runs the four guard blocks
# (ambiguous-caller, cross-session parked, pending-bg short-circuit,
# same-session stale-marker cleanup) in order. When a guard decides to
# short-circuit the stop hook, it emits the appropriate JSON on stdout
# and `exit 0`s directly; the caller (sourcing the hook script) never
# returns. When no guard fires, this function returns 0 and the stop
# hook continues into its normal gate logic.
#
# Depends on FIELD_SESSION_ID and resolve_active_state_file from
# loop-common.sh.
#
# Usage: handle_bg_task_short_circuit "$LOOP_DIR" "$HOOK_INPUT" "$HOOK_SESSION_ID"
handle_bg_task_short_circuit() {
local loop_dir="$1" hook_input="$2" hook_session_id="$3"

# Shared state used by the guard blocks below.
# Loop-start boundary: derived from the loop dir basename
# (`YYYY-MM-DD_HH-MM-SS`). Empty means derivation failed; helpers
# treat empty since_ts as no boundary.
local loop_start_ts transcript_path
loop_start_ts=$(derive_loop_start_iso_ts "$loop_dir")
transcript_path=$(extract_transcript_path "$hook_input")

# ----------------------------------------
# Ambiguous-Caller Marker Guard
# ----------------------------------------
# If a bg-pending.marker is present but we have no session_id on
# this hook invocation (typical of scripts/rlcr-stop-gate.sh
# invoked without --session-id, or any other caller that doesn't
# forward session_id), we cannot tell whether this caller owns the
# parked loop. Taking either branch (foreign-session guard below,
# or same-session cleanup further down) would be wrong in one of
# the two possible realities. Exit 0 silently: the real Claude
# hook will arrive with session_id populated and drive parking /
# cleanup from an authoritative context.
if [[ -f "$loop_dir/bg-pending.marker" ]] && [[ -z "$hook_session_id" ]]; then
exit 0
fi

# ----------------------------------------
# Cross-Session Parked-Loop Guard
# ----------------------------------------
# If find_active_loop handed this dir over via the marker fallback,
# the loop is parked by a different session waiting on a background
# task. The current session has no authority to inspect or advance
# that loop - its transcript sees none of the foreign bg activity -
# so the only safe response is to exit 0 with a distinct
# systemMessage and leave every on-disk artifact (state file,
# stored session_id, marker) untouched.
#
# Both sides of the session-id comparison must be non-empty for
# this branch to trigger: an empty hook_session_id has already
# exited above via the ambiguous-caller guard, and an empty stored
# session_id keeps the backward-compat "matches any" semantics
# from find_active_loop.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local guard_state_file guard_stored_sid
guard_state_file=$(resolve_active_state_file "$loop_dir")
if [[ -n "$guard_state_file" ]]; then
guard_stored_sid=$(sed -n '/^---$/,/^---$/{ /^'"${FIELD_SESSION_ID}"':/{ s/^'"${FIELD_SESSION_ID}"': *//; p; } }' "$guard_state_file" 2>/dev/null | tr -d ' ')
if [[ -n "$guard_stored_sid" ]] \
&& [[ -n "$hook_session_id" ]] \
&& [[ "$guard_stored_sid" != "$hook_session_id" ]]; then
jq -n \
'{systemMessage: "RLCR loop in this repo is parked by another Claude session waiting for background work. Stop allowed; your session leaves the loop untouched. If that session ended, run /humanize:cancel-rlcr-loop to clean up."}'
exit 0
fi
fi
fi

# ----------------------------------------
# Early Exit: Pending Background Tasks
# ----------------------------------------
# When the main Claude Code session has dispatched background work
# (Agent with run_in_background=true, or Bash with
# run_in_background=true) whose completion notifications have not
# yet arrived, the natural "stop" is simply "I am waiting for the
# background task". Running git/summary/BitLesson/Codex gates in
# that state wastes Codex tokens and produces low-signal reviews.
#
# Allow the stop (exit 0) and emit a user-visible systemMessage so
# nobody mistakes the pause for loop completion. The on-disk loop
# state is left untouched -- the next natural stop (after
# background work finishes) will re-enter this hook with no
# pending tasks and run the normal flow.
#
# loop_start_ts confines the transcript scan to launches that
# actually happened during this loop; earlier session-wide bg
# activity cannot pin the loop.
#
# This check MUST run before any other gate (phase detection,
# state parsing, branch / plan / git-clean / summary / max-iter
# checks, Codex review).
local pending_bg_ids
pending_bg_ids=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) || true
if [[ -n "$pending_bg_ids" ]]; then
local pending_bg_count
pending_bg_count=$(printf '%s\n' "$pending_bg_ids" | sed '/^$/d' | wc -l | tr -d ' ')
# Mark the loop as parked; allows the same session to resume
# later and makes the cross-session guard above reachable if
# the user opens a different Claude session in this repo
# before the bg task completes.
: > "$loop_dir/bg-pending.marker" 2>/dev/null || true
jq -n --arg count "$pending_bg_count" \
'{systemMessage: ("RLCR loop active. " + $count + " background task(s) still running - stop allowed naturally; loop has NOT terminated and will resume on completion.")}'
exit 0
fi

# ----------------------------------------
# Same-Session Stale-Marker Cleanup
# ----------------------------------------
# The cross-session guard above already exited for every foreign
# session, so reaching here with the marker present means the
# CURRENT session parked the loop and has now come back with a
# transcript showing no pending bg events. Remove the stale marker
# before the normal flow takes over.
#
# Two-part guard to make sure we never drop the parked-state
# signal without evidence:
# (a) list_pending_background_task_ids returned exit 0 -- the
# transcript was present, readable, AND parsed successfully.
# The helper is fail-closed on missing files, empty paths,
# jq parse failure, and truncation, so a non-zero exit
# blocks cleanup here even when the transcript "file"
# exists.
# (b) its output is empty -- proves "no pending" was
# authoritatively verified, not inferred from a failure.
# The check uses a single fresh call so we capture both the exit
# code and the emptiness without double-running jq.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local pending_bg_check
if pending_bg_check=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) \
&& [[ -z "$pending_bg_check" ]]; then
rm -f "$loop_dir/bg-pending.marker" 2>/dev/null || true
fi
fi
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Allow natural stop while background tasks are in flight by SihaoLiu · Pull Request #91 · PolyArch/humanize · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 363 additions & 0 deletions hooks/lib/loop-bg-tasks.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
#!/usr/bin/env bash
#
# Background-task helpers for the RLCR stop hook.
#
# Owns all logic that inspects the Claude Code transcript to decide
# whether the hook should short-circuit (the main session is still
# waiting on an asynchronous Agent/Bash dispatch), plus the four guard
# blocks that the stop hook runs before its normal gate logic:
#
# 1. Ambiguous-caller marker guard
# 2. Cross-session parked-loop guard
# 3. Early exit: pending background tasks
# 4. Same-session stale-marker cleanup
#
# Depends on loop-common.sh (FIELD_SESSION_ID, resolve_active_state_file)
# being sourced first.
#

# Source guard.
[[ -n "${_LOOP_BG_TASKS_LOADED:-}" ]] && return 0 2>/dev/null || true
_LOOP_BG_TASKS_LOADED=1

# Expand a leading "~" or "~/" in a path to "$HOME" without using eval.
# Only the bare "~" and "~/..." forms are expanded; "~user/..." and every
# other input (absolute path, relative path, empty string) is returned verbatim.
#
# Usage: expand_leading_tilde "$path"
# Prints the normalized path to stdout.
expand_leading_tilde() {
local path="$1"
case "$path" in
'~') printf '%s' "${HOME:-}" ;;
'~/'*) printf '%s/%s' "${HOME:-}" "${path#'~/'}" ;;
*) printf '%s' "$path" ;;
esac
}

# Extract transcript_path from hook JSON input and expand any leading tilde.
# Usage: extract_transcript_path "$json_input"
# Outputs the transcript_path to stdout, or empty string if not available.
extract_transcript_path() {
local input="$1"
local raw
raw=$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null || echo "")
expand_leading_tilde "$raw"
}

# Convert an RLCR loop dir basename to a lexically-comparable ISO-8601
# UTC timestamp suitable for filtering transcript events.
#
# `setup-rlcr-loop.sh` creates loop dirs named `YYYY-MM-DD_HH-MM-SS` in
# the system's LOCAL wall clock (it calls `date +%Y-%m-%d_%H-%M-%S`
# without `-u`). Claude transcript events carry actual UTC timestamps
# like `2026-04-16T13:19:26.819Z`. To compare them correctly, this
# helper converts the local wall-clock parse back to a real UTC moment
# via a two-step: parse local -> epoch seconds -> format in UTC.
#
# The `.000Z` suffix keeps sub-second transcript timestamps in the same
# second compared greater via lexical string ordering.
#
# Usage: derive_loop_start_iso_ts "$loop_dir"
# Prints the ISO-8601 UTC timestamp, or empty string when the
# basename does not match the expected format or the local `date`
# binary cannot parse it.
derive_loop_start_iso_ts() {
local loop_dir="$1"
local base
base=$(basename "$loop_dir" 2>/dev/null || echo "")
if [[ ! "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})$ ]]; then
return
fi
local local_datetime
local_datetime="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}:${BASH_REMATCH[3]}:${BASH_REMATCH[4]}"

# Local wall-clock -> epoch seconds. GNU `date -d` first,
# BSD/macOS `date -j -f ...` second. Both honour the caller's TZ
# for interpretation, matching setup-rlcr-loop.sh's behaviour at
# loop-dir creation time.
local epoch
epoch=$(date -d "$local_datetime" +%s 2>/dev/null) || epoch=""
if [[ -z "$epoch" ]]; then
epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$local_datetime" +%s 2>/dev/null) || epoch=""
fi
if [[ -z "$epoch" ]]; then
return
fi

# Epoch -> UTC ISO-8601. Try GNU then BSD.
local utc_iso
utc_iso=$(date -u -d "@$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
if [[ -z "$utc_iso" ]]; then
utc_iso=$(date -u -r "$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
fi
printf '%s' "$utc_iso"
}

# Enumerate background-task ids that have been launched but not yet marked
# completed in a Claude Code transcript.jsonl.
#
# Launch events (inspected in tool_result "user" messages):
# - Background subagent: toolUseResult.isAsync == true
# -> id is toolUseResult.agentId
# - Background shell: toolUseResult.backgroundTaskId non-empty
# -> id is toolUseResult.backgroundTaskId
#
# Completion events are recognised from two Claude Code transcript forms:
#
# 1. Structured SDK record
# (see SDKTaskNotificationMessage in docs/typescript.md):
# `type == "system"`, `subtype == "task_notification"`,
# `task_id` is the completed id. Any `status` value
# (completed, failed, stopped, ...) is treated as terminal.
#
# 2. Legacy queue-operation enqueue whose `content` embeds a
# `<task-notification>` XML block with `<task-id>...</task-id>`;
# kept for transcripts produced by older Claude Code versions.
#
# pending := launched \ completed
#
# Optional second argument `since_ts` (ISO-8601 string, e.g. the value
# returned by `derive_loop_start_iso_ts`): when provided, only launch
# events whose top-level `.timestamp` field is >= `since_ts` count as
# candidate launches. Events without a `.timestamp` are included (keeps
# fixture transcripts and older record formats working). This keeps
# pre-loop session-wide background work from pinning an RLCR loop that
# has no pending work of its own.
#
# Usage: list_pending_background_task_ids "$transcript_path" [since_ts]
# - Outputs one id per line on stdout (possibly empty).
# - Returns 0 when the transcript is readable (including when there are
# no pending tasks). Returns 1 when the transcript path is empty, not
# a regular file, or jq is unavailable, so callers must treat non-zero
# as "unknown -> do not short-circuit".
list_pending_background_task_ids() {
local transcript_path="$1"
local since_ts="${2:-}"

# Normalize a leading tilde so direct callers (tests, ad-hoc scripts)
# work correctly even when transcript_path was not routed through
# extract_transcript_path.
transcript_path=$(expand_leading_tilde "$transcript_path")

if [[ -z "$transcript_path" ]] || [[ ! -f "$transcript_path" ]]; then
return 1
fi
if ! command -v jq >/dev/null 2>&1; then
return 1
fi

local launched completed
launched=$(jq -r --arg since_ts "$since_ts" '
select(.toolUseResult != null)
| select(
($since_ts == ""
or ((.timestamp // "") == "")
or ((.timestamp // "") >= $since_ts))
)
| select(
(.toolUseResult.isAsync == true and (.toolUseResult.agentId // "") != "")
or ((.toolUseResult.backgroundTaskId // "") != "")
)
| (.toolUseResult.agentId // .toolUseResult.backgroundTaskId)
' "$transcript_path" 2>/dev/null | sort -u) || return 1

# Union of both completion formats. Either source alone is enough to
# mark a launched id terminal.
#
# The `grep -oE || true` guard on the legacy branch keeps `set -o
# pipefail` from poisoning the combined pipeline when no legacy
# queue-operation records exist in the transcript (grep with `-o`
# exits 1 on no matches, which would otherwise wipe out any SDK
# task_notification results collected above).
completed=$(
{
jq -r '
select(.type == "system" and .subtype == "task_notification")
| (.task_id // empty)
' "$transcript_path" 2>/dev/null
jq -r '
select(.type == "queue-operation" and .operation == "enqueue")
| (.content // "" | tostring)
| select(contains("<task-notification>"))
' "$transcript_path" 2>/dev/null \
| { grep -oE '<task-id>[^<]+</task-id>' || true; } \
| sed -E 's|</?task-id>||g'
} | sort -u | sed '/^$/d'
) || completed=""

# Emit launched ids that have no matching completion notification.
comm -23 \
<(printf '%s\n' "$launched" | sed '/^$/d') \
<(printf '%s\n' "$completed" | sed '/^$/d')
}

# Returns 0 when the transcript shows at least one pending background task.
# Returns 1 when no pending tasks are detected (including fail-closed cases
# like missing transcript, non-file path, or jq unavailable).
#
# Usage: has_pending_background_tasks "$transcript_path" [since_ts]
has_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || return 1
[[ -n "$pending" ]]
}

# Prints the count of pending background tasks to stdout. Prints 0 for any
# error case so callers can still format messages safely.
#
# Usage: count_pending_background_tasks "$transcript_path" [since_ts]
count_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || {
echo 0
return 0
}
if [[ -z "$pending" ]]; then
echo 0
else
printf '%s\n' "$pending" | sed '/^$/d' | wc -l | tr -d ' '
fi
}

# Single entry point for the stop hook: runs the four guard blocks
# (ambiguous-caller, cross-session parked, pending-bg short-circuit,
# same-session stale-marker cleanup) in order. When a guard decides to
# short-circuit the stop hook, it emits the appropriate JSON on stdout
# and `exit 0`s directly; the caller (sourcing the hook script) never
# returns. When no guard fires, this function returns 0 and the stop
# hook continues into its normal gate logic.
#
# Depends on FIELD_SESSION_ID and resolve_active_state_file from
# loop-common.sh.
#
# Usage: handle_bg_task_short_circuit "$LOOP_DIR" "$HOOK_INPUT" "$HOOK_SESSION_ID"
handle_bg_task_short_circuit() {
local loop_dir="$1" hook_input="$2" hook_session_id="$3"

# Shared state used by the guard blocks below.
# Loop-start boundary: derived from the loop dir basename
# (`YYYY-MM-DD_HH-MM-SS`). Empty means derivation failed; helpers
# treat empty since_ts as no boundary.
local loop_start_ts transcript_path
loop_start_ts=$(derive_loop_start_iso_ts "$loop_dir")
transcript_path=$(extract_transcript_path "$hook_input")

# ----------------------------------------
# Ambiguous-Caller Marker Guard
# ----------------------------------------
# If a bg-pending.marker is present but we have no session_id on
# this hook invocation (typical of scripts/rlcr-stop-gate.sh
# invoked without --session-id, or any other caller that doesn't
# forward session_id), we cannot tell whether this caller owns the
# parked loop. Taking either branch (foreign-session guard below,
# or same-session cleanup further down) would be wrong in one of
# the two possible realities. Exit 0 silently: the real Claude
# hook will arrive with session_id populated and drive parking /
# cleanup from an authoritative context.
if [[ -f "$loop_dir/bg-pending.marker" ]] && [[ -z "$hook_session_id" ]]; then
exit 0
fi

# ----------------------------------------
# Cross-Session Parked-Loop Guard
# ----------------------------------------
# If find_active_loop handed this dir over via the marker fallback,
# the loop is parked by a different session waiting on a background
# task. The current session has no authority to inspect or advance
# that loop - its transcript sees none of the foreign bg activity -
# so the only safe response is to exit 0 with a distinct
# systemMessage and leave every on-disk artifact (state file,
# stored session_id, marker) untouched.
#
# Both sides of the session-id comparison must be non-empty for
# this branch to trigger: an empty hook_session_id has already
# exited above via the ambiguous-caller guard, and an empty stored
# session_id keeps the backward-compat "matches any" semantics
# from find_active_loop.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local guard_state_file guard_stored_sid
guard_state_file=$(resolve_active_state_file "$loop_dir")
if [[ -n "$guard_state_file" ]]; then
guard_stored_sid=$(sed -n '/^---$/,/^---$/{ /^'"${FIELD_SESSION_ID}"':/{ s/^'"${FIELD_SESSION_ID}"': *//; p; } }' "$guard_state_file" 2>/dev/null | tr -d ' ')
if [[ -n "$guard_stored_sid" ]] \
&& [[ -n "$hook_session_id" ]] \
&& [[ "$guard_stored_sid" != "$hook_session_id" ]]; then
jq -n \
'{systemMessage: "RLCR loop in this repo is parked by another Claude session waiting for background work. Stop allowed; your session leaves the loop untouched. If that session ended, run /humanize:cancel-rlcr-loop to clean up."}'
exit 0
fi
fi
fi

# ----------------------------------------
# Early Exit: Pending Background Tasks
# ----------------------------------------
# When the main Claude Code session has dispatched background work
# (Agent with run_in_background=true, or Bash with
# run_in_background=true) whose completion notifications have not
# yet arrived, the natural "stop" is simply "I am waiting for the
# background task". Running git/summary/BitLesson/Codex gates in
# that state wastes Codex tokens and produces low-signal reviews.
#
# Allow the stop (exit 0) and emit a user-visible systemMessage so
# nobody mistakes the pause for loop completion. The on-disk loop
# state is left untouched -- the next natural stop (after
# background work finishes) will re-enter this hook with no
# pending tasks and run the normal flow.
#
# loop_start_ts confines the transcript scan to launches that
# actually happened during this loop; earlier session-wide bg
# activity cannot pin the loop.
#
# This check MUST run before any other gate (phase detection,
# state parsing, branch / plan / git-clean / summary / max-iter
# checks, Codex review).
local pending_bg_ids
pending_bg_ids=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) || true
if [[ -n "$pending_bg_ids" ]]; then
local pending_bg_count
pending_bg_count=$(printf '%s\n' "$pending_bg_ids" | sed '/^$/d' | wc -l | tr -d ' ')
# Mark the loop as parked; allows the same session to resume
# later and makes the cross-session guard above reachable if
# the user opens a different Claude session in this repo
# before the bg task completes.
: > "$loop_dir/bg-pending.marker" 2>/dev/null || true
jq -n --arg count "$pending_bg_count" \
'{systemMessage: ("RLCR loop active. " + $count + " background task(s) still running - stop allowed naturally; loop has NOT terminated and will resume on completion.")}'
exit 0
fi

# ----------------------------------------
# Same-Session Stale-Marker Cleanup
# ----------------------------------------
# The cross-session guard above already exited for every foreign
# session, so reaching here with the marker present means the
# CURRENT session parked the loop and has now come back with a
# transcript showing no pending bg events. Remove the stale marker
# before the normal flow takes over.
#
# Two-part guard to make sure we never drop the parked-state
# signal without evidence:
# (a) list_pending_background_task_ids returned exit 0 -- the
# transcript was present, readable, AND parsed successfully.
# The helper is fail-closed on missing files, empty paths,
# jq parse failure, and truncation, so a non-zero exit
# blocks cleanup here even when the transcript "file"
# exists.
# (b) its output is empty -- proves "no pending" was
# authoritatively verified, not inferred from a failure.
# The check uses a single fresh call so we capture both the exit
# code and the emptiness without double-running jq.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local pending_bg_check
if pending_bg_check=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) \
&& [[ -z "$pending_bg_check" ]]; then
rm -f "$loop_dir/bg-pending.marker" 2>/dev/null || true
fi
fi
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Allow natural stop while background tasks are in flight by SihaoLiu · Pull Request #91 · PolyArch/humanize · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 363 additions & 0 deletions hooks/lib/loop-bg-tasks.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
#!/usr/bin/env bash
#
# Background-task helpers for the RLCR stop hook.
#
# Owns all logic that inspects the Claude Code transcript to decide
# whether the hook should short-circuit (the main session is still
# waiting on an asynchronous Agent/Bash dispatch), plus the four guard
# blocks that the stop hook runs before its normal gate logic:
#
# 1. Ambiguous-caller marker guard
# 2. Cross-session parked-loop guard
# 3. Early exit: pending background tasks
# 4. Same-session stale-marker cleanup
#
# Depends on loop-common.sh (FIELD_SESSION_ID, resolve_active_state_file)
# being sourced first.
#

# Source guard.
[[ -n "${_LOOP_BG_TASKS_LOADED:-}" ]] && return 0 2>/dev/null || true
_LOOP_BG_TASKS_LOADED=1

# Expand a leading "~" or "~/" in a path to "$HOME" without using eval.
# Only the bare "~" and "~/..." forms are expanded; "~user/..." and every
# other input (absolute path, relative path, empty string) is returned verbatim.
#
# Usage: expand_leading_tilde "$path"
# Prints the normalized path to stdout.
expand_leading_tilde() {
local path="$1"
case "$path" in
'~') printf '%s' "${HOME:-}" ;;
'~/'*) printf '%s/%s' "${HOME:-}" "${path#'~/'}" ;;
*) printf '%s' "$path" ;;
esac
}

# Extract transcript_path from hook JSON input and expand any leading tilde.
# Usage: extract_transcript_path "$json_input"
# Outputs the transcript_path to stdout, or empty string if not available.
extract_transcript_path() {
local input="$1"
local raw
raw=$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null || echo "")
expand_leading_tilde "$raw"
}

# Convert an RLCR loop dir basename to a lexically-comparable ISO-8601
# UTC timestamp suitable for filtering transcript events.
#
# `setup-rlcr-loop.sh` creates loop dirs named `YYYY-MM-DD_HH-MM-SS` in
# the system's LOCAL wall clock (it calls `date +%Y-%m-%d_%H-%M-%S`
# without `-u`). Claude transcript events carry actual UTC timestamps
# like `2026-04-16T13:19:26.819Z`. To compare them correctly, this
# helper converts the local wall-clock parse back to a real UTC moment
# via a two-step: parse local -> epoch seconds -> format in UTC.
#
# The `.000Z` suffix keeps sub-second transcript timestamps in the same
# second compared greater via lexical string ordering.
#
# Usage: derive_loop_start_iso_ts "$loop_dir"
# Prints the ISO-8601 UTC timestamp, or empty string when the
# basename does not match the expected format or the local `date`
# binary cannot parse it.
derive_loop_start_iso_ts() {
local loop_dir="$1"
local base
base=$(basename "$loop_dir" 2>/dev/null || echo "")
if [[ ! "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})$ ]]; then
return
fi
local local_datetime
local_datetime="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}:${BASH_REMATCH[3]}:${BASH_REMATCH[4]}"

# Local wall-clock -> epoch seconds. GNU `date -d` first,
# BSD/macOS `date -j -f ...` second. Both honour the caller's TZ
# for interpretation, matching setup-rlcr-loop.sh's behaviour at
# loop-dir creation time.
local epoch
epoch=$(date -d "$local_datetime" +%s 2>/dev/null) || epoch=""
if [[ -z "$epoch" ]]; then
epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$local_datetime" +%s 2>/dev/null) || epoch=""
fi
if [[ -z "$epoch" ]]; then
return
fi

# Epoch -> UTC ISO-8601. Try GNU then BSD.
local utc_iso
utc_iso=$(date -u -d "@$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
if [[ -z "$utc_iso" ]]; then
utc_iso=$(date -u -r "$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
fi
printf '%s' "$utc_iso"
}

# Enumerate background-task ids that have been launched but not yet marked
# completed in a Claude Code transcript.jsonl.
#
# Launch events (inspected in tool_result "user" messages):
# - Background subagent: toolUseResult.isAsync == true
# -> id is toolUseResult.agentId
# - Background shell: toolUseResult.backgroundTaskId non-empty
# -> id is toolUseResult.backgroundTaskId
#
# Completion events are recognised from two Claude Code transcript forms:
#
# 1. Structured SDK record
# (see SDKTaskNotificationMessage in docs/typescript.md):
# `type == "system"`, `subtype == "task_notification"`,
# `task_id` is the completed id. Any `status` value
# (completed, failed, stopped, ...) is treated as terminal.
#
# 2. Legacy queue-operation enqueue whose `content` embeds a
# `<task-notification>` XML block with `<task-id>...</task-id>`;
# kept for transcripts produced by older Claude Code versions.
#
# pending := launched \ completed
#
# Optional second argument `since_ts` (ISO-8601 string, e.g. the value
# returned by `derive_loop_start_iso_ts`): when provided, only launch
# events whose top-level `.timestamp` field is >= `since_ts` count as
# candidate launches. Events without a `.timestamp` are included (keeps
# fixture transcripts and older record formats working). This keeps
# pre-loop session-wide background work from pinning an RLCR loop that
# has no pending work of its own.
#
# Usage: list_pending_background_task_ids "$transcript_path" [since_ts]
# - Outputs one id per line on stdout (possibly empty).
# - Returns 0 when the transcript is readable (including when there are
# no pending tasks). Returns 1 when the transcript path is empty, not
# a regular file, or jq is unavailable, so callers must treat non-zero
# as "unknown -> do not short-circuit".
list_pending_background_task_ids() {
local transcript_path="$1"
local since_ts="${2:-}"

# Normalize a leading tilde so direct callers (tests, ad-hoc scripts)
# work correctly even when transcript_path was not routed through
# extract_transcript_path.
transcript_path=$(expand_leading_tilde "$transcript_path")

if [[ -z "$transcript_path" ]] || [[ ! -f "$transcript_path" ]]; then
return 1
fi
if ! command -v jq >/dev/null 2>&1; then
return 1
fi

local launched completed
launched=$(jq -r --arg since_ts "$since_ts" '
select(.toolUseResult != null)
| select(
($since_ts == ""
or ((.timestamp // "") == "")
or ((.timestamp // "") >= $since_ts))
)
| select(
(.toolUseResult.isAsync == true and (.toolUseResult.agentId // "") != "")
or ((.toolUseResult.backgroundTaskId // "") != "")
)
| (.toolUseResult.agentId // .toolUseResult.backgroundTaskId)
' "$transcript_path" 2>/dev/null | sort -u) || return 1

# Union of both completion formats. Either source alone is enough to
# mark a launched id terminal.
#
# The `grep -oE || true` guard on the legacy branch keeps `set -o
# pipefail` from poisoning the combined pipeline when no legacy
# queue-operation records exist in the transcript (grep with `-o`
# exits 1 on no matches, which would otherwise wipe out any SDK
# task_notification results collected above).
completed=$(
{
jq -r '
select(.type == "system" and .subtype == "task_notification")
| (.task_id // empty)
' "$transcript_path" 2>/dev/null
jq -r '
select(.type == "queue-operation" and .operation == "enqueue")
| (.content // "" | tostring)
| select(contains("<task-notification>"))
' "$transcript_path" 2>/dev/null \
| { grep -oE '<task-id>[^<]+</task-id>' || true; } \
| sed -E 's|</?task-id>||g'
} | sort -u | sed '/^$/d'
) || completed=""

# Emit launched ids that have no matching completion notification.
comm -23 \
<(printf '%s\n' "$launched" | sed '/^$/d') \
<(printf '%s\n' "$completed" | sed '/^$/d')
}

# Returns 0 when the transcript shows at least one pending background task.
# Returns 1 when no pending tasks are detected (including fail-closed cases
# like missing transcript, non-file path, or jq unavailable).
#
# Usage: has_pending_background_tasks "$transcript_path" [since_ts]
has_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || return 1
[[ -n "$pending" ]]
}

# Prints the count of pending background tasks to stdout. Prints 0 for any
# error case so callers can still format messages safely.
#
# Usage: count_pending_background_tasks "$transcript_path" [since_ts]
count_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || {
echo 0
return 0
}
if [[ -z "$pending" ]]; then
echo 0
else
printf '%s\n' "$pending" | sed '/^$/d' | wc -l | tr -d ' '
fi
}

# Single entry point for the stop hook: runs the four guard blocks
# (ambiguous-caller, cross-session parked, pending-bg short-circuit,
# same-session stale-marker cleanup) in order. When a guard decides to
# short-circuit the stop hook, it emits the appropriate JSON on stdout
# and `exit 0`s directly; the caller (sourcing the hook script) never
# returns. When no guard fires, this function returns 0 and the stop
# hook continues into its normal gate logic.
#
# Depends on FIELD_SESSION_ID and resolve_active_state_file from
# loop-common.sh.
#
# Usage: handle_bg_task_short_circuit "$LOOP_DIR" "$HOOK_INPUT" "$HOOK_SESSION_ID"
handle_bg_task_short_circuit() {
local loop_dir="$1" hook_input="$2" hook_session_id="$3"

# Shared state used by the guard blocks below.
# Loop-start boundary: derived from the loop dir basename
# (`YYYY-MM-DD_HH-MM-SS`). Empty means derivation failed; helpers
# treat empty since_ts as no boundary.
local loop_start_ts transcript_path
loop_start_ts=$(derive_loop_start_iso_ts "$loop_dir")
transcript_path=$(extract_transcript_path "$hook_input")

# ----------------------------------------
# Ambiguous-Caller Marker Guard
# ----------------------------------------
# If a bg-pending.marker is present but we have no session_id on
# this hook invocation (typical of scripts/rlcr-stop-gate.sh
# invoked without --session-id, or any other caller that doesn't
# forward session_id), we cannot tell whether this caller owns the
# parked loop. Taking either branch (foreign-session guard below,
# or same-session cleanup further down) would be wrong in one of
# the two possible realities. Exit 0 silently: the real Claude
# hook will arrive with session_id populated and drive parking /
# cleanup from an authoritative context.
if [[ -f "$loop_dir/bg-pending.marker" ]] && [[ -z "$hook_session_id" ]]; then
exit 0
fi

# ----------------------------------------
# Cross-Session Parked-Loop Guard
# ----------------------------------------
# If find_active_loop handed this dir over via the marker fallback,
# the loop is parked by a different session waiting on a background
# task. The current session has no authority to inspect or advance
# that loop - its transcript sees none of the foreign bg activity -
# so the only safe response is to exit 0 with a distinct
# systemMessage and leave every on-disk artifact (state file,
# stored session_id, marker) untouched.
#
# Both sides of the session-id comparison must be non-empty for
# this branch to trigger: an empty hook_session_id has already
# exited above via the ambiguous-caller guard, and an empty stored
# session_id keeps the backward-compat "matches any" semantics
# from find_active_loop.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local guard_state_file guard_stored_sid
guard_state_file=$(resolve_active_state_file "$loop_dir")
if [[ -n "$guard_state_file" ]]; then
guard_stored_sid=$(sed -n '/^---$/,/^---$/{ /^'"${FIELD_SESSION_ID}"':/{ s/^'"${FIELD_SESSION_ID}"': *//; p; } }' "$guard_state_file" 2>/dev/null | tr -d ' ')
if [[ -n "$guard_stored_sid" ]] \
&& [[ -n "$hook_session_id" ]] \
&& [[ "$guard_stored_sid" != "$hook_session_id" ]]; then
jq -n \
'{systemMessage: "RLCR loop in this repo is parked by another Claude session waiting for background work. Stop allowed; your session leaves the loop untouched. If that session ended, run /humanize:cancel-rlcr-loop to clean up."}'
exit 0
fi
fi
fi

# ----------------------------------------
# Early Exit: Pending Background Tasks
# ----------------------------------------
# When the main Claude Code session has dispatched background work
# (Agent with run_in_background=true, or Bash with
# run_in_background=true) whose completion notifications have not
# yet arrived, the natural "stop" is simply "I am waiting for the
# background task". Running git/summary/BitLesson/Codex gates in
# that state wastes Codex tokens and produces low-signal reviews.
#
# Allow the stop (exit 0) and emit a user-visible systemMessage so
# nobody mistakes the pause for loop completion. The on-disk loop
# state is left untouched -- the next natural stop (after
# background work finishes) will re-enter this hook with no
# pending tasks and run the normal flow.
#
# loop_start_ts confines the transcript scan to launches that
# actually happened during this loop; earlier session-wide bg
# activity cannot pin the loop.
#
# This check MUST run before any other gate (phase detection,
# state parsing, branch / plan / git-clean / summary / max-iter
# checks, Codex review).
local pending_bg_ids
pending_bg_ids=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) || true
if [[ -n "$pending_bg_ids" ]]; then
local pending_bg_count
pending_bg_count=$(printf '%s\n' "$pending_bg_ids" | sed '/^$/d' | wc -l | tr -d ' ')
# Mark the loop as parked; allows the same session to resume
# later and makes the cross-session guard above reachable if
# the user opens a different Claude session in this repo
# before the bg task completes.
: > "$loop_dir/bg-pending.marker" 2>/dev/null || true
jq -n --arg count "$pending_bg_count" \
'{systemMessage: ("RLCR loop active. " + $count + " background task(s) still running - stop allowed naturally; loop has NOT terminated and will resume on completion.")}'
exit 0
fi

# ----------------------------------------
# Same-Session Stale-Marker Cleanup
# ----------------------------------------
# The cross-session guard above already exited for every foreign
# session, so reaching here with the marker present means the
# CURRENT session parked the loop and has now come back with a
# transcript showing no pending bg events. Remove the stale marker
# before the normal flow takes over.
#
# Two-part guard to make sure we never drop the parked-state
# signal without evidence:
# (a) list_pending_background_task_ids returned exit 0 -- the
# transcript was present, readable, AND parsed successfully.
# The helper is fail-closed on missing files, empty paths,
# jq parse failure, and truncation, so a non-zero exit
# blocks cleanup here even when the transcript "file"
# exists.
# (b) its output is empty -- proves "no pending" was
# authoritatively verified, not inferred from a failure.
# The check uses a single fresh call so we capture both the exit
# code and the emptiness without double-running jq.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local pending_bg_check
if pending_bg_check=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) \
&& [[ -z "$pending_bg_check" ]]; then
rm -f "$loop_dir/bg-pending.marker" 2>/dev/null || true
fi
fi
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Allow natural stop while background tasks are in flight by SihaoLiu · Pull Request #91 · PolyArch/humanize · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 363 additions & 0 deletions hooks/lib/loop-bg-tasks.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
#!/usr/bin/env bash
#
# Background-task helpers for the RLCR stop hook.
#
# Owns all logic that inspects the Claude Code transcript to decide
# whether the hook should short-circuit (the main session is still
# waiting on an asynchronous Agent/Bash dispatch), plus the four guard
# blocks that the stop hook runs before its normal gate logic:
#
# 1. Ambiguous-caller marker guard
# 2. Cross-session parked-loop guard
# 3. Early exit: pending background tasks
# 4. Same-session stale-marker cleanup
#
# Depends on loop-common.sh (FIELD_SESSION_ID, resolve_active_state_file)
# being sourced first.
#

# Source guard.
[[ -n "${_LOOP_BG_TASKS_LOADED:-}" ]] && return 0 2>/dev/null || true
_LOOP_BG_TASKS_LOADED=1

# Expand a leading "~" or "~/" in a path to "$HOME" without using eval.
# Only the bare "~" and "~/..." forms are expanded; "~user/..." and every
# other input (absolute path, relative path, empty string) is returned verbatim.
#
# Usage: expand_leading_tilde "$path"
# Prints the normalized path to stdout.
expand_leading_tilde() {
local path="$1"
case "$path" in
'~') printf '%s' "${HOME:-}" ;;
'~/'*) printf '%s/%s' "${HOME:-}" "${path#'~/'}" ;;
*) printf '%s' "$path" ;;
esac
}

# Extract transcript_path from hook JSON input and expand any leading tilde.
# Usage: extract_transcript_path "$json_input"
# Outputs the transcript_path to stdout, or empty string if not available.
extract_transcript_path() {
local input="$1"
local raw
raw=$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null || echo "")
expand_leading_tilde "$raw"
}

# Convert an RLCR loop dir basename to a lexically-comparable ISO-8601
# UTC timestamp suitable for filtering transcript events.
#
# `setup-rlcr-loop.sh` creates loop dirs named `YYYY-MM-DD_HH-MM-SS` in
# the system's LOCAL wall clock (it calls `date +%Y-%m-%d_%H-%M-%S`
# without `-u`). Claude transcript events carry actual UTC timestamps
# like `2026-04-16T13:19:26.819Z`. To compare them correctly, this
# helper converts the local wall-clock parse back to a real UTC moment
# via a two-step: parse local -> epoch seconds -> format in UTC.
#
# The `.000Z` suffix keeps sub-second transcript timestamps in the same
# second compared greater via lexical string ordering.
#
# Usage: derive_loop_start_iso_ts "$loop_dir"
# Prints the ISO-8601 UTC timestamp, or empty string when the
# basename does not match the expected format or the local `date`
# binary cannot parse it.
derive_loop_start_iso_ts() {
local loop_dir="$1"
local base
base=$(basename "$loop_dir" 2>/dev/null || echo "")
if [[ ! "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})$ ]]; then
return
fi
local local_datetime
local_datetime="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}:${BASH_REMATCH[3]}:${BASH_REMATCH[4]}"

# Local wall-clock -> epoch seconds. GNU `date -d` first,
# BSD/macOS `date -j -f ...` second. Both honour the caller's TZ
# for interpretation, matching setup-rlcr-loop.sh's behaviour at
# loop-dir creation time.
local epoch
epoch=$(date -d "$local_datetime" +%s 2>/dev/null) || epoch=""
if [[ -z "$epoch" ]]; then
epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$local_datetime" +%s 2>/dev/null) || epoch=""
fi
if [[ -z "$epoch" ]]; then
return
fi

# Epoch -> UTC ISO-8601. Try GNU then BSD.
local utc_iso
utc_iso=$(date -u -d "@$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
if [[ -z "$utc_iso" ]]; then
utc_iso=$(date -u -r "$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
fi
printf '%s' "$utc_iso"
}

# Enumerate background-task ids that have been launched but not yet marked
# completed in a Claude Code transcript.jsonl.
#
# Launch events (inspected in tool_result "user" messages):
# - Background subagent: toolUseResult.isAsync == true
# -> id is toolUseResult.agentId
# - Background shell: toolUseResult.backgroundTaskId non-empty
# -> id is toolUseResult.backgroundTaskId
#
# Completion events are recognised from two Claude Code transcript forms:
#
# 1. Structured SDK record
# (see SDKTaskNotificationMessage in docs/typescript.md):
# `type == "system"`, `subtype == "task_notification"`,
# `task_id` is the completed id. Any `status` value
# (completed, failed, stopped, ...) is treated as terminal.
#
# 2. Legacy queue-operation enqueue whose `content` embeds a
# `<task-notification>` XML block with `<task-id>...</task-id>`;
# kept for transcripts produced by older Claude Code versions.
#
# pending := launched \ completed
#
# Optional second argument `since_ts` (ISO-8601 string, e.g. the value
# returned by `derive_loop_start_iso_ts`): when provided, only launch
# events whose top-level `.timestamp` field is >= `since_ts` count as
# candidate launches. Events without a `.timestamp` are included (keeps
# fixture transcripts and older record formats working). This keeps
# pre-loop session-wide background work from pinning an RLCR loop that
# has no pending work of its own.
#
# Usage: list_pending_background_task_ids "$transcript_path" [since_ts]
# - Outputs one id per line on stdout (possibly empty).
# - Returns 0 when the transcript is readable (including when there are
# no pending tasks). Returns 1 when the transcript path is empty, not
# a regular file, or jq is unavailable, so callers must treat non-zero
# as "unknown -> do not short-circuit".
list_pending_background_task_ids() {
local transcript_path="$1"
local since_ts="${2:-}"

# Normalize a leading tilde so direct callers (tests, ad-hoc scripts)
# work correctly even when transcript_path was not routed through
# extract_transcript_path.
transcript_path=$(expand_leading_tilde "$transcript_path")

if [[ -z "$transcript_path" ]] || [[ ! -f "$transcript_path" ]]; then
return 1
fi
if ! command -v jq >/dev/null 2>&1; then
return 1
fi

local launched completed
launched=$(jq -r --arg since_ts "$since_ts" '
select(.toolUseResult != null)
| select(
($since_ts == ""
or ((.timestamp // "") == "")
or ((.timestamp // "") >= $since_ts))
)
| select(
(.toolUseResult.isAsync == true and (.toolUseResult.agentId // "") != "")
or ((.toolUseResult.backgroundTaskId // "") != "")
)
| (.toolUseResult.agentId // .toolUseResult.backgroundTaskId)
' "$transcript_path" 2>/dev/null | sort -u) || return 1

# Union of both completion formats. Either source alone is enough to
# mark a launched id terminal.
#
# The `grep -oE || true` guard on the legacy branch keeps `set -o
# pipefail` from poisoning the combined pipeline when no legacy
# queue-operation records exist in the transcript (grep with `-o`
# exits 1 on no matches, which would otherwise wipe out any SDK
# task_notification results collected above).
completed=$(
{
jq -r '
select(.type == "system" and .subtype == "task_notification")
| (.task_id // empty)
' "$transcript_path" 2>/dev/null
jq -r '
select(.type == "queue-operation" and .operation == "enqueue")
| (.content // "" | tostring)
| select(contains("<task-notification>"))
' "$transcript_path" 2>/dev/null \
| { grep -oE '<task-id>[^<]+</task-id>' || true; } \
| sed -E 's|</?task-id>||g'
} | sort -u | sed '/^$/d'
) || completed=""

# Emit launched ids that have no matching completion notification.
comm -23 \
<(printf '%s\n' "$launched" | sed '/^$/d') \
<(printf '%s\n' "$completed" | sed '/^$/d')
}

# Returns 0 when the transcript shows at least one pending background task.
# Returns 1 when no pending tasks are detected (including fail-closed cases
# like missing transcript, non-file path, or jq unavailable).
#
# Usage: has_pending_background_tasks "$transcript_path" [since_ts]
has_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || return 1
[[ -n "$pending" ]]
}

# Prints the count of pending background tasks to stdout. Prints 0 for any
# error case so callers can still format messages safely.
#
# Usage: count_pending_background_tasks "$transcript_path" [since_ts]
count_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || {
echo 0
return 0
}
if [[ -z "$pending" ]]; then
echo 0
else
printf '%s\n' "$pending" | sed '/^$/d' | wc -l | tr -d ' '
fi
}

# Single entry point for the stop hook: runs the four guard blocks
# (ambiguous-caller, cross-session parked, pending-bg short-circuit,
# same-session stale-marker cleanup) in order. When a guard decides to
# short-circuit the stop hook, it emits the appropriate JSON on stdout
# and `exit 0`s directly; the caller (sourcing the hook script) never
# returns. When no guard fires, this function returns 0 and the stop
# hook continues into its normal gate logic.
#
# Depends on FIELD_SESSION_ID and resolve_active_state_file from
# loop-common.sh.
#
# Usage: handle_bg_task_short_circuit "$LOOP_DIR" "$HOOK_INPUT" "$HOOK_SESSION_ID"
handle_bg_task_short_circuit() {
local loop_dir="$1" hook_input="$2" hook_session_id="$3"

# Shared state used by the guard blocks below.
# Loop-start boundary: derived from the loop dir basename
# (`YYYY-MM-DD_HH-MM-SS`). Empty means derivation failed; helpers
# treat empty since_ts as no boundary.
local loop_start_ts transcript_path
loop_start_ts=$(derive_loop_start_iso_ts "$loop_dir")
transcript_path=$(extract_transcript_path "$hook_input")

# ----------------------------------------
# Ambiguous-Caller Marker Guard
# ----------------------------------------
# If a bg-pending.marker is present but we have no session_id on
# this hook invocation (typical of scripts/rlcr-stop-gate.sh
# invoked without --session-id, or any other caller that doesn't
# forward session_id), we cannot tell whether this caller owns the
# parked loop. Taking either branch (foreign-session guard below,
# or same-session cleanup further down) would be wrong in one of
# the two possible realities. Exit 0 silently: the real Claude
# hook will arrive with session_id populated and drive parking /
# cleanup from an authoritative context.
if [[ -f "$loop_dir/bg-pending.marker" ]] && [[ -z "$hook_session_id" ]]; then
exit 0
fi

# ----------------------------------------
# Cross-Session Parked-Loop Guard
# ----------------------------------------
# If find_active_loop handed this dir over via the marker fallback,
# the loop is parked by a different session waiting on a background
# task. The current session has no authority to inspect or advance
# that loop - its transcript sees none of the foreign bg activity -
# so the only safe response is to exit 0 with a distinct
# systemMessage and leave every on-disk artifact (state file,
# stored session_id, marker) untouched.
#
# Both sides of the session-id comparison must be non-empty for
# this branch to trigger: an empty hook_session_id has already
# exited above via the ambiguous-caller guard, and an empty stored
# session_id keeps the backward-compat "matches any" semantics
# from find_active_loop.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local guard_state_file guard_stored_sid
guard_state_file=$(resolve_active_state_file "$loop_dir")
if [[ -n "$guard_state_file" ]]; then
guard_stored_sid=$(sed -n '/^---$/,/^---$/{ /^'"${FIELD_SESSION_ID}"':/{ s/^'"${FIELD_SESSION_ID}"': *//; p; } }' "$guard_state_file" 2>/dev/null | tr -d ' ')
if [[ -n "$guard_stored_sid" ]] \
&& [[ -n "$hook_session_id" ]] \
&& [[ "$guard_stored_sid" != "$hook_session_id" ]]; then
jq -n \
'{systemMessage: "RLCR loop in this repo is parked by another Claude session waiting for background work. Stop allowed; your session leaves the loop untouched. If that session ended, run /humanize:cancel-rlcr-loop to clean up."}'
exit 0
fi
fi
fi

# ----------------------------------------
# Early Exit: Pending Background Tasks
# ----------------------------------------
# When the main Claude Code session has dispatched background work
# (Agent with run_in_background=true, or Bash with
# run_in_background=true) whose completion notifications have not
# yet arrived, the natural "stop" is simply "I am waiting for the
# background task". Running git/summary/BitLesson/Codex gates in
# that state wastes Codex tokens and produces low-signal reviews.
#
# Allow the stop (exit 0) and emit a user-visible systemMessage so
# nobody mistakes the pause for loop completion. The on-disk loop
# state is left untouched -- the next natural stop (after
# background work finishes) will re-enter this hook with no
# pending tasks and run the normal flow.
#
# loop_start_ts confines the transcript scan to launches that
# actually happened during this loop; earlier session-wide bg
# activity cannot pin the loop.
#
# This check MUST run before any other gate (phase detection,
# state parsing, branch / plan / git-clean / summary / max-iter
# checks, Codex review).
local pending_bg_ids
pending_bg_ids=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) || true
if [[ -n "$pending_bg_ids" ]]; then
local pending_bg_count
pending_bg_count=$(printf '%s\n' "$pending_bg_ids" | sed '/^$/d' | wc -l | tr -d ' ')
# Mark the loop as parked; allows the same session to resume
# later and makes the cross-session guard above reachable if
# the user opens a different Claude session in this repo
# before the bg task completes.
: > "$loop_dir/bg-pending.marker" 2>/dev/null || true
jq -n --arg count "$pending_bg_count" \
'{systemMessage: ("RLCR loop active. " + $count + " background task(s) still running - stop allowed naturally; loop has NOT terminated and will resume on completion.")}'
exit 0
fi

# ----------------------------------------
# Same-Session Stale-Marker Cleanup
# ----------------------------------------
# The cross-session guard above already exited for every foreign
# session, so reaching here with the marker present means the
# CURRENT session parked the loop and has now come back with a
# transcript showing no pending bg events. Remove the stale marker
# before the normal flow takes over.
#
# Two-part guard to make sure we never drop the parked-state
# signal without evidence:
# (a) list_pending_background_task_ids returned exit 0 -- the
# transcript was present, readable, AND parsed successfully.
# The helper is fail-closed on missing files, empty paths,
# jq parse failure, and truncation, so a non-zero exit
# blocks cleanup here even when the transcript "file"
# exists.
# (b) its output is empty -- proves "no pending" was
# authoritatively verified, not inferred from a failure.
# The check uses a single fresh call so we capture both the exit
# code and the emptiness without double-running jq.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local pending_bg_check
if pending_bg_check=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) \
&& [[ -z "$pending_bg_check" ]]; then
rm -f "$loop_dir/bg-pending.marker" 2>/dev/null || true
fi
fi
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Allow natural stop while background tasks are in flight by SihaoLiu · Pull Request #91 · PolyArch/humanize · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 363 additions & 0 deletions hooks/lib/loop-bg-tasks.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
#!/usr/bin/env bash
#
# Background-task helpers for the RLCR stop hook.
#
# Owns all logic that inspects the Claude Code transcript to decide
# whether the hook should short-circuit (the main session is still
# waiting on an asynchronous Agent/Bash dispatch), plus the four guard
# blocks that the stop hook runs before its normal gate logic:
#
# 1. Ambiguous-caller marker guard
# 2. Cross-session parked-loop guard
# 3. Early exit: pending background tasks
# 4. Same-session stale-marker cleanup
#
# Depends on loop-common.sh (FIELD_SESSION_ID, resolve_active_state_file)
# being sourced first.
#

# Source guard.
[[ -n "${_LOOP_BG_TASKS_LOADED:-}" ]] && return 0 2>/dev/null || true
_LOOP_BG_TASKS_LOADED=1

# Expand a leading "~" or "~/" in a path to "$HOME" without using eval.
# Only the bare "~" and "~/..." forms are expanded; "~user/..." and every
# other input (absolute path, relative path, empty string) is returned verbatim.
#
# Usage: expand_leading_tilde "$path"
# Prints the normalized path to stdout.
expand_leading_tilde() {
local path="$1"
case "$path" in
'~') printf '%s' "${HOME:-}" ;;
'~/'*) printf '%s/%s' "${HOME:-}" "${path#'~/'}" ;;
*) printf '%s' "$path" ;;
esac
}

# Extract transcript_path from hook JSON input and expand any leading tilde.
# Usage: extract_transcript_path "$json_input"
# Outputs the transcript_path to stdout, or empty string if not available.
extract_transcript_path() {
local input="$1"
local raw
raw=$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null || echo "")
expand_leading_tilde "$raw"
}

# Convert an RLCR loop dir basename to a lexically-comparable ISO-8601
# UTC timestamp suitable for filtering transcript events.
#
# `setup-rlcr-loop.sh` creates loop dirs named `YYYY-MM-DD_HH-MM-SS` in
# the system's LOCAL wall clock (it calls `date +%Y-%m-%d_%H-%M-%S`
# without `-u`). Claude transcript events carry actual UTC timestamps
# like `2026-04-16T13:19:26.819Z`. To compare them correctly, this
# helper converts the local wall-clock parse back to a real UTC moment
# via a two-step: parse local -> epoch seconds -> format in UTC.
#
# The `.000Z` suffix keeps sub-second transcript timestamps in the same
# second compared greater via lexical string ordering.
#
# Usage: derive_loop_start_iso_ts "$loop_dir"
# Prints the ISO-8601 UTC timestamp, or empty string when the
# basename does not match the expected format or the local `date`
# binary cannot parse it.
derive_loop_start_iso_ts() {
local loop_dir="$1"
local base
base=$(basename "$loop_dir" 2>/dev/null || echo "")
if [[ ! "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})$ ]]; then
return
fi
local local_datetime
local_datetime="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}:${BASH_REMATCH[3]}:${BASH_REMATCH[4]}"

# Local wall-clock -> epoch seconds. GNU `date -d` first,
# BSD/macOS `date -j -f ...` second. Both honour the caller's TZ
# for interpretation, matching setup-rlcr-loop.sh's behaviour at
# loop-dir creation time.
local epoch
epoch=$(date -d "$local_datetime" +%s 2>/dev/null) || epoch=""
if [[ -z "$epoch" ]]; then
epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$local_datetime" +%s 2>/dev/null) || epoch=""
fi
if [[ -z "$epoch" ]]; then
return
fi

# Epoch -> UTC ISO-8601. Try GNU then BSD.
local utc_iso
utc_iso=$(date -u -d "@$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
if [[ -z "$utc_iso" ]]; then
utc_iso=$(date -u -r "$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
fi
printf '%s' "$utc_iso"
}

# Enumerate background-task ids that have been launched but not yet marked
# completed in a Claude Code transcript.jsonl.
#
# Launch events (inspected in tool_result "user" messages):
# - Background subagent: toolUseResult.isAsync == true
# -> id is toolUseResult.agentId
# - Background shell: toolUseResult.backgroundTaskId non-empty
# -> id is toolUseResult.backgroundTaskId
#
# Completion events are recognised from two Claude Code transcript forms:
#
# 1. Structured SDK record
# (see SDKTaskNotificationMessage in docs/typescript.md):
# `type == "system"`, `subtype == "task_notification"`,
# `task_id` is the completed id. Any `status` value
# (completed, failed, stopped, ...) is treated as terminal.
#
# 2. Legacy queue-operation enqueue whose `content` embeds a
# `<task-notification>` XML block with `<task-id>...</task-id>`;
# kept for transcripts produced by older Claude Code versions.
#
# pending := launched \ completed
#
# Optional second argument `since_ts` (ISO-8601 string, e.g. the value
# returned by `derive_loop_start_iso_ts`): when provided, only launch
# events whose top-level `.timestamp` field is >= `since_ts` count as
# candidate launches. Events without a `.timestamp` are included (keeps
# fixture transcripts and older record formats working). This keeps
# pre-loop session-wide background work from pinning an RLCR loop that
# has no pending work of its own.
#
# Usage: list_pending_background_task_ids "$transcript_path" [since_ts]
# - Outputs one id per line on stdout (possibly empty).
# - Returns 0 when the transcript is readable (including when there are
# no pending tasks). Returns 1 when the transcript path is empty, not
# a regular file, or jq is unavailable, so callers must treat non-zero
# as "unknown -> do not short-circuit".
list_pending_background_task_ids() {
local transcript_path="$1"
local since_ts="${2:-}"

# Normalize a leading tilde so direct callers (tests, ad-hoc scripts)
# work correctly even when transcript_path was not routed through
# extract_transcript_path.
transcript_path=$(expand_leading_tilde "$transcript_path")

if [[ -z "$transcript_path" ]] || [[ ! -f "$transcript_path" ]]; then
return 1
fi
if ! command -v jq >/dev/null 2>&1; then
return 1
fi

local launched completed
launched=$(jq -r --arg since_ts "$since_ts" '
select(.toolUseResult != null)
| select(
($since_ts == ""
or ((.timestamp // "") == "")
or ((.timestamp // "") >= $since_ts))
)
| select(
(.toolUseResult.isAsync == true and (.toolUseResult.agentId // "") != "")
or ((.toolUseResult.backgroundTaskId // "") != "")
)
| (.toolUseResult.agentId // .toolUseResult.backgroundTaskId)
' "$transcript_path" 2>/dev/null | sort -u) || return 1

# Union of both completion formats. Either source alone is enough to
# mark a launched id terminal.
#
# The `grep -oE || true` guard on the legacy branch keeps `set -o
# pipefail` from poisoning the combined pipeline when no legacy
# queue-operation records exist in the transcript (grep with `-o`
# exits 1 on no matches, which would otherwise wipe out any SDK
# task_notification results collected above).
completed=$(
{
jq -r '
select(.type == "system" and .subtype == "task_notification")
| (.task_id // empty)
' "$transcript_path" 2>/dev/null
jq -r '
select(.type == "queue-operation" and .operation == "enqueue")
| (.content // "" | tostring)
| select(contains("<task-notification>"))
' "$transcript_path" 2>/dev/null \
| { grep -oE '<task-id>[^<]+</task-id>' || true; } \
| sed -E 's|</?task-id>||g'
} | sort -u | sed '/^$/d'
) || completed=""

# Emit launched ids that have no matching completion notification.
comm -23 \
<(printf '%s\n' "$launched" | sed '/^$/d') \
<(printf '%s\n' "$completed" | sed '/^$/d')
}

# Returns 0 when the transcript shows at least one pending background task.
# Returns 1 when no pending tasks are detected (including fail-closed cases
# like missing transcript, non-file path, or jq unavailable).
#
# Usage: has_pending_background_tasks "$transcript_path" [since_ts]
has_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || return 1
[[ -n "$pending" ]]
}

# Prints the count of pending background tasks to stdout. Prints 0 for any
# error case so callers can still format messages safely.
#
# Usage: count_pending_background_tasks "$transcript_path" [since_ts]
count_pending_background_tasks() {
local transcript_path="$1"
local since_ts="${2:-}"
local pending
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || {
echo 0
return 0
}
if [[ -z "$pending" ]]; then
echo 0
else
printf '%s\n' "$pending" | sed '/^$/d' | wc -l | tr -d ' '
fi
}

# Single entry point for the stop hook: runs the four guard blocks
# (ambiguous-caller, cross-session parked, pending-bg short-circuit,
# same-session stale-marker cleanup) in order. When a guard decides to
# short-circuit the stop hook, it emits the appropriate JSON on stdout
# and `exit 0`s directly; the caller (sourcing the hook script) never
# returns. When no guard fires, this function returns 0 and the stop
# hook continues into its normal gate logic.
#
# Depends on FIELD_SESSION_ID and resolve_active_state_file from
# loop-common.sh.
#
# Usage: handle_bg_task_short_circuit "$LOOP_DIR" "$HOOK_INPUT" "$HOOK_SESSION_ID"
handle_bg_task_short_circuit() {
local loop_dir="$1" hook_input="$2" hook_session_id="$3"

# Shared state used by the guard blocks below.
# Loop-start boundary: derived from the loop dir basename
# (`YYYY-MM-DD_HH-MM-SS`). Empty means derivation failed; helpers
# treat empty since_ts as no boundary.
local loop_start_ts transcript_path
loop_start_ts=$(derive_loop_start_iso_ts "$loop_dir")
transcript_path=$(extract_transcript_path "$hook_input")

# ----------------------------------------
# Ambiguous-Caller Marker Guard
# ----------------------------------------
# If a bg-pending.marker is present but we have no session_id on
# this hook invocation (typical of scripts/rlcr-stop-gate.sh
# invoked without --session-id, or any other caller that doesn't
# forward session_id), we cannot tell whether this caller owns the
# parked loop. Taking either branch (foreign-session guard below,
# or same-session cleanup further down) would be wrong in one of
# the two possible realities. Exit 0 silently: the real Claude
# hook will arrive with session_id populated and drive parking /
# cleanup from an authoritative context.
if [[ -f "$loop_dir/bg-pending.marker" ]] && [[ -z "$hook_session_id" ]]; then
exit 0
fi

# ----------------------------------------
# Cross-Session Parked-Loop Guard
# ----------------------------------------
# If find_active_loop handed this dir over via the marker fallback,
# the loop is parked by a different session waiting on a background
# task. The current session has no authority to inspect or advance
# that loop - its transcript sees none of the foreign bg activity -
# so the only safe response is to exit 0 with a distinct
# systemMessage and leave every on-disk artifact (state file,
# stored session_id, marker) untouched.
#
# Both sides of the session-id comparison must be non-empty for
# this branch to trigger: an empty hook_session_id has already
# exited above via the ambiguous-caller guard, and an empty stored
# session_id keeps the backward-compat "matches any" semantics
# from find_active_loop.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local guard_state_file guard_stored_sid
guard_state_file=$(resolve_active_state_file "$loop_dir")
if [[ -n "$guard_state_file" ]]; then
guard_stored_sid=$(sed -n '/^---$/,/^---$/{ /^'"${FIELD_SESSION_ID}"':/{ s/^'"${FIELD_SESSION_ID}"': *//; p; } }' "$guard_state_file" 2>/dev/null | tr -d ' ')
if [[ -n "$guard_stored_sid" ]] \
&& [[ -n "$hook_session_id" ]] \
&& [[ "$guard_stored_sid" != "$hook_session_id" ]]; then
jq -n \
'{systemMessage: "RLCR loop in this repo is parked by another Claude session waiting for background work. Stop allowed; your session leaves the loop untouched. If that session ended, run /humanize:cancel-rlcr-loop to clean up."}'
exit 0
fi
fi
fi

# ----------------------------------------
# Early Exit: Pending Background Tasks
# ----------------------------------------
# When the main Claude Code session has dispatched background work
# (Agent with run_in_background=true, or Bash with
# run_in_background=true) whose completion notifications have not
# yet arrived, the natural "stop" is simply "I am waiting for the
# background task". Running git/summary/BitLesson/Codex gates in
# that state wastes Codex tokens and produces low-signal reviews.
#
# Allow the stop (exit 0) and emit a user-visible systemMessage so
# nobody mistakes the pause for loop completion. The on-disk loop
# state is left untouched -- the next natural stop (after
# background work finishes) will re-enter this hook with no
# pending tasks and run the normal flow.
#
# loop_start_ts confines the transcript scan to launches that
# actually happened during this loop; earlier session-wide bg
# activity cannot pin the loop.
#
# This check MUST run before any other gate (phase detection,
# state parsing, branch / plan / git-clean / summary / max-iter
# checks, Codex review).
local pending_bg_ids
pending_bg_ids=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) || true
if [[ -n "$pending_bg_ids" ]]; then
local pending_bg_count
pending_bg_count=$(printf '%s\n' "$pending_bg_ids" | sed '/^$/d' | wc -l | tr -d ' ')
# Mark the loop as parked; allows the same session to resume
# later and makes the cross-session guard above reachable if
# the user opens a different Claude session in this repo
# before the bg task completes.
: > "$loop_dir/bg-pending.marker" 2>/dev/null || true
jq -n --arg count "$pending_bg_count" \
'{systemMessage: ("RLCR loop active. " + $count + " background task(s) still running - stop allowed naturally; loop has NOT terminated and will resume on completion.")}'
exit 0
fi

# ----------------------------------------
# Same-Session Stale-Marker Cleanup
# ----------------------------------------
# The cross-session guard above already exited for every foreign
# session, so reaching here with the marker present means the
# CURRENT session parked the loop and has now come back with a
# transcript showing no pending bg events. Remove the stale marker
# before the normal flow takes over.
#
# Two-part guard to make sure we never drop the parked-state
# signal without evidence:
# (a) list_pending_background_task_ids returned exit 0 -- the
# transcript was present, readable, AND parsed successfully.
# The helper is fail-closed on missing files, empty paths,
# jq parse failure, and truncation, so a non-zero exit
# blocks cleanup here even when the transcript "file"
# exists.
# (b) its output is empty -- proves "no pending" was
# authoritatively verified, not inferred from a failure.
# The check uses a single fresh call so we capture both the exit
# code and the emptiness without double-running jq.
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
local pending_bg_check
if pending_bg_check=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) \
&& [[ -z "$pending_bg_check" ]]; then
rm -f "$loop_dir/bg-pending.marker" 2>/dev/null || true
fi
fi
}
Loading
Loading