Files
oh-my-claudecode/scripts/lib/hud-cache-wrapper.sh
snowlaxc 34c6d7ca17 fix(team): handle Claude Code v2.1.x banner and Enter swallow stalls
Claude Code v2.1.x ships two TUI changes that hang OMC team workflows:

1. A persistent "⏵⏵ bypass permissions on (shift+tab to cycle)" mode
   indicator that renders below the prompt on idle panes — previously
   used as a startup signal.
2. The TUI swallows a single Enter sent during state transitions while
   input handlers are still binding, so a freshly-typed message lands
   in the input buffer but is never submitted.

Reported symptoms:
- omc team N:claude workers hang in "[OMC] Starting..." forever.
- Dispatched messages appear in the worker input but never submit.
- New Claude Code sessions show "[OMC] Starting..." stuck on the
  statusLine until the user types a key, because v2.1.x does not
  re-poll the statusLine until input.

Detection + mitigation across three runtime paths and the HUD wrapper:
- paneHasClaudeStartupBanner: if a prompt is visible anywhere in the
  tail, treat the banner as the idle mode indicator, not bootstrap.
- runtime-v2 spawn: extend the bounded Enter-resubmit retry from 3 to
  4 attempts and evidence wait from 6 to 12 seconds, riding through a
  longer TUI bind window.
- defaultInjector: always send 2 Enters (was claude:1 / codex:2) so
  the dispatch path mirrors runtime-v2 swallow mitigation. Drop the
  now-unused resolveWorkerCliForRequest helper.
- hud-cache-wrapper.sh: when no cached HUD exists but stdin is
  available, refresh synchronously and emit the rendered line on the
  first frame; keep the "[OMC] Starting..." placeholder only for the
  no-stdin path.

Tests:
- src/team/__tests__/tmux-session.test.ts: v2.1.142 capture fixtures
  for idle pane (banner below prompt), mid-task pane (spinner +
  paneHasActiveTask), and banner-only pre-prompt.
- src/installer/__tests__/hud-cache-wrapper.test.ts: first-render
  returns the real HUD when stdin is present; placeholder path
  remains for the no-stdin case.

Constraint: Claude Code v2.1.x rendering and input-handler binding are
external behavior we cannot influence; fixes are detection and timing
accommodations.

Rejected: Bumping the dispatch timeout | timeout was never the
bottleneck — the wrapper and injector were producing the wrong output
deterministically. Raising it would mask the issue and worsen the
first-frame statusLine UX.

Confidence: high

Scope-risk: moderate

Directive: Future edits to paneHasClaudeStartupBanner must keep the
prompt-presence short-circuit; v2.1.x idle panes rely on it to avoid
the same false-positive recurring under a different banner string.

Tested: npm run test:run -- src/team/__tests__/tmux-session.test.ts
(34/34); npm run test:run -- src/installer/__tests__/hud-cache-wrapper.test.ts
(8/8); manual omc team N:claude dispatch on Claude Code v2.1.142;
manual new-session statusLine first-frame check.

Not-tested: Behavior on Claude Code <2.1 (no permanent mode
indicator) — the banner-without-prompt branch remains, so the previous
detection is preserved by construction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:09:26 +00:00

182 lines
5.8 KiB
Bash
Executable File

