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
112 changes: 107 additions & 5 deletions plugin/scripts/_lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,124 @@ claude_smart_prepend_astral_bins() {
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
}

# Return 0 (true) if running under a Windows-flavoured bash (Git Bash,
# MSYS, Cygwin). Used to gate POSIX-only primitives (setsid, process
# groups) and route around Windows-specific potholes (the python3 App
# Execution Alias stub at WindowsApps\python3.exe).
claude_smart_is_windows() {
case "$(uname -s 2>/dev/null)" in
MINGW*|MSYS*|CYGWIN*) return 0 ;;
*) return 1 ;;
esac
}
Comment on lines +24 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Route Cygwin through the POSIX code path, not Windows.

CYGWIN* fully supports POSIX setsid, process groups, and signal-based termination. Routing it through MINGW*|MSYS* branches in claude_smart_spawn_detached() and claude_smart_kill_tree() breaks this: the Windows branch uses taskkill, a Windows-native tool that cannot reliably terminate POSIX Cygwin processes, and it loses the graceful TERM→KILL semantics. Narrow claude_smart_is_windows to MINGW*|MSYS* only.

Suggested change
 claude_smart_is_windows() {
   case "$(uname -s 2>/dev/null)" in
-    MINGW*|MSYS*|CYGWIN*) return 0 ;;
+    MINGW*|MSYS*) return 0 ;;
     *) return 1 ;;
   esac
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Return 0 (true) if running under a Windows-flavoured bash (Git Bash,
# MSYS, Cygwin). Used to gate POSIX-only primitives (setsid, process
# groups) and route around Windows-specific potholes (the python3 App
# Execution Alias stub at WindowsApps\python3.exe).
claude_smart_is_windows() {
case "$(uname -s 2>/dev/null)" in
MINGW*|MSYS*|CYGWIN*) return 0 ;;
*) return 1 ;;
esac
}
# Return 0 (true) if running under a Windows-flavoured bash (Git Bash,
# MSYS, Cygwin). Used to gate POSIX-only primitives (setsid, process
# groups) and route around Windows-specific potholes (the python3 App
# Execution Alias stub at WindowsApps\python3.exe).
claude_smart_is_windows() {
case "$(uname -s 2>/dev/null)" in
MINGW*|MSYS*) return 0 ;;
*) return 1 ;;
esac
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugin/scripts/_lib.sh` around lines 24 - 33, The claude_smart_is_windows()
helper incorrectly treats CYGWIN as Windows; narrow its case pattern to only
MINGW* and MSYS* so CYGWIN falls through to the POSIX path—update the case in
claude_smart_is_windows() to match only MINGW*|MSYS* and leave CYGWIN* out so
callers like claude_smart_spawn_detached() and claude_smart_kill_tree() will use
the POSIX behavior (setsid/process groups and TERM→KILL semantics) instead of
taskkill.


# Print the absolute path of a working python interpreter, or nothing
# (and return non-zero) if none is usable. On Windows, `python3` is
# usually the Microsoft Store "App Execution Alias" stub at
# %LocalAppData%\Microsoft\WindowsApps\python3.exe — `command -v python3`
# returns truthy but invoking it just prints a "Python was not found"
# message and exits non-zero. We probe with `-V` to filter the stub out
# and prefer `python` (the real interpreter when one is installed).
claude_smart_resolve_python() {
if claude_smart_is_windows; then
for cand in python python3; do
if command -v "$cand" >/dev/null 2>&1 && "$cand" -V >/dev/null 2>&1; then
command -v "$cand"
return 0
fi
done
return 1
fi
for cand in python3 python; do
if command -v "$cand" >/dev/null 2>&1 && "$cand" -V >/dev/null 2>&1; then
command -v "$cand"
return 0
fi
done
return 1
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Spawn a command fully detached from the current shell so a hook timeout
# (Claude Code's install/SessionStart budget) cannot kill it mid-flight.
# Picks the strongest available primitive: setsid → python3 os.setsid → nohup.
# Caller is responsible for redirecting stdout/stderr; we do not impose a
# log destination here. Stdin is closed so the child cannot inherit a tty.
# POSIX: setsid → python3 os.setsid → nohup (in that order of strength).
# Windows: nohup alone — Git Bash has no setsid, no process groups, and
# `os.setsid()` is POSIX-only; nohup ignores SIGHUP which is enough to
# survive the parent console closing. The python3 fallback is gated on a
# real-interpreter probe (-V) so the Windows App Execution Alias stub
# doesn't get invoked. Caller is responsible for redirecting stdout/stderr;
# we do not impose a log destination here. Stdin is closed so the child
# cannot inherit a tty. Use `$!` after this call to capture the pid.
claude_smart_spawn_detached() {
if claude_smart_is_windows; then
nohup "$@" < /dev/null &
return 0
fi
if command -v setsid >/dev/null 2>&1; then
setsid nohup "$@" < /dev/null &
elif command -v python3 >/dev/null 2>&1; then
python3 -c 'import os,sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])' \
elif _CS_PY=$(claude_smart_resolve_python) && [ -n "$_CS_PY" ]; then
"$_CS_PY" -c 'import os,sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])' \
"$@" < /dev/null &
else
nohup "$@" < /dev/null &
fi
}

# Terminate a process and (on POSIX) its whole process group, escalating
# from TERM to KILL after a short grace period. On Windows there are no
# POSIX process groups, so we use `taskkill /T /F /PID` which walks the
# child-process tree via the Windows job-object/parent-pid relationships
# — the closest equivalent to a group kill.
#
# Windows-specific subtlety: in Git Bash / MSYS, `$!` for a backgrounded
# job returns the MSYS pid (an internal counter), NOT the native Windows
# pid that taskkill needs. `ps -W` (or `-o winpid=`) exposes the WINPID
# column for the translation. If the lookup fails we fall back to
# treating the input as a native pid, so callers can pass either an MSYS
# pid (recorded via $!) or a Windows pid (from tasklist) interchangeably.
# The `//T //F //PID` syntax escapes Git Bash's MSYS path-mangling of
# arguments that begin with `/`.
claude_smart_kill_tree() {
pid="$1"
[ -z "$pid" ] && return 0
if claude_smart_is_windows; then
# Git Bash's `ps` is the procps fork, not BSD/Linux ps; it has no
# -o option but its default header is `PID PPID PGID WINPID TTY ...`,
# so column 4 of the data row is the Windows pid. awk extracts it
# without depending on -o support.
target=""
if command -v ps >/dev/null 2>&1; then
target=$(ps -p "$pid" 2>/dev/null | awk 'NR==2 {print $4}' | tr -d ' \r\n' || true)
fi
[ -z "$target" ] && target="$pid"
if command -v taskkill >/dev/null 2>&1; then
taskkill //T //F //PID "$target" >/dev/null 2>&1 || true
else
kill -TERM "$pid" 2>/dev/null || true
sleep 0.5
kill -KILL "$pid" 2>/dev/null || true
fi
return 0
fi
current_pgid=""
if command -v ps >/dev/null 2>&1; then
current_pgid=$(ps -o pgid= -p "$$" 2>/dev/null | tr -d ' ')
fi
if [ -n "$current_pgid" ] && [ "$pid" = "$current_pgid" ]; then
return 0
fi
if ! kill -TERM -- "-$pid" 2>/dev/null; then
kill -TERM "$pid" 2>/dev/null || true
sleep 0.5
kill -KILL "$pid" 2>/dev/null || true
return 0
fi
for _ in 1 2 3 4 5; do
kill -0 -- "-$pid" 2>/dev/null || return 0
sleep 0.2
done
kill -KILL -- "-$pid" 2>/dev/null || true
}

