-
Notifications
You must be signed in to change notification settings - Fork 59
Expand file tree
/
Copy pathbackend-service.sh
More file actions
executable file
·335 lines (314 loc) · 13.5 KB
/
backend-service.sh
File metadata and controls
executable file
·335 lines (314 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
#!/usr/bin/env bash
# Auto-start the reflexio FastAPI backend (port 8071) if it's not already
# running. Mirrors dashboard-service.sh: detached spawn, returns immediately
# so the SessionStart hook doesn't block the session.
#
# Subcommands:
# start probe /health; if nothing we recognize is on the port,
# spawn `uv run reflexio services start --only backend
# --no-reload` detached. Polls /health briefly so first
# use after session start lands on a warm server, then
# returns a continue payload regardless.
# stop SIGTERM the recorded process group, escalating to
# SIGKILL after a short grace period.
# session-end no-op by default; only stops the backend if
# CLAUDE_SMART_BACKEND_STOP_ON_END=1 (opt-in — the
# backend is intended to be long-lived across sessions).
# status print "running on http://localhost:PORT" or "not running".
set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_lib.sh
. "$HERE/_lib.sh"
claude_smart_source_login_path
claude_smart_prepend_astral_bins
claude_smart_source_reflexio_env
CMD="${1:-start}"
PORT=8071
EMBEDDING_PORT="${EMBEDDING_PORT:-8072}"
# Pass through to `reflexio services start/stop` so the spawned backend
# binds to PORT instead of reflexio's library default (8081).
export BACKEND_PORT="$PORT"
export EMBEDDING_PORT
# Default: route extraction through the active host CLI + ONNX embedder
# so claude-smart works without any LLM API key. Users can opt out by
# pre-exporting these to 0.
export CLAUDE_SMART_USE_LOCAL_CLI="${CLAUDE_SMART_USE_LOCAL_CLI:-1}"
export CLAUDE_SMART_USE_LOCAL_EMBEDDING="${CLAUDE_SMART_USE_LOCAL_EMBEDDING:-1}"
if [ "${CLAUDE_SMART_USE_LOCAL_EMBEDDING:-}" = "1" ]; then
export REFLEXIO_EMBEDDING_PROVIDER="${REFLEXIO_EMBEDDING_PROVIDER:-local_service}"
export REFLEXIO_EMBEDDING_SERVICE_URL="${REFLEXIO_EMBEDDING_SERVICE_URL:-http://127.0.0.1:$EMBEDDING_PORT}"
fi
# The backend can be spawned from contexts whose PATH lacks the host
# CLI dir (commonly ~/.local/bin or /opt/homebrew/bin). Pin the CLI
# explicitly if we can resolve it from our own (post-login-path) PATH.
PLUGIN_ROOT="$(cd "$HERE/.." && pwd)"
claude_smart_reexec_stable_plugin_root_if_needed "$PLUGIN_ROOT" "backend-service.sh" "$@"
if [ -z "${CLAUDE_SMART_CLI_PATH:-}" ]; then
if [ "${CLAUDE_SMART_HOST:-claude-code}" = "codex" ]; then
# Reflexio's provider still calls CLAUDE_SMART_CLI_PATH with Claude CLI
# flags. Use a small compatibility executable that translates that narrow
# contract to `codex exec`.
claude_smart_prepend_node_bins
export CLAUDE_SMART_CLI_PATH="$PLUGIN_ROOT/scripts/codex-claude-compat"
elif _cs_cli_path=$(command -v claude 2>/dev/null) && [ -n "$_cs_cli_path" ]; then
export CLAUDE_SMART_CLI_PATH="$_cs_cli_path"
elif [ -x "$HOME/.local/bin/claude" ]; then
export CLAUDE_SMART_CLI_PATH="$HOME/.local/bin/claude"
fi
unset _cs_cli_path
fi
STATE_DIR="$HOME/.claude-smart"
PID_FILE="$STATE_DIR/backend.pid"
LOG_FILE="$STATE_DIR/backend.log"
LOG_MAX_BYTES="$(claude_smart_log_max_bytes)"
mkdir -p "$STATE_DIR"
claude_smart_trim_log_file "$LOG_FILE" "$LOG_MAX_BYTES"
emit_ok() { claude_smart_emit_continue; }
emit_start_failure() {
reason="$1"
if py=$(claude_smart_resolve_python 2>/dev/null); then
"$py" - "$reason" <<'PY'
import json
import sys
reason = sys.argv[1].strip()
message = (
"> **claude-smart learning backend is not running.** "
"Interactions are being buffered locally, but learning will not publish "
"until the backend starts.\n"
)
if reason:
message += f">\n> Last startup error: `{reason}`\n"
message += (
">\n> Make sure the local model provider is available: Claude Code needs "
"`claude`, Codex needs `codex`. Then run `/claude-smart:restart`."
)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": message,
}
}))
PY
else
emit_ok
fi
}
# Tree-kill the recorded process. Delegates to claude_smart_kill_tree
# (POSIX: signal the process group; Windows: taskkill /T /F /PID).
kill_group() {
claude_smart_kill_tree "$1"
}
# True if /health returns 200. Reflexio's /health is a plain GET with no
# marker header, so we can't distinguish our backend from someone else's
# reflexio on the same port — if you run two reflexio instances on 8071
# you'll get collision regardless of what we do here.
backend_healthy() {
command -v curl >/dev/null 2>&1 || return 1
curl -sf -o /dev/null "http://127.0.0.1:$PORT/health" 2>/dev/null
}
# True only if the recorded PID is alive AND /health responds. A stale
# PID file from a crashed backend is not enough — we must see the port
# actually answer, so next hook retries cleanly.
is_our_backend_running() {
if [ -f "$PID_FILE" ]; then
pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
backend_healthy && return 0
fi
fi
# Recover from a missing PID file if a foreign-but-functional reflexio
# is already serving — no need to start a second one.
backend_healthy && return 0
return 1
}
# True if *anything* is listening on the port (even non-HTTP). Used to
# avoid stomping on a foreign listener with a failed-to-start uvicorn.
port_occupied() {
(echo >"/dev/tcp/127.0.0.1/$PORT") 2>/dev/null
}
# Reap listeners still holding the given port after the PID file kill
# when their command line matches one of the supplied patterns. Filters
# by cmdline so we don't knock over an
# unrelated service a user has bound there — symmetric with start's
# refusal to stomp on a foreign listener. Silent on failure.
reap_port_listeners() {
port="${1:-$PORT}"
shift || true
[ "$#" -eq 0 ] && return 0
command -v lsof >/dev/null 2>&1 || return 0
candidates=$(lsof -ti:"$port" 2>/dev/null) || candidates=""
[ -z "$candidates" ] && return 0
ours=""
for pid in $candidates; do
cmdline=$(ps -p "$pid" -o command= 2>/dev/null || true)
for pattern in "$@"; do
if [[ "$cmdline" == $pattern ]]; then
ours="$ours $pid"
break
fi
done
done
[ -z "$ours" ] && return 0
# shellcheck disable=SC2086
kill -TERM $ours 2>/dev/null || true
sleep 1
remaining=""
for pid in $ours; do
kill -0 "$pid" 2>/dev/null && remaining="$remaining $pid"
done
[ -z "$remaining" ] && return 0
# shellcheck disable=SC2086
kill -KILL $remaining 2>/dev/null || true
}
# Describe what (if anything) is currently listening on $1. Returns
# "<command> (pid <pid>)" or empty if the port is free or lsof is
# unavailable. Used to make port-conflict log lines diagnosable.
port_holder() {
command -v lsof >/dev/null 2>&1 || return 0
lsof -i:"$1" -sTCP:LISTEN -P -n 2>/dev/null \
| awk 'NR==2 {print $1" (pid "$2")"; exit}'
}
# Full shutdown: kill the recorded process group (if any) then sweep
# both the backend port and the embedding-service port for surviving
# reflexio listeners. Used by both `stop` and the opt-in `session-end`
# path so a stale/missing PID file doesn't produce a silent no-op, and
# so a stale embedding service on EMBEDDING_PORT doesn't block the next
# fresh boot (e.g. when codex-hook.js falls back to 8072 for the main
# backend because 8071 is held by another app).
full_stop() {
if [ -f "$PID_FILE" ]; then
kill_group "$(cat "$PID_FILE" 2>/dev/null)"
rm -f "$PID_FILE"
fi
reap_port_listeners "$PORT" '*reflexio*' '*uvicorn*'
if [ -n "${EMBEDDING_PORT:-}" ] && [ "$EMBEDDING_PORT" != "$PORT" ]; then
reap_port_listeners "$EMBEDDING_PORT" \
'*reflexio.server.llm.embedding_service:app*' \
'*reflexio*embedding_service*'
fi
}
case "$CMD" in
start)
if claude_smart_is_internal_invocation_env; then
emit_ok; exit 0
fi
if claude_smart_reflexio_url_is_remote; then
claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" "[claude-smart] backend: remote REFLEXIO_URL configured; skipping local backend start"
emit_ok; exit 0
fi
# Opt-out: users who don't want the backend managed by the hook can
# set CLAUDE_SMART_BACKEND_AUTOSTART=0.
if [ "${CLAUDE_SMART_BACKEND_AUTOSTART:-1}" = "0" ]; then
emit_ok; exit 0
fi
if is_our_backend_running; then emit_ok; exit 0; fi
if port_occupied; then
# Something answered the TCP probe but /health didn't — don't
# start a second uvicorn on top of it. Surface the holder so the
# user knows which process to quit (common case: editor/dev tool
# squatting 8071) instead of silently failing.
holder="$(port_holder "$PORT" 2>/dev/null || true)"
msg="[claude-smart] backend: port $PORT held by another process${holder:+ ($holder)}; skipping start"
claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" "$msg"
echo "$msg" >&2
echo "Free port $PORT (or stop the process above) and run /claude-smart:restart again." >&2
emit_ok; exit 0
fi
if ! command -v uv >/dev/null 2>&1; then
if [ "${CLAUDE_SMART_BOOTSTRAPPING:-}" != "1" ] && [ -x "$PLUGIN_ROOT/scripts/smart-install.sh" ]; then
claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" "[claude-smart] backend: uv not on PATH; starting installer in background"
claude_smart_spawn_detached env CLAUDE_SMART_BOOTSTRAPPING=1 \
bash "$PLUGIN_ROOT/scripts/smart-install.sh" \
>>"$STATE_DIR/install.log" 2>&1 || true
fi
if ! command -v uv >/dev/null 2>&1; then
claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" "[claude-smart] backend: uv not on PATH; installer recovery scheduled; skipping"
emit_ok; exit 0
fi
fi
cd "$PLUGIN_ROOT"
# Cap local interaction history to keep the SQLite store small for
# claude-smart users. Reflexio's library defaults are much higher
# (250k/50k) for server deployments; here we override only in the
# claude-smart plugin context. Users can still override via env.
export INTERACTION_CLEANUP_THRESHOLD="${INTERACTION_CLEANUP_THRESHOLD:-500}"
export INTERACTION_CLEANUP_DELETE_COUNT="${INTERACTION_CLEANUP_DELETE_COUNT:-200}"
# Keep plugin runtime data in ~/.reflexio even when the backend imports
# Reflexio from an editable checkout inside a larger repo with its own
# .env. python-dotenv respects pre-existing env vars, so this prevents a
# parent REFLEXIO_LOG_DIR from sending claude-smart to enterprise configs.
export REFLEXIO_LOG_DIR="${REFLEXIO_LOG_DIR:-$HOME}"
# Force sqlite: the plugin venv ships only the open-source reflexio
# package, which doesn't register the Supabase/Postgres storage
# factories. If the user's ~/.reflexio/.env sets REFLEXIO_STORAGE to
# something else (common when sharing the file with reflexio_ext),
# the backend boots but crashes every request. load_dotenv() inside
# the CLI respects pre-existing env vars, so exporting here wins
# without touching the file on disk.
export REFLEXIO_STORAGE="sqlite"
# (nohup; no process groups). backend-log-runner.sh owns stdout/stderr
# capture so process output cannot grow backend.log past its cap.
#
# --workers: reflexio defaults to 2 (zero-downtime worker recycling
# for server deployments). For a single-user Claude Code plugin
# that's pure overhead: ~1.1 GB extra RSS, periodic 5–10 s spawn
# hiccups during worker rotation, and SQLite can't accept concurrent
# writers anyway. Default to 1 here; opt in to N via
# CLAUDE_SMART_BACKEND_WORKERS for power users running concurrent
# Claude Code sessions or wanting zero-downtime recycling.
workers="${CLAUDE_SMART_BACKEND_WORKERS:-1}"
claude_smart_spawn_detached bash "$HERE/backend-log-runner.sh" \
"$LOG_FILE" "$LOG_MAX_BYTES" -- \
env PYTHONIOENCODING="${PYTHONIOENCODING:-utf-8}" \
uv run --project "$PLUGIN_ROOT" --no-sync --quiet \
reflexio services start --only backend --no-reload --workers "$workers"
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
# warmup) — dashboard auto-start does the same thing. We always
# return ok; the backend catches up in background if it needs to.
for _ in 1 2 3 4 5 6 7 8 9 10; do
backend_healthy && break
sleep 1
done
if ! backend_healthy; then
pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then
reason=$(tail -n 120 "$LOG_FILE" 2>/dev/null | grep -E "No LLM provider available|No generation-capable LLM provider available|CLI not found|skipping provider registration|Application startup failed" | tail -n 1 | sed 's/^[[:space:]]*//')
emit_start_failure "$reason"
exit 0
fi
fi
emit_ok
;;
stop)
full_stop
emit_ok
;;
session-end)
# Default: leave the backend running so learning keeps flowing
# between sessions. Opt in to teardown with
# CLAUDE_SMART_BACKEND_STOP_ON_END=1.
if [ "${CLAUDE_SMART_BACKEND_STOP_ON_END:-0}" = "1" ]; then
full_stop
fi
emit_ok
;;
status)
if claude_smart_reflexio_url_is_remote; then
echo "remote configured at $REFLEXIO_URL"
elif is_our_backend_running; then
echo "running on http://localhost:$PORT"
else
echo "not running"
fi
;;
*)
emit_ok
;;
esac