#!/bin/sh
# OMC HUD cached statusLine launcher.
#
# Claude Code invokes statusLine commands for every render. Starting Node and
# importing the HUD bundle each time can take hundreds of milliseconds, which
# makes the first frame blank/flickery. This POSIX wrapper keeps the statusLine
# protocol unchanged (stdin JSON in, one line out) while making the hot path a
# shell read + cat of the last rendered line. A single background Node refresh
# updates the session-scoped cache for the next frame.
case "$0" in
*/*) SCRIPT_DIR=${0%/*} ;;
*) SCRIPT_DIR=. ;;
esac
SCRIPT_DIR=$(cd "$SCRIPT_DIR" 2>/dev/null && pwd -P) || SCRIPT_DIR=.
CONFIG_DIR=${CLAUDE_CONFIG_DIR:-$(cd "$SCRIPT_DIR/.." 2>/dev/null && pwd -P)}
CACHE_DIR=${OMC_HUD_CACHE_DIR:-"$CONFIG_DIR/hud/cache"}
HUD_SCRIPT=${1:-"$SCRIPT_DIR/omc-hud.mjs"}
INPUT_TMP="$CACHE_DIR/stdin.$$.tmp"
LOCK_STALE_SECONDS=${OMC_HUD_LOCK_STALE_SECONDS:-10}
mkdir -p "$CACHE_DIR" 2>/dev/null || {
printf '[OMC] Starting...\n'
exit 0
}
CACHE_DIR=$(cd "$CACHE_DIR" 2>/dev/null && pwd -P) || {
printf '[OMC] Starting...\n'
exit 0
}
INPUT_TMP="$CACHE_DIR/stdin.$$.tmp"
file_mtime() {
(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null) | head -1
}
is_stale_path() {
path=$1
now=$(date +%s 2>/dev/null || printf '0')
path_mtime=$(file_mtime "$path")
[ -n "$path_mtime" ] || return 1
[ "$now" -gt 0 ] || return 1
[ $((now - path_mtime)) -gt "$LOCK_STALE_SECONDS" ] || return 1
}
cleanup_empty_temp_files() {
for temp_path in "$CACHE_DIR"/stdin.*.tmp "$CACHE_DIR"/statusline.*.tmp "$CACHE_DIR"/statusline.*.err; do
[ -f "$temp_path" ] || continue
[ -s "$temp_path" ] && continue
is_stale_path "$temp_path" || continue
rm -f "$temp_path" 2>/dev/null || :
done
}
cleanup_stale_render_locks() {
for stale_lock_dir in "$CACHE_DIR"/render.*.lock; do
[ -d "$stale_lock_dir" ] || continue
is_stale_path "$stale_lock_dir" || continue
rm -rf "$stale_lock_dir" 2>/dev/null || :
done
}
cleanup_empty_temp_files
cleanup_stale_render_locks
# Capture Claude's current statusLine stdin first so rendered output can be
# scoped per session/worktree instead of leaking across concurrent sessions.
cat > "$INPUT_TMP" 2>/dev/null || :
extract_json_string() {
key=$1
sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$INPUT_TMP" 2>/dev/null | head -1
}
SESSION_KEY=$(extract_json_string session_id)
if [ -z "$SESSION_KEY" ] && [ -n "${CLAUDE_SESSION_ID:-}" ]; then
SESSION_KEY=$CLAUDE_SESSION_ID
fi
if [ -z "$SESSION_KEY" ] && [ -n "${CLAUDECODE_SESSION_ID:-}" ]; then
SESSION_KEY=$CLAUDECODE_SESSION_ID
fi
TRANSCRIPT_PATH=$(extract_json_string transcript_path)
if [ -z "$SESSION_KEY" ] && [ -n "$TRANSCRIPT_PATH" ]; then
SESSION_KEY=$(printf '%s\n' "$TRANSCRIPT_PATH" | sed -n 's/.*\([0-9a-fA-F][0-9a-fA-F-]\{35\}\).*/\1/p' | head -1)
if [ -z "$SESSION_KEY" ]; then
SESSION_KEY=$(printf '%s\n' "$TRANSCRIPT_PATH" | cksum 2>/dev/null | awk '{print "transcript-" $1}')
fi
fi
if [ -z "$SESSION_KEY" ]; then
CWD_VALUE=$(extract_json_string cwd)
if [ -n "$CWD_VALUE" ]; then
SESSION_KEY=$(printf '%s\n' "$CWD_VALUE" | cksum 2>/dev/null | awk '{print "cwd-" $1}')
fi
fi
if [ -z "$SESSION_KEY" ]; then
SESSION_KEY=default
fi
SESSION_KEY=$(printf '%s' "$SESSION_KEY" | sed 's/[^A-Za-z0-9_.-]/_/g')
INPUT_FILE="$CACHE_DIR/stdin.$SESSION_KEY.json"
OUTPUT_FILE="$CACHE_DIR/statusline.$SESSION_KEY.txt"
LOCK_DIR="$CACHE_DIR/render.$SESSION_KEY.lock"
NODE_STDOUT_TMP="$CACHE_DIR/statusline.$SESSION_KEY.$$.tmp"
NODE_STDERR_TMP="$CACHE_DIR/statusline.$SESSION_KEY.$$.err"
if [ -s "$INPUT_TMP" ]; then
mv "$INPUT_TMP" "$INPUT_FILE" 2>/dev/null || cp "$INPUT_TMP" "$INPUT_FILE" 2>/dev/null || :
fi
rm -f "$INPUT_TMP" 2>/dev/null || :
try_acquire_lock() {
if mkdir "$LOCK_DIR" 2>/dev/null; then
return 0
fi
if [ ! -d "$LOCK_DIR" ]; then
return 1
fi
is_stale_path "$LOCK_DIR" || return 1
rm -rf "$LOCK_DIR" 2>/dev/null || :
mkdir "$LOCK_DIR" 2>/dev/null
}
refresh_cache() {
cleanup_refresh_artifacts() {
rm -f "$NODE_STDOUT_TMP" 2>/dev/null || :
if [ ! -s "$NODE_STDERR_TMP" ]; then
rm -f "$NODE_STDERR_TMP" 2>/dev/null || :
fi
rm -rf "$LOCK_DIR" 2>/dev/null || :
}
trap 'cleanup_refresh_artifacts' EXIT
trap 'cleanup_refresh_artifacts; exit 0' HUP INT TERM
if [ ! -s "$INPUT_FILE" ]; then
cleanup_refresh_artifacts
return
fi
if [ -x "$SCRIPT_DIR/find-node.sh" ]; then
sh "$SCRIPT_DIR/find-node.sh" "$HUD_SCRIPT" < "$INPUT_FILE" > "$NODE_STDOUT_TMP" 2> "$NODE_STDERR_TMP"
else
node "$HUD_SCRIPT" < "$INPUT_FILE" > "$NODE_STDOUT_TMP" 2> "$NODE_STDERR_TMP"
fi
# Keep the last good line if rendering fails or returns empty output.
if [ -s "$NODE_STDOUT_TMP" ]; then
mv "$NODE_STDOUT_TMP" "$OUTPUT_FILE" 2>/dev/null || cp "$NODE_STDOUT_TMP" "$OUTPUT_FILE" 2>/dev/null || :
fi
rm -f "$NODE_STDOUT_TMP" "$NODE_STDERR_TMP" 2>/dev/null || :
rm -rf "$LOCK_DIR" 2>/dev/null || :
trap - EXIT HUP INT TERM
}
# Hot path: return immediately from the last successful render for this session.
if [ -s "$OUTPUT_FILE" ]; then
cat "$OUTPUT_FILE" 2>/dev/null || printf '[OMC] Starting...\n'
# Refresh in background for the next frame.
if try_acquire_lock; then
if [ "${OMC_HUD_SYNC_REFRESH:-0}" = "1" ]; then
refresh_cache
else
( refresh_cache ) >/dev/null 2>&1 &
fi
fi
exit 0
fi
# First render for this session: do a synchronous refresh so the user
# sees the real HUD from the first frame. Claude Code v2.1.x does not
# re-poll the statusLine until user interaction, so an async background
# refresh leaves the pane stuck on "[OMC] Starting..." until they type.
if [ -s "$INPUT_FILE" ] && try_acquire_lock; then
refresh_cache
if [ -s "$OUTPUT_FILE" ]; then
cat "$OUTPUT_FILE" 2>/dev/null && exit 0
fi
fi
printf '[OMC] Starting...\n'
exit 0