# Return 0 (true) if $1 names a pid file whose pid is currently alive.
# Silent on missing/empty/stale files.
claude_smart_pid_alive_file() {
Expand Down
52 changes: 18 additions & 34 deletions plugin/scripts/backend-service.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,10 @@ mkdir -p "$STATE_DIR"

emit_ok() { echo '{"continue":true,"suppressOutput":true}'; }

# Kill a process group started via setsid. Same pattern as
# dashboard-service.sh: SIGTERM, short grace, SIGKILL. Silent on failure.
# Tree-kill the recorded process. Delegates to claude_smart_kill_tree
# (POSIX: signal the process group; Windows: taskkill /T /F /PID).
kill_group() {
pgid="$1"
[ -z "$pgid" ] && return 0
kill -TERM -- "-$pgid" 2>/dev/null || true
for _ in 1 2 3 4 5; do
kill -0 -- "-$pgid" 2>/dev/null || return 0
sleep 0.2
done
kill -KILL -- "-$pgid" 2>/dev/null || true
claude_smart_kill_tree "$1"
}

# True if /health returns 200. Reflexio's /health is a plain GET with no
Expand Down Expand Up @@ -165,31 +158,22 @@ case "$CMD" in
export INTERACTION_CLEANUP_THRESHOLD="${INTERACTION_CLEANUP_THRESHOLD:-500}"
export INTERACTION_CLEANUP_DELETE_COUNT="${INTERACTION_CLEANUP_DELETE_COUNT:-200}"

# --no-reload: uvicorn's reloader forks a supervisor; makes PGID
# --no-reload: uvicorn's reloader forks a supervisor; makes
# bookkeeping harder and we don't need hot-reload for a user-facing
# service. Same detach pattern as dashboard-service.sh.
if command -v setsid >/dev/null 2>&1; then
setsid nohup uv run --project "$PLUGIN_ROOT" --quiet \
reflexio services start --only backend --no-reload \
>>"$LOG_FILE" 2>&1 < /dev/null &
echo $! > "$PID_FILE"
elif command -v python3 >/dev/null 2>&1; then
python3 -c 'import os,sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])' \
uv run --project "$PLUGIN_ROOT" --quiet \
reflexio services start --only backend --no-reload \
>>"$LOG_FILE" 2>&1 < /dev/null &
echo $! > "$PID_FILE"
else
nohup uv run --project "$PLUGIN_ROOT" --quiet \
reflexio services start --only backend --no-reload \
>>"$LOG_FILE" 2>&1 < /dev/null &
svc_pid=$!
actual_pgid=""
if command -v ps >/dev/null 2>&1; then
actual_pgid=$(ps -o pgid= -p "$svc_pid" 2>/dev/null | tr -d ' ')
fi
echo "${actual_pgid:-$svc_pid}" > "$PID_FILE"
fi
# service. Detach via claude_smart_spawn_detached so the same code
# path covers Linux (setsid), macOS (python3 os.setsid), and Windows
# (nohup; no process groups). Caller-side stdout/stderr redirection
# works across all three primitives — Git Bash routes the > and 2>&1
# through to the underlying CRT before nohup execs the child.
claude_smart_spawn_detached uv run --project "$PLUGIN_ROOT" --quiet \
reflexio services start --only backend --no-reload \
>>"$LOG_FILE" 2>&1
svc_pid=$!
# Record the spawned pid, not a pgid sampled with ps. On POSIX,
# setsid/python os.setsid make this pid the new process group leader;
# sampling immediately can race and capture the caller's pgid instead.
# On Windows, claude_smart_kill_tree translates the MSYS pid to WINPID.
echo "$svc_pid" > "$PID_FILE"

# Give uvicorn up to ~10s to answer /health. The very first boot
# after a fresh checkout may be slower (LiteLLM import, chromadb
Expand Down
51 changes: 17 additions & 34 deletions plugin/scripts/dashboard-service.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,10 @@ mkdir -p "$STATE_DIR"

emit_ok() { echo '{"continue":true,"suppressOutput":true}'; }

# Kill a process group started via setsid. Sends SIGTERM, waits briefly,
# then SIGKILL. Silent on failure — the PID file may point at a process
# that already exited.
# Tree-kill the recorded process. Delegates to claude_smart_kill_tree
# (POSIX: signal the process group; Windows: taskkill /T /F /PID).
kill_group() {
pgid="$1"
[ -z "$pgid" ] && return 0
kill -TERM -- "-$pgid" 2>/dev/null || true
for _ in 1 2 3 4 5; do
kill -0 -- "-$pgid" 2>/dev/null || return 0
sleep 0.2
done
kill -KILL -- "-$pgid" 2>/dev/null || true
claude_smart_kill_tree "$1"
}

# True if the marker header served by app/api/health is present on the
Expand Down Expand Up @@ -126,29 +118,20 @@ case "$CMD" in

cd "$DASHBOARD_DIR"

# Detach so the hook returns immediately, and put the child in its own
# session so kill_group can signal the whole tree via a negative PID.
# - Linux: setsid is standard.
# - macOS: setsid is not installed; use python3 (ships with the OS)
# to call os.setsid() before execing npm, which makes the child
# session/group leader with PID==PGID.
# - Fallback: bare nohup, then derive the real PGID via ps -o pgid.
if command -v setsid >/dev/null 2>&1; then
setsid nohup npm run start >>"$LOG_FILE" 2>&1 < /dev/null &
echo $! > "$PID_FILE"
elif command -v python3 >/dev/null 2>&1; then
python3 -c 'import os,sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])' \
npm run start >>"$LOG_FILE" 2>&1 < /dev/null &
echo $! > "$PID_FILE"
else
nohup npm run start >>"$LOG_FILE" 2>&1 < /dev/null &
dash_pid=$!
actual_pgid=""
if command -v ps >/dev/null 2>&1; then
actual_pgid=$(ps -o pgid= -p "$dash_pid" 2>/dev/null | tr -d ' ')
fi
echo "${actual_pgid:-$dash_pid}" > "$PID_FILE"
fi
# Detach so the hook returns immediately. claude_smart_spawn_detached
# picks the strongest primitive available:
# - Linux: setsid (puts child in its own session/group, pid==pgid).
# - macOS: python3 os.setsid + execvp (same effect as setsid).
# - Windows: nohup alone (no process groups; tree-kill via taskkill).
# Caller-side `>>file 2>&1` redirection is honoured before the child
# detaches, so per-OS log paths stay identical.
claude_smart_spawn_detached npm run start >>"$LOG_FILE" 2>&1
dash_pid=$!
# Record the spawned pid, not a pgid sampled with ps. On POSIX,
# setsid/python os.setsid make this pid the new process group leader;
# sampling immediately can race and capture the caller's pgid instead.
# On Windows, claude_smart_kill_tree translates the MSYS pid to WINPID.
echo "$dash_pid" > "$PID_FILE"
emit_ok
;;
stop)
Expand Down
30 changes: 26 additions & 4 deletions plugin/scripts/smart-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,29 @@ fi

