From 152acfa67e96b4dd67c8d324bc616b0b41d57b32 Mon Sep 17 00:00:00 2001 From: d9ng Date: Sat, 16 May 2026 17:48:09 +0900 Subject: [PATCH] feat(install): wire SessionStart daemon autostart (close pip-install gap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pip install ctx-retriever && ctx-install` was advertised as matching the plugin Setup path's daemon-autostart behaviour, but in practice `ctx-install` never wired a SessionStart hook. Daemons therefore did not start on session open for pip-based installs, despite README L88-90 ("bge-daemon starts automatically on session open"). Telemetry stayed permanently on `vec_daemon_down` for affected users. The plugin Setup path uses an inline bash block inside `plugin/hooks/hooks.json` to probe + spawn each daemon. This PR ships the same logic as a maintained script so the two install paths produce equivalent results. ## Changes 1. **`src/hooks/ensure-claude-vault-daemons.sh`** (new, +56 lines) - Detects Python interpreter in priority order: pipx env → claude-vault venv → graceful exit (BM25-only mode) - `pgrep` is source of truth for liveness; stale `*.sock` / `*.pid` get cleaned before respawn (fixes the silent-skip-on-dead-daemon failure mode where a leftover socket file blocked any restart) - `bge-daemon` opt-in via `CTX_BGE_ENABLE=1` (matches the plugin SessionStart inline block) 2. **`src/cli/install.py`** - `CTX_HOOKS` adds `(ensure-claude-vault-daemons.sh, SessionStart, async)` - `_hook_entry` now branches on extension: `.sh` files run via `bash`, `.py` files via `python3` - Hook copy step already handles `.sh` (chmod 0o755 was already set for all hook files); no additional changes needed there 3. **`pyproject.toml`** - `[tool.setuptools.package-data]` for `ctx_retriever.hooks` adds `*.sh` so the script ships in the wheel ## Why a script instead of an inline command The plugin Setup path uses a ~700-char inline bash block embedded in JSON. That works for the marketplace path because the JSON is generated; for `ctx-install`-managed `settings.json` a maintained script is easier to audit, fix, and version (one of the maintainers landed a stale-socket hardening fix to a script-form of this hook recently — that pattern is encoded here verbatim). ## Validation - Bash syntax: `bash -n src/hooks/ensure-claude-vault-daemons.sh` OK - `_new_hooks_block()` output now includes `SessionStart` group with the expected `bash $HOME/.claude/hooks/...` command (unit-verified) - pipx-env detection verified in a fork-local environment where daemons had been silently down for 6 days — script restarted both daemons cleanly on first invocation after fix Refs: ensure-claude-vault-daemons.sh stale-socket discussion (local maintainer notes); MAINTAINERS.md areas-of-ownership (install machinery + hook hardening). Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- src/cli/install.py | 28 +++++++---- src/hooks/ensure-claude-vault-daemons.sh | 59 ++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 9 deletions(-) create mode 100755 src/hooks/ensure-claude-vault-daemons.sh diff --git a/pyproject.toml b/pyproject.toml index d86f6d2..4eb3e39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,5 +66,5 @@ packages = [ [tool.setuptools.package-data] "*" = ["*.json", "*.yaml"] -"ctx_retriever.hooks" = ["*.py", "ctx-telemetry"] +"ctx_retriever.hooks" = ["*.py", "*.sh", "ctx-telemetry"] "ctx_retriever.dashboard" = ["*.py", "static/*.html", "static/*.js", "static/*.css"] diff --git a/src/cli/install.py b/src/cli/install.py index ec61d0d..8afac86 100644 --- a/src/cli/install.py +++ b/src/cli/install.py @@ -48,20 +48,32 @@ # Daemon scripts shipped in wheel → deployed to ~/.local/share/claude-vault/ CTX_DAEMONS = ["vec-daemon.py", "bge-daemon.py"] -# The 5 production hooks CTX ships. Each entry: (filename, event, async). +# The CTX hooks ctx-install ships. Each entry: (filename, event, async[, extra_args]). # Matched against current ~/.claude/settings.json structure. +# +# SessionStart hook (ensure-claude-vault-daemons.sh) handles vec-daemon / +# bge-daemon autostart so a fresh `pip install ctx-retriever && ctx-install` +# matches the daemon-autostart behaviour the plugin Setup path provides. +# Async so session start isn't blocked by daemon model load. CTX_HOOKS = [ - ("chat-memory.py", "UserPromptSubmit", False), - ("bm25-memory.py", "UserPromptSubmit", False, ["--rich"]), - ("memory-keyword-trigger.py", "UserPromptSubmit", False), - ("g2-fallback.py", "PostToolUse", False), - ("utility-rate.py", "Stop", True), # telemetry — async + ("chat-memory.py", "UserPromptSubmit", False), + ("bm25-memory.py", "UserPromptSubmit", False, ["--rich"]), + ("memory-keyword-trigger.py", "UserPromptSubmit", False), + ("g2-fallback.py", "PostToolUse", False), + ("utility-rate.py", "Stop", True), # telemetry — async + ("ensure-claude-vault-daemons.sh", "SessionStart", True), # daemon autostart — async ] def _hook_entry(filename: str, extra_args: list[str] | None = None) -> dict: - """Build the JSON hook entry for settings.json.""" - cmd = f"python3 $HOME/.claude/hooks/{filename}" + """Build the JSON hook entry for settings.json. + + Shell scripts (`.sh`) are invoked via `bash`; Python hooks via `python3`. + """ + if filename.endswith(".sh"): + cmd = f"bash $HOME/.claude/hooks/{filename}" + else: + cmd = f"python3 $HOME/.claude/hooks/{filename}" if extra_args: cmd = cmd + " " + " ".join(extra_args) return {"type": "command", "command": cmd} diff --git a/src/hooks/ensure-claude-vault-daemons.sh b/src/hooks/ensure-claude-vault-daemons.sh new file mode 100755 index 0000000..b10342e --- /dev/null +++ b/src/hooks/ensure-claude-vault-daemons.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Idempotent autostart for vec-daemon + bge-daemon (SessionStart hook). +# Wired into ~/.claude/settings.json by ctx-install. +# Spawns missing daemons in background; exits immediately so session start isn't blocked. +# +# Python interpreter detection (priority order): +# 1. pipx env: ~/.local/pipx/venvs/ctx-retriever/bin/python (pip install path) +# 2. venv: ~/.local/share/claude-vault/venv/bin/python (plugin Setup path) +# 3. (none) graceful exit — hook stays BM25-only +# Daemon spawn requires `sentence_transformers` importable in the chosen interpreter. +# +# Liveness check: pgrep is the source of truth. A stale socket file alone (e.g. +# from a daemon SIGKILL'd, system reboot, or terminal close) does not count as +# "alive" — we unlink it and respawn. This avoids the "dead daemon, stale sock, +# script silently skips spawn forever" failure mode. + +set -u +DAEMON_DIR="$HOME/.local/share/claude-vault" +LOG_DIR="$HOME/.cache/claude-vault-daemons" +mkdir -p "$LOG_DIR" + +PIPX_PY="$HOME/.local/pipx/venvs/ctx-retriever/bin/python" +VENV_PY="$DAEMON_DIR/venv/bin/python" + +PY="" +if [ -x "$PIPX_PY" ] && "$PIPX_PY" -c "import sentence_transformers" 2>/dev/null; then + PY="$PIPX_PY" +elif [ -x "$VENV_PY" ] && "$VENV_PY" -c "import sentence_transformers" 2>/dev/null; then + PY="$VENV_PY" +fi + +# Graceful degradation: no usable interpreter → BM25-only, no spawn +if [ -z "$PY" ]; then + exit 0 +fi + +# vec-daemon — spawn if process not alive (pgrep authoritative, socket can be stale) +if ! pgrep -f "vec-daemon.py" >/dev/null 2>&1; then + rm -f "$DAEMON_DIR/vec-daemon.sock" "$DAEMON_DIR/vec-daemon.pid" + if [ -f "$DAEMON_DIR/vec-daemon.py" ]; then + nohup "$PY" "$DAEMON_DIR/vec-daemon.py" \ + >"$LOG_DIR/vec-daemon.log" 2>&1 & + disown + fi +fi + +# bge-daemon — opt-in via CTX_BGE_ENABLE=1 (semantic rerank, ~58s first-run model load) +if [ "${CTX_BGE_ENABLE:-0}" = "1" ]; then + if ! pgrep -f "bge-daemon.py" >/dev/null 2>&1; then + rm -f "$DAEMON_DIR/bge-daemon.sock" "$DAEMON_DIR/bge-daemon.pid" + if [ -f "$DAEMON_DIR/bge-daemon.py" ]; then + nohup "$PY" "$DAEMON_DIR/bge-daemon.py" \ + >"$LOG_DIR/bge-daemon.log" 2>&1 & + disown + fi + fi +fi + +exit 0