if ! command -v uv >/dev/null 2>&1; then
echo "[claude-smart] uv not found — installing from astral.sh..." >&2
if ! curl -LsSf https://astral.sh/uv/install.sh | sh >&2; then
write_failure "uv install failed — install manually from https://docs.astral.sh/uv/"
# The astral.sh bash installer downloads a zip and unzips it. On
# Windows-flavoured bash (Git Bash / MSYS) the bundled `unzip` corrupts
# the Windows uv binary (bad CRC on the inflated uv.exe), leaving the
# install half-finished. Use the official PowerShell installer
# (install.ps1) on Windows, which writes uv.exe to ~/.local/bin
# natively — same destination the bash installer targets on POSIX, so
# claude_smart_prepend_astral_bins picks it up uniformly afterwards.
if claude_smart_is_windows; then
if ! command -v powershell >/dev/null 2>&1; then
write_failure "uv install needs PowerShell on Windows but powershell is not on PATH — install uv manually from https://docs.astral.sh/uv/"
fi
if ! powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://astral.sh/uv/install.ps1 | iex" >&2; then
write_failure "uv install via PowerShell failed — install manually from https://docs.astral.sh/uv/"
fi
else
if ! curl -LsSf https://astral.sh/uv/install.sh | sh >&2; then
write_failure "uv install failed — install manually from https://docs.astral.sh/uv/"
fi
fi
claude_smart_prepend_astral_bins
if ! command -v uv >/dev/null 2>&1; then
UV_FOUND=""
for candidate in "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv" "$HOME/bin/uv"; do
for candidate in "$HOME/.local/bin/uv" "$HOME/.local/bin/uv.exe" "$HOME/.cargo/bin/uv" "$HOME/bin/uv"; do
if [ -x "$candidate" ]; then
UV_FOUND="$candidate"
break
Expand Down Expand Up @@ -103,9 +119,15 @@ fi
# Allowlist cs-cite globally so Claude's citation Bash calls don't pop a
# permission prompt mid-turn. Idempotent: no-ops when the entry is already
# present. Uses Python to preserve the rest of settings.json intact.
# Resolves python via claude_smart_resolve_python so we don't fire the
# Windows App Execution Alias stub (which exits non-zero with "Python
# was not found" when no real interpreter is installed).
CLAUDE_SETTINGS="$HOME/.claude/settings.json"
mkdir -p "$(dirname "$CLAUDE_SETTINGS")"
if python3 - "$CLAUDE_SETTINGS" <<'PY' >&2
PY_BIN=$(claude_smart_resolve_python || true)
if [ -z "$PY_BIN" ]; then
echo "[claude-smart] WARNING: no working python interpreter found; skipping cs-cite allowlist" >&2
elif "$PY_BIN" - "$CLAUDE_SETTINGS" <<'PY' >&2
import json
import sys
from pathlib import Path
Expand Down
Loading