claude-smart 0.2.48 → 0.2.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +11 -40
  3. package/bin/claude-smart.js +393 -55
  4. package/package.json +3 -2
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/.coverage +0 -0
  8. package/plugin/README.md +4 -3
  9. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +4 -2
  10. package/plugin/dashboard/app/dashboard/page.tsx +6 -1
  11. package/plugin/dashboard/app/preferences/[id]/page.tsx +18 -6
  12. package/plugin/dashboard/app/preferences/page.tsx +32 -35
  13. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +16 -1
  14. package/plugin/dashboard/app/sessions/page.tsx +2 -0
  15. package/plugin/dashboard/app/skills/page.tsx +65 -50
  16. package/plugin/dashboard/app/skills/project/[id]/page.tsx +17 -8
  17. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +1 -6
  18. package/plugin/dashboard/components/common/host-badge.tsx +118 -0
  19. package/plugin/dashboard/components/common/learning-application-badge.tsx +34 -0
  20. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  21. package/plugin/dashboard/components/common/page-header.tsx +3 -3
  22. package/plugin/dashboard/lib/config-file.ts +5 -1
  23. package/plugin/dashboard/lib/host-attribution.ts +62 -0
  24. package/plugin/dashboard/lib/session-reader.ts +40 -2
  25. package/plugin/dashboard/lib/types.ts +7 -1
  26. package/plugin/opencode/dist/server.mjs +60 -2
  27. package/plugin/opencode/server.mts +64 -2
  28. package/plugin/pyproject.toml +2 -2
  29. package/plugin/scripts/_lib.sh +255 -7
  30. package/plugin/scripts/backend-python-runner.py +46 -0
  31. package/plugin/scripts/backend-service.sh +766 -123
  32. package/plugin/scripts/codex-hook.js +79 -229
  33. package/plugin/scripts/dashboard-open.sh +6 -4
  34. package/plugin/scripts/dashboard-service.sh +118 -137
  35. package/plugin/scripts/ensure-plugin-root.sh +106 -8
  36. package/plugin/scripts/hook_entry.sh +3 -0
  37. package/plugin/scripts/opencode-claude-compat.js +8 -1
  38. package/plugin/scripts/smart-install.sh +116 -21
  39. package/plugin/src/README.md +1 -1
  40. package/plugin/src/claude_smart/cli.py +93 -29
  41. package/plugin/src/claude_smart/context_inject.py +3 -0
  42. package/plugin/src/claude_smart/env_config.py +56 -11
  43. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  44. package/plugin/src/claude_smart/events/session_end.py +2 -1
  45. package/plugin/src/claude_smart/events/stop.py +3 -0
  46. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  47. package/plugin/src/claude_smart/internal_call.py +5 -2
  48. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  49. package/plugin/src/claude_smart/publish.py +59 -7
  50. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  51. package/plugin/src/claude_smart/runtime.py +15 -6
  52. package/plugin/src/claude_smart/state.py +211 -52
  53. package/plugin/uv.lock +5 -5
  54. package/plugin/vendor/reflexio/.env.example +13 -0
  55. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  56. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  57. package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
  58. package/plugin/vendor/reflexio/reflexio/client/client.py +166 -9
  59. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  60. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  61. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  62. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +195 -25
  63. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  64. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  65. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  66. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  67. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  68. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  69. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  70. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  71. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  72. package/plugin/vendor/reflexio/reflexio/server/api.py +79 -5
  73. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  74. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  75. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  76. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  77. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +63 -6
  78. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  79. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1 -0
  80. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  81. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  82. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  83. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  84. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +199 -2
  85. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  86. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  90. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  91. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  92. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  93. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  94. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  95. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  96. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  97. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  98. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  99. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +63 -2
  100. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  101. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  102. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  103. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  104. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  107. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  108. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  109. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  111. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  112. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  113. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  114. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  115. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  116. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
  118. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +262 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  120. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  121. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  122. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  123. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  124. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  125. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  126. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +1 -3
  127. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +640 -80
  129. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  131. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  133. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  135. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  136. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  137. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  138. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  139. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  140. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  141. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  142. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  143. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  144. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +166 -66
  145. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  146. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  147. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  148. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  149. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  150. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +2 -0
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +190 -2
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +414 -0
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -5
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +86 -2
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +59 -7
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +57 -12
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +197 -12
  172. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +70 -11
  173. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +17 -0
  174. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
  175. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  176. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +269 -0
  177. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  178. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  179. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  180. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  181. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  182. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  183. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  184. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  185. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +82 -1
  186. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +74 -2
  187. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  188. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  189. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  190. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  191. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  192. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  193. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
@@ -4,6 +4,8 @@
4
4
  # so the SessionStart hook doesn't block the session.
5
5
  #
6
6
  # Subcommands:
7
+ # preflight verify that this plugin can import its bundled Reflexio
8
+ # runtime without changing any running service.
7
9
  # start probe /health; if nothing we recognize is on the port,
8
10
  # spawn the prepared venv's `reflexio services start --only
9
11
  # backend --no-reload` detached. Polls /health briefly so first
@@ -27,7 +29,7 @@ fi
27
29
  claude_smart_prepend_astral_bins
28
30
  claude_smart_source_reflexio_env
29
31
 
30
- PORT=8071
32
+ PORT="${BACKEND_PORT:-8071}"
31
33
  EMBEDDING_PORT="${EMBEDDING_PORT:-8072}"
32
34
  # Pass through to `reflexio services start/stop` so the spawned backend
33
35
  # binds to PORT instead of reflexio's library default (8081).
@@ -45,7 +47,7 @@ export REFLEXIO_DEFAULT_ORG_ID="claude-smart"
45
47
  # Load claude-smart's env from ~/.claude-smart/.env instead of the reflexio
46
48
  # default ~/.reflexio/.env, so an OSS reflexio backend's .env on this machine
47
49
  # can't leak in. The data dir is independent (LOCAL_STORAGE_PATH), so both still
48
- # share ~/.reflexio/data. Must stay in sync with env_config.REFLEXIO_ENV_PATH and
50
+ # share ~/.reflexio/data. Must stay in sync with env_config.CLAUDE_SMART_ENV_PATH and
49
51
  # the source path in _lib.sh (claude_smart_source_reflexio_env).
50
52
  export REFLEXIO_ENV_FILE="$HOME/.claude-smart/.env"
51
53
 
@@ -63,13 +65,51 @@ fi
63
65
  # explicitly if we can resolve it from our own (post-login-path) PATH.
64
66
  PLUGIN_ROOT="$(cd "$HERE/.." && pwd)"
65
67
  claude_smart_reexec_stable_plugin_root_if_needed "$PLUGIN_ROOT" "backend-service.sh" "$@"
68
+ PLUGIN_ROOT_CANONICAL="$(cd "$PLUGIN_ROOT" 2>/dev/null && pwd -P || printf '%s\n' "$PLUGIN_ROOT")"
69
+ VENDORED_REFLEXIO="$PLUGIN_ROOT_CANONICAL/vendor/reflexio"
70
+ VENDORED_REFLEXIO_FOR_PYTHON="$VENDORED_REFLEXIO"
71
+ if claude_smart_is_windows; then
72
+ VENDORED_REFLEXIO_FOR_PYTHON="$(claude_smart_to_windows_path "$VENDORED_REFLEXIO")"
73
+ fi
74
+ export CLAUDE_SMART_REFLEXIO_VENDOR_ROOT="$VENDORED_REFLEXIO_FOR_PYTHON"
75
+
76
+ plugin_version_from_root() {
77
+ root="$1"
78
+ version="$(
79
+ sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
80
+ "$root/.codex-plugin/plugin.json" 2>/dev/null | head -n1
81
+ )"
82
+ if [ -z "$version" ]; then
83
+ version="$(
84
+ sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
85
+ "$root/package.json" 2>/dev/null | head -n1
86
+ )"
87
+ fi
88
+ if [ -z "$version" ]; then
89
+ version="$(
90
+ sed -n 's/^[[:space:]]*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' \
91
+ "$root/pyproject.toml" 2>/dev/null | head -n1
92
+ )"
93
+ fi
94
+ printf '%s\n' "${version:-unknown}"
95
+ }
96
+
97
+ plugin_root_has_version_manifest() {
98
+ root="$1"
99
+ [ -f "$root/.codex-plugin/plugin.json" ] || [ -f "$root/package.json" ] || [ -f "$root/pyproject.toml" ]
100
+ }
101
+
102
+ PLUGIN_VERSION="$(plugin_version_from_root "$PLUGIN_ROOT_CANONICAL")"
66
103
 
67
104
  if [ -z "${CLAUDE_SMART_CLI_PATH:-}" ]; then
68
- if [ "${CLAUDE_SMART_HOST:-claude-code}" = "opencode" ] && command -v opencode >/dev/null 2>&1; then
105
+ if [ "${CLAUDE_SMART_HOST:-claude-code}" = "opencode" ]; then
69
106
  # Preserve Reflexio's Claude CLI provider contract while routing
70
- # generation through the user's authenticated OpenCode setup.
107
+ # generation through the user's authenticated OpenCode setup. The bridge
108
+ # resolves opencode from CLAUDE_SMART_OPENCODE_PATH or PATH; don't require
109
+ # Git Bash's PATH to match the installer shell's PATH here.
71
110
  claude_smart_prepend_node_bins
72
- export CLAUDE_SMART_CLI_PATH="$PLUGIN_ROOT/scripts/opencode-claude-compat"
111
+ CLAUDE_SMART_CLI_PATH="$(claude_smart_opencode_compat_path "$PLUGIN_ROOT")"
112
+ export CLAUDE_SMART_CLI_PATH
73
113
  elif [ "${CLAUDE_SMART_HOST:-claude-code}" = "codex" ]; then
74
114
  # Reflexio's provider still calls CLAUDE_SMART_CLI_PATH with Claude CLI
75
115
  # flags. Use a small compatibility executable that translates that narrow
@@ -85,7 +125,12 @@ if [ -z "${CLAUDE_SMART_CLI_PATH:-}" ]; then
85
125
  fi
86
126
 
87
127
  STATE_DIR="$HOME/.claude-smart"
88
- PID_FILE="$STATE_DIR/backend.pid"
128
+ backend_pid_key="$(
129
+ printf '%s' "$PLUGIN_ROOT_CANONICAL" | cksum 2>/dev/null | awk '{print $1}' || true
130
+ )"
131
+ backend_pid_key="${backend_pid_key:-default}"
132
+ PID_FILE="$STATE_DIR/backend.$backend_pid_key.pid"
133
+ SHARED_PID_FILE="$STATE_DIR/backend.pid"
89
134
  LOG_FILE="$STATE_DIR/backend.log"
90
135
  LOG_MAX_BYTES="$(claude_smart_log_max_bytes)"
91
136
  mkdir -p "$STATE_DIR"
@@ -93,14 +138,19 @@ claude_smart_trim_log_file "$LOG_FILE" "$LOG_MAX_BYTES"
93
138
 
94
139
  emit_ok() { claude_smart_emit_continue; }
95
140
 
141
+ # Reason string for a failed bundled-Reflexio preflight. Shared by every site
142
+ # that reports it so the remedy text in emit_start_failure stays in sync.
143
+ VENDOR_PREFLIGHT_FAILURE="bundled Reflexio import preflight failed"
144
+
96
145
  emit_start_failure() {
97
146
  reason="$1"
98
147
  if py=$(claude_smart_resolve_python 2>/dev/null); then
99
- "$py" - "$reason" <<'PY'
148
+ "$py" - "$reason" "$VENDOR_PREFLIGHT_FAILURE" <<'PY'
100
149
  import json
101
150
  import sys
102
151
 
103
152
  reason = sys.argv[1].strip()
153
+ vendor_preflight_failure = sys.argv[2]
104
154
  message = (
105
155
  "> **claude-smart learning backend is not running.** "
106
156
  "Interactions are being buffered locally, but learning will not publish "
@@ -108,10 +158,19 @@ message = (
108
158
  )
109
159
  if reason:
110
160
  message += f">\n> Last startup error: `{reason}`\n"
111
- message += (
112
- ">\n> Make sure the local model provider is available: Claude Code needs "
113
- "`claude`, Codex needs `codex`. Then run `/claude-smart:restart`."
114
- )
161
+ if reason == vendor_preflight_failure:
162
+ # /claude-smart:restart cannot repair this: the bundled Reflexio runtime
163
+ # only ships in the npm artifact, so the cache has to be replaced.
164
+ message += (
165
+ ">\n> The bundled Reflexio runtime is missing or unusable. Repair it "
166
+ "with `npx claude-smart update`, then restart Claude Code."
167
+ )
168
+ else:
169
+ message += (
170
+ ">\n> Make sure the local model provider is available: Claude Code needs "
171
+ "`claude`, Codex needs `codex`, and OpenCode needs `opencode`. "
172
+ "Then run `/claude-smart:restart`."
173
+ )
115
174
  print(json.dumps({
116
175
  "hookSpecificOutput": {
117
176
  "hookEventName": "SessionStart",
@@ -130,79 +189,592 @@ kill_group() {
130
189
  claude_smart_kill_tree "$1"
131
190
  }
132
191
 
133
- # True if /health returns 200. Reflexio's /health is a plain GET with no
134
- # marker header, so we can't distinguish our backend from someone else's
135
- # reflexio on the same port — if you run two reflexio instances on 8071
136
- # you'll get collision regardless of what we do here.
192
+ # True if /health returns 200, regardless of runtime identity. Used only for
193
+ # generic liveness; ownership checks inspect the listener process.
137
194
  backend_healthy() {
138
195
  command -v curl >/dev/null 2>&1 || return 1
139
- curl -sf -o /dev/null "http://127.0.0.1:$PORT/health" 2>/dev/null
196
+ curl -sf --connect-timeout 1 --max-time 1 -o /dev/null "http://127.0.0.1:$PORT/health" 2>/dev/null
197
+ }
198
+
199
+ wait_for_health() {
200
+ probe_fn="$1"
201
+ attempts="$2"
202
+ interval="$3"
203
+ while [ "$attempts" -gt 0 ]; do
204
+ "$probe_fn" && return 0
205
+ attempts=$((attempts - 1))
206
+ sleep "$interval"
207
+ done
208
+ "$probe_fn"
209
+ }
210
+
211
+ log_embedding_degraded() {
212
+ claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" \
213
+ "[claude-smart] backend: Embedding service did not become healthy on port $EMBEDDING_PORT; semantic retrieval remains unavailable until the local embedding daemon recovers"
214
+ }
215
+
216
+ # Best-effort port probe. curl catches responsive HTTP listeners; /dev/tcp
217
+ # catches other listeners where bash supports it. Used to avoid stomping on
218
+ # a foreign listener with a failed-to-start uvicorn.
219
+ port_occupied() {
220
+ if command -v curl >/dev/null 2>&1; then
221
+ curl -sf --max-time 2 -o /dev/null "http://127.0.0.1:$PORT" 2>/dev/null && return 0
222
+ fi
223
+ (echo >"/dev/tcp/127.0.0.1/$PORT") 2>/dev/null
224
+ }
225
+
226
+ pid_details() {
227
+ pid="$1"
228
+ seen=" "
229
+ depth=0
230
+ while [ -n "$pid" ] && [ "$pid" != "0" ] && [ "$depth" -lt 6 ]; do
231
+ case "$seen" in *" $pid "*) break ;; esac
232
+ seen="$seen$pid "
233
+ {
234
+ printf 'pid=%s ' "$pid"
235
+ claude_smart_pid_command "$pid" 2>/dev/null || true
236
+ if command -v lsof >/dev/null 2>&1; then
237
+ lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n/cwd=/p' || true
238
+ fi
239
+ }
240
+ parent="$(claude_smart_pid_parent "$pid" 2>/dev/null || true)"
241
+ [ -n "$parent" ] && [ "$parent" != "$pid" ] || break
242
+ pid="$parent"
243
+ depth=$((depth + 1))
244
+ done | tr '\n' ' '
245
+ }
246
+
247
+ marked_backend_ancestor_pid() {
248
+ pid="$1"
249
+ seen=" "
250
+ depth=0
251
+ while [ -n "$pid" ] && [ "$pid" != "0" ] && [ "$depth" -lt 6 ]; do
252
+ case "$seen" in *" $pid "*) break ;; esac
253
+ seen="$seen$pid "
254
+ cmdline="$(claude_smart_pid_command "$pid" | tr '\n' ' ' || true)"
255
+ case "$cmdline" in
256
+ *backend-python-runner.py*--claude-smart-backend=1*)
257
+ printf '%s\n' "$pid"
258
+ return 0
259
+ ;;
260
+ esac
261
+ parent="$(claude_smart_pid_parent "$pid" 2>/dev/null || true)"
262
+ [ -n "$parent" ] && [ "$parent" != "$pid" ] || break
263
+ pid="$parent"
264
+ depth=$((depth + 1))
265
+ done
266
+ return 1
267
+ }
268
+
269
+ stop_backend_pids() {
270
+ pids="$1"
271
+ [ -n "$pids" ] || return 0
272
+ targets=""
273
+ for pid in $pids; do
274
+ target="$(marked_backend_ancestor_pid "$pid" 2>/dev/null || true)"
275
+ for candidate in ${target:-} "$pid"; do
276
+ [ -n "$candidate" ] || continue
277
+ case " $targets " in *" $candidate "*) ;; *) targets="$targets $candidate" ;; esac
278
+ done
279
+ done
280
+ for target in $targets; do
281
+ kill_group "$target"
282
+ done
283
+ }
284
+
285
+ looks_like_claude_smart_backend_pid() {
286
+ pid="$1"
287
+ text="$(pid_details "$pid")"
288
+ [ -n "$text" ] || return 1
289
+ case "$text" in
290
+ *--claude-smart-backend=1*|*--claude-smart-reflexio-vendor-root=*|*--claude-smart-plugin-root=*|*"reflexioai/claude-smart"*|*"reflexioai\\claude-smart"*)
291
+ return 0
292
+ ;;
293
+ esac
294
+ for token in $text; do
295
+ value="$(pid_token_path_entry "$token")"
296
+ path_is_or_under_root "$PLUGIN_ROOT_CANONICAL" "$value" && return 0
297
+ done
298
+ return 1
299
+ }
300
+
301
+ is_claude_smart_backend_pid() {
302
+ pid="$1"
303
+ backend_pid_command_matches "$pid" && looks_like_claude_smart_backend_pid "$pid"
304
+ }
305
+
306
+ path_is_or_under_root() {
307
+ root="$1"
308
+ value="$2"
309
+ [ -n "$root" ] && [ -n "$value" ] || return 1
310
+ case "$value" in
311
+ "$root"|"$root"/*|"$root"\\*) return 0 ;;
312
+ esac
313
+ return 1
314
+ }
315
+
316
+ pid_token_path_entry() {
317
+ token="$1"
318
+ value="${token#*=}"
319
+ # PYTHONPATH can hold multiple roots; split path-list suffixes without
320
+ # treating a Windows drive prefix like C:\... as a separator.
321
+ case "$value" in
322
+ *";"*) value="${value%%;*}" ;;
323
+ [A-Za-z]:\\*) ;;
324
+ *) value="${value%%:*}" ;;
325
+ esac
326
+ printf '%s\n' "$value"
327
+ }
328
+
329
+ pid_vendor_root() {
330
+ pid="$1"
331
+ text="$(pid_details "$pid")"
332
+ [ -n "$text" ] || return 1
333
+ for token in $text; do
334
+ case "$token" in
335
+ --claude-smart-reflexio-vendor-root=*)
336
+ printf '%s\n' "${token#--claude-smart-reflexio-vendor-root=}"
337
+ return 0
338
+ ;;
339
+ --claude-smart-plugin-root=*)
340
+ printf '%s/vendor/reflexio\n' "${token#--claude-smart-plugin-root=}"
341
+ return 0
342
+ ;;
343
+ esac
344
+ value="$(pid_token_path_entry "$token")"
345
+ case "$value" in
346
+ */vendor/reflexio|*\\vendor\\reflexio)
347
+ root="$(plugin_root_from_vendor_root "$value" 2>/dev/null || true)"
348
+ if [ -n "$root" ] && plugin_root_has_version_manifest "$root"; then
349
+ printf '%s\n' "$value"
350
+ return 0
351
+ fi
352
+ ;;
353
+ esac
354
+ done
355
+ return 1
356
+ }
357
+
358
+ plugin_root_from_vendor_root() {
359
+ vendor="$1"
360
+ case "$vendor" in
361
+ */vendor/reflexio) printf '%s\n' "${vendor%/vendor/reflexio}"; return 0 ;;
362
+ *\\vendor\\reflexio) printf '%s\n' "${vendor%\\vendor\\reflexio}"; return 0 ;;
363
+ esac
364
+ return 1
365
+ }
366
+
367
+ pid_plugin_root() {
368
+ text="$(pid_details "$1")"
369
+ for token in $text; do
370
+ case "$token" in
371
+ --claude-smart-plugin-root=*)
372
+ printf '%s\n' "${token#--claude-smart-plugin-root=}"
373
+ return 0
374
+ ;;
375
+ esac
376
+ done
377
+ vendor="$(pid_vendor_root "$1" 2>/dev/null || true)"
378
+ if [ -n "$vendor" ]; then
379
+ plugin_root_from_vendor_root "$vendor" && return 0
380
+ fi
381
+ for token in $text; do
382
+ case "$token" in
383
+ cwd=*)
384
+ root="${token#cwd=}"
385
+ if plugin_root_has_version_manifest "$root"; then
386
+ printf '%s\n' "$root"
387
+ return 0
388
+ fi
389
+ ;;
390
+ esac
391
+ done
392
+ return 1
393
+ }
394
+
395
+ pid_plugin_version() {
396
+ text="$(pid_details "$1")"
397
+ for token in $text; do
398
+ case "$token" in
399
+ --claude-smart-version=*)
400
+ printf '%s\n' "${token#--claude-smart-version=}"
401
+ return 0
402
+ ;;
403
+ esac
404
+ done
405
+ root="$(pid_plugin_root "$1" 2>/dev/null || true)"
406
+ [ -n "$root" ] || { printf '%s\n' "unknown"; return 0; }
407
+ plugin_version_from_root "$root"
408
+ }
409
+
410
+ pid_uses_current_vendor_root() {
411
+ pid="$1"
412
+ text="$(pid_details "$pid")"
413
+ [ -n "$text" ] || return 1
414
+ for token in $text; do
415
+ case "$token" in
416
+ --claude-smart-plugin-root=*)
417
+ value="${token#--claude-smart-plugin-root=}"
418
+ path_is_or_under_root "$PLUGIN_ROOT_CANONICAL" "$value" && return 0
419
+ ;;
420
+ --claude-smart-reflexio-vendor-root=*)
421
+ value="${token#--claude-smart-reflexio-vendor-root=}"
422
+ path_is_or_under_root "$VENDORED_REFLEXIO" "$value" && return 0
423
+ path_is_or_under_root "$VENDORED_REFLEXIO_FOR_PYTHON" "$value" && return 0
424
+ ;;
425
+ *)
426
+ value="$(pid_token_path_entry "$token")"
427
+ path_is_or_under_root "$VENDORED_REFLEXIO" "$value" && return 0
428
+ path_is_or_under_root "$VENDORED_REFLEXIO_FOR_PYTHON" "$value" && return 0
429
+ ;;
430
+ esac
431
+ done
432
+ return 1
433
+ }
434
+
435
+ backend_version_core_parts() {
436
+ version="$1"
437
+ printf '%s\n' "$version" \
438
+ | sed -n 's/^\([0-9][0-9]*\)\.\([0-9][0-9]*\)\.\([0-9][0-9]*\)$/\1 \2 \3/p'
439
+ }
440
+
441
+ backend_version_older_than() {
442
+ actual_version_input="$1"
443
+ target_version_input="$2"
444
+ actual_core="$(backend_version_core_parts "$actual_version_input")"
445
+ target_core="$(backend_version_core_parts "$target_version_input")"
446
+ [ -n "$actual_core" ] && [ -n "$target_core" ] || return 1
447
+
448
+ set -- $actual_core
449
+ actual_major="$1"; actual_minor="$2"; actual_patch="$3"
450
+ set -- $target_core
451
+ target_major="$1"; target_minor="$2"; target_patch="$3"
452
+
453
+ [ "$actual_major" -lt "$target_major" ] && return 0
454
+ [ "$actual_major" -gt "$target_major" ] && return 1
455
+ [ "$actual_minor" -lt "$target_minor" ] && return 0
456
+ [ "$actual_minor" -gt "$target_minor" ] && return 1
457
+ [ "$actual_patch" -lt "$target_patch" ] && return 0
458
+ return 1
459
+ }
460
+
461
+ backend_pid_current_or_compatible() {
462
+ pid="$1"
463
+ pid_uses_current_vendor_root "$pid" && return 0
464
+ [ "$PLUGIN_VERSION" = "unknown" ] && return 0
465
+ running_version="$(pid_plugin_version "$pid")"
466
+ [ "$running_version" = "unknown" ] && return 0
467
+ ! backend_version_older_than "$running_version" "$PLUGIN_VERSION"
468
+ }
469
+
470
+ backend_pid_strictly_older_than_plugin() {
471
+ pid="$1"
472
+ [ "$PLUGIN_VERSION" != "unknown" ] || return 1
473
+ pid_uses_current_vendor_root "$pid" && return 1
474
+ running_version="$(pid_plugin_version "$pid")"
475
+ [ "$running_version" != "unknown" ] || return 1
476
+ backend_version_older_than "$running_version" "$PLUGIN_VERSION"
477
+ }
478
+
479
+ backend_listener_pids() {
480
+ claude_smart_port_listener_pids "$PORT"
481
+ }
482
+
483
+ classify_backend_listener() {
484
+ pids="$(backend_listener_pids || true)"
485
+ [ -n "$pids" ] || { printf '%s\n' "free"; return 0; }
486
+ for pid in $pids; do
487
+ if is_claude_smart_backend_pid "$pid"; then
488
+ printf '%s\n' "claude_smart"
489
+ return 0
490
+ fi
491
+ done
492
+ printf '%s\n' "foreign"
493
+ }
494
+
495
+ backend_owned_by_current_vendor() {
496
+ backend_healthy || return 1
497
+ pids="$(backend_listener_pids)"
498
+ for pid in $pids; do
499
+ if is_claude_smart_backend_pid "$pid" && pid_uses_current_vendor_root "$pid"; then
500
+ return 0
501
+ fi
502
+ done
503
+ return 1
504
+ }
505
+
506
+ backend_owned_by_stale_claude_smart() {
507
+ pids="$(backend_listener_pids)"
508
+ for pid in $pids; do
509
+ if is_claude_smart_backend_pid "$pid" && backend_pid_strictly_older_than_plugin "$pid"; then
510
+ return 0
511
+ fi
512
+ done
513
+ return 1
514
+ }
515
+
516
+ backend_current_or_compatible() {
517
+ backend_healthy || return 1
518
+ pids="$(backend_listener_pids)"
519
+ for pid in $pids; do
520
+ if is_claude_smart_backend_pid "$pid" && backend_pid_current_or_compatible "$pid"; then
521
+ return 0
522
+ fi
523
+ done
524
+ return 1
525
+ }
526
+
527
+ embedding_owned_by_current_vendor() {
528
+ [ "${CLAUDE_SMART_USE_LOCAL_EMBEDDING:-}" = "1" ] || return 0
529
+ pids="$(claude_smart_port_listener_pids "$EMBEDDING_PORT")"
530
+ for pid in $pids; do
531
+ if is_claude_smart_backend_pid "$pid" && pid_uses_current_vendor_root "$pid"; then
532
+ return 0
533
+ fi
534
+ done
535
+ return 1
536
+ }
537
+
538
+ embedding_current_or_compatible() {
539
+ [ "${CLAUDE_SMART_USE_LOCAL_EMBEDDING:-}" = "1" ] || return 0
540
+ pids="$(claude_smart_port_listener_pids "$EMBEDDING_PORT")"
541
+ for pid in $pids; do
542
+ if is_claude_smart_backend_pid "$pid" && backend_pid_current_or_compatible "$pid"; then
543
+ return 0
544
+ fi
545
+ done
546
+ return 1
547
+ }
548
+
549
+ verify_bundled_reflexio_import() {
550
+ python_bin="$1"
551
+ pythonpath="$2"
552
+ vendor_root_for_python="$3"
553
+ [ -d "$VENDORED_REFLEXIO/reflexio" ] || {
554
+ echo "bundled Reflexio package not found at $VENDORED_REFLEXIO" >&2
555
+ return 1
556
+ }
557
+ PYTHONPATH="$pythonpath" "$python_bin" - "$vendor_root_for_python" <<'PY'
558
+ from pathlib import Path
559
+ import sys
560
+
561
+ vendor = Path(sys.argv[1]).resolve()
562
+ try:
563
+ import reflexio
564
+ except Exception as exc:
565
+ print(f"failed to import bundled reflexio from {vendor}: {exc}", file=sys.stderr)
566
+ raise SystemExit(1)
567
+
568
+ module = Path(reflexio.__file__).resolve()
569
+ try:
570
+ module.relative_to(vendor)
571
+ except ValueError:
572
+ print(
573
+ f"reflexio import resolved outside bundled vendor: {module} (expected under {vendor})",
574
+ file=sys.stderr,
575
+ )
576
+ raise SystemExit(1)
577
+ PY
578
+ }
579
+
580
+ prepare_backend_import_context() {
581
+ backend_pythonpath="${PYTHONPATH:-}"
582
+ if [ -d "$VENDORED_REFLEXIO/reflexio" ]; then
583
+ vendor_pythonpath="$VENDORED_REFLEXIO"
584
+ pythonpath_sep=":"
585
+ if claude_smart_is_windows; then
586
+ # Native Windows Python expects ;-separated Windows-style paths in
587
+ # PYTHONPATH; MSYS does not auto-convert arbitrary env vars.
588
+ pythonpath_sep=";"
589
+ vendor_pythonpath="$VENDORED_REFLEXIO_FOR_PYTHON"
590
+ fi
591
+ backend_pythonpath="$vendor_pythonpath${backend_pythonpath:+$pythonpath_sep$backend_pythonpath}"
592
+ fi
593
+ backend_python="$(claude_smart_plugin_python "$PLUGIN_ROOT")"
140
594
  }
141
595
 
142
- # True only if the recorded PID is alive AND /health responds. A stale
143
- # PID file from a crashed backend is not enough we must see the port
144
- # actually answer, so next hook retries cleanly.
596
+ # Repair the vendored install if its version drifted, then prove this plugin
597
+ # can import its own bundled Reflexio. Callers run this before any action that
598
+ # would stop or replace a running service.
599
+ preflight_bundled_reflexio_runtime() {
600
+ ensure_vendored_reflexio_active || return 1
601
+ prepare_backend_import_context
602
+ verify_bundled_reflexio_import "$backend_python" "$backend_pythonpath" "$VENDORED_REFLEXIO_FOR_PYTHON"
603
+ }
604
+
605
+ # True only if the recorded PID is alive, /health responds, and the listener
606
+ # process is using this package's bundled Reflexio import path.
145
607
  is_our_backend_running() {
146
608
  if [ -f "$PID_FILE" ]; then
147
609
  pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
148
- if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
149
- backend_healthy && return 0
610
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && backend_pid_command_matches "$pid"; then
611
+ if command -v curl >/dev/null 2>&1; then
612
+ backend_healthy && backend_pid_current_or_compatible "$pid" && return 0
613
+ else
614
+ backend_pid_current_or_compatible "$pid" && return 0
615
+ fi
150
616
  fi
151
617
  fi
152
- # Recover from a missing PID file if a foreign-but-functional reflexio
153
- # is already serving no need to start a second one.
154
- backend_healthy && return 0
618
+ # Recover from a missing PID file only when the listener proves it belongs
619
+ # to a compatible claude-smart package. A generic healthy service is not
620
+ # enough, and a known older claude-smart package is restarted later.
621
+ backend_current_or_compatible && return 0
155
622
  return 1
156
623
  }
157
624
 
158
- # True if *anything* is listening on the port (even non-HTTP). Used to
159
- # avoid stomping on a foreign listener with a failed-to-start uvicorn.
160
- port_occupied() {
161
- (echo >"/dev/tcp/127.0.0.1/$PORT") 2>/dev/null
625
+ stop_backend_listener_if_owned() {
626
+ pids="$(backend_listener_pids)"
627
+ [ -n "$pids" ] || return 1
628
+ ours=""
629
+ for pid in $pids; do
630
+ if is_claude_smart_backend_pid "$pid" && { pid_uses_current_vendor_root "$pid" || backend_pid_strictly_older_than_plugin "$pid"; }; then
631
+ ours="$ours $pid"
632
+ fi
633
+ done
634
+ [ -n "$ours" ] || return 1
635
+ stop_backend_pids "$ours"
162
636
  }
163
637
 
164
- # Reap listeners still holding the given port after the PID file kill
165
- # when their command line matches one of the supplied patterns. Filters
166
- # by cmdline so we don't knock over an
167
- # unrelated service a user has bound there — symmetric with start's
168
- # refusal to stomp on a foreign listener. Silent on failure.
169
- reap_port_listeners() {
170
- port="${1:-$PORT}"
171
- shift || true
172
- [ "$#" -eq 0 ] && return 0
173
- command -v lsof >/dev/null 2>&1 || return 0
174
- candidates=$(lsof -ti:"$port" 2>/dev/null) || candidates=""
175
- [ -z "$candidates" ] && return 0
176
- ours=""
177
- for pid in $candidates; do
178
- cmdline=$(ps -p "$pid" -o command= 2>/dev/null || true)
179
- for pattern in "$@"; do
180
- if [[ "$cmdline" == $pattern ]]; then
181
- ours="$ours $pid"
182
- break
183
- fi
184
- done
638
+ stop_stale_backend_listener_if_owned() {
639
+ backend_owned_by_stale_claude_smart || return 1
640
+ stale=""
641
+ for pid in $(backend_listener_pids); do
642
+ if is_claude_smart_backend_pid "$pid" && backend_pid_strictly_older_than_plugin "$pid"; then
643
+ stale="$stale $pid"
644
+ fi
185
645
  done
186
- [ -z "$ours" ] && return 0
187
- # shellcheck disable=SC2086
188
- kill -TERM $ours 2>/dev/null || true
189
- sleep 1
190
- remaining=""
191
- for pid in $ours; do
192
- kill -0 "$pid" 2>/dev/null && remaining="$remaining $pid"
646
+ [ -n "$stale" ] || return 1
647
+ stop_backend_pids "$stale"
648
+ }
649
+
650
+ stop_stale_embedding_listener_if_owned() {
651
+ [ "${CLAUDE_SMART_USE_LOCAL_EMBEDDING:-}" = "1" ] || return 0
652
+ pids="$(claude_smart_port_listener_pids "$EMBEDDING_PORT")"
653
+ [ -n "$pids" ] || return 0
654
+ ours=""
655
+ for pid in $pids; do
656
+ if is_claude_smart_backend_pid "$pid" && backend_pid_strictly_older_than_plugin "$pid"; then
657
+ ours="$ours $pid"
658
+ fi
193
659
  done
194
- [ -z "$remaining" ] && return 0
195
- # shellcheck disable=SC2086
196
- kill -KILL $remaining 2>/dev/null || true
660
+ [ -n "$ours" ] || return 0
661
+ stop_backend_pids "$ours"
197
662
  }
198
663
 
199
664
  # Describe what (if anything) is currently listening on $1. Returns
200
- # "<command> (pid <pid>)" or empty if the port is free or lsof is
201
- # unavailable. Used to make port-conflict log lines diagnosable.
665
+ # "<command> (pid <pid>)" or empty if the port is free or no platform probe is
666
+ # available. Used to make port-conflict log lines diagnosable.
202
667
  port_holder() {
203
- command -v lsof >/dev/null 2>&1 || return 0
204
- lsof -i:"$1" -sTCP:LISTEN -P -n 2>/dev/null \
205
- | awk 'NR==2 {print $1" (pid "$2")"; exit}'
668
+ claude_smart_port_holder "$1"
669
+ }
670
+
671
+ recorded_backend_pid() {
672
+ [ -f "$PID_FILE" ] || return 1
673
+ pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
674
+ [ -n "$pid" ] || return 1
675
+ printf '%s\n' "$pid"
676
+ }
677
+
678
+ shared_backend_pid() {
679
+ [ -f "$SHARED_PID_FILE" ] || return 1
680
+ pid=$(cat "$SHARED_PID_FILE" 2>/dev/null || echo "")
681
+ [ -n "$pid" ] || return 1
682
+ printf '%s\n' "$pid"
683
+ }
684
+
685
+ backend_pid_command_matches() {
686
+ pid="$1"
687
+ cmdline="$(claude_smart_pid_command "$pid" | tr '\n' ' ' || true)"
688
+ [ -n "$cmdline" ] || return 1
689
+ case "$cmdline" in
690
+ *backend-python-runner.py*--claude-smart-backend=1*services*start*|*reflexio.cli*services*start*|*reflexio.server*|*uvicorn*reflexio*|*reflexio*uvicorn*) return 0 ;;
691
+ *) return 1 ;;
692
+ esac
693
+ }
694
+
695
+ recorded_backend_pid_alive() {
696
+ pid="$(recorded_backend_pid 2>/dev/null || true)"
697
+ [ -n "$pid" ] || return 1
698
+ kill -0 "$pid" 2>/dev/null || return 1
699
+ backend_pid_command_matches "$pid"
700
+ }
701
+
702
+ backend_pid_is_own_recorded() {
703
+ pid="$1"
704
+ recorded="$(recorded_backend_pid 2>/dev/null || true)"
705
+ [ -n "$recorded" ] && [ "$pid" = "$recorded" ]
706
+ }
707
+
708
+ pid_cwd_matches_current_plugin_root() {
709
+ pid="$1"
710
+ command -v lsof >/dev/null 2>&1 || return 1
711
+ cwd="$(
712
+ lsof -a -p "$pid" -d cwd -Fn 2>/dev/null \
713
+ | sed -n 's/^n//p' \
714
+ | head -n 1
715
+ )"
716
+ [ -n "$cwd" ] || return 1
717
+ cwd_canonical="$(cd "$cwd" 2>/dev/null && pwd -P || printf '%s\n' "$cwd")"
718
+ [ "$cwd_canonical" = "$PLUGIN_ROOT_CANONICAL" ]
719
+ }
720
+
721
+ backend_pid_is_legacy_same_root_shared() {
722
+ pid="$1"
723
+ shared="$(shared_backend_pid 2>/dev/null || true)"
724
+ [ -n "$shared" ] && [ "$pid" = "$shared" ] || return 1
725
+ pid_cwd_matches_current_plugin_root "$pid"
726
+ }
727
+
728
+ backend_start_grace_seconds() {
729
+ value="${CLAUDE_SMART_BACKEND_START_GRACE_SECONDS:-60}"
730
+ case "$value" in
731
+ ''|*[!0-9]*) printf '%s\n' "60" ;;
732
+ *) printf '%s\n' "$value" ;;
733
+ esac
734
+ }
735
+
736
+ recorded_backend_pid_in_startup_grace() {
737
+ recorded_backend_pid_alive || return 1
738
+ now="$(claude_smart_now_epoch 2>/dev/null || printf '%s\n' "0")"
739
+ mtime="$(claude_smart_path_mtime_epoch "$PID_FILE" 2>/dev/null || printf '%s\n' "$now")"
740
+ age=$((now - mtime))
741
+ [ "$age" -lt "$(backend_start_grace_seconds)" ]
742
+ }
743
+
744
+ kill_pid_if_directionally_owned() {
745
+ pid="$1"
746
+ [ -n "$pid" ] || return 1
747
+ kill -0 "$pid" 2>/dev/null || return 1
748
+ is_claude_smart_backend_pid "$pid" || return 1
749
+ if pid_uses_current_vendor_root "$pid" \
750
+ || backend_pid_strictly_older_than_plugin "$pid" \
751
+ || backend_pid_is_own_recorded "$pid" \
752
+ || backend_pid_is_legacy_same_root_shared "$pid"; then
753
+ kill_group "$pid"
754
+ return 0
755
+ fi
756
+ return 1
757
+ }
758
+
759
+ kill_recorded_backend() {
760
+ pid="$(recorded_backend_pid 2>/dev/null || true)"
761
+ if [ -n "$pid" ]; then
762
+ if ! kill_pid_if_directionally_owned "$pid"; then
763
+ claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" \
764
+ "[claude-smart] backend: recorded pid $pid is not owned by this plugin; removing stale root-specific pid file"
765
+ fi
766
+ rm -f "$PID_FILE"
767
+ fi
768
+ shared_pid="$(shared_backend_pid 2>/dev/null || true)"
769
+ if [ -n "$shared_pid" ]; then
770
+ if kill_pid_if_directionally_owned "$shared_pid"; then
771
+ rm -f "$SHARED_PID_FILE"
772
+ else
773
+ claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" \
774
+ "[claude-smart] backend: shared pid $shared_pid is not owned by this plugin; leaving shared pid file"
775
+ fi
776
+ fi
777
+ return 0
206
778
  }
207
779
 
208
780
  ensure_vendored_reflexio_active() {
@@ -234,27 +806,31 @@ PY
234
806
  uv pip install --project "$PLUGIN_ROOT" --python "$plugin_python" --quiet --reinstall --no-deps "$vendor" >&2
235
807
  }
236
808
 
237
- # Full shutdown: kill the recorded process group (if any) then sweep
238
- # both the backend port and the embedding-service port for surviving
239
- # reflexio listeners. Used by both `stop` and the opt-in `session-end`
240
- # path so a stale/missing PID file doesn't produce a silent no-op, and
241
- # so a stale embedding service on EMBEDDING_PORT doesn't block the next
242
- # fresh boot (e.g. when codex-hook.js falls back to 8072 for the main
243
- # backend because 8071 is held by another app).
809
+ # Full shutdown: kill the recorded process group (if any) then sweep stale
810
+ # claude-smart-owned backend/embedding listeners. Foreign services on the same
811
+ # ports are left alone.
244
812
  full_stop() {
245
- if [ -f "$PID_FILE" ]; then
246
- kill_group "$(cat "$PID_FILE" 2>/dev/null)"
247
- rm -f "$PID_FILE"
248
- fi
249
- reap_port_listeners "$PORT" '*reflexio*' '*uvicorn*'
813
+ kill_recorded_backend
814
+ stop_backend_listener_if_owned || true
250
815
  if [ -n "${EMBEDDING_PORT:-}" ] && [ "$EMBEDDING_PORT" != "$PORT" ]; then
251
- reap_port_listeners "$EMBEDDING_PORT" \
252
- '*reflexio.server.llm.embedding_service:app*' \
253
- '*reflexio*embedding_service*'
816
+ stop_stale_embedding_listener_if_owned || true
254
817
  fi
255
818
  }
256
819
 
257
820
  case "$CMD" in
821
+ preflight)
822
+ if claude_smart_reflexio_url_is_remote \
823
+ || [ "${CLAUDE_SMART_BACKEND_AUTOSTART:-1}" = "0" ]; then
824
+ exit 0
825
+ fi
826
+ if ! preflight_bundled_reflexio_runtime; then
827
+ echo "claude-smart backend preflight failed ($VENDOR_PREFLIGHT_FAILURE);" \
828
+ "existing services were not changed" >&2
829
+ echo "Repair the bundled runtime with: npx claude-smart update" >&2
830
+ exit 1
831
+ fi
832
+ exit 0
833
+ ;;
258
834
  start)
259
835
  if claude_smart_is_internal_invocation_env; then
260
836
  emit_ok; exit 0
@@ -268,19 +844,47 @@ case "$CMD" in
268
844
  if [ "${CLAUDE_SMART_BACKEND_AUTOSTART:-1}" = "0" ]; then
269
845
  emit_ok; exit 0
270
846
  fi
271
- if is_our_backend_running; then emit_ok; exit 0; fi
272
- if port_occupied; then
273
- # Something answered the TCP probe but /health didn't — don't
274
- # start a second uvicorn on top of it. Surface the holder so the
275
- # user knows which process to quit (common case: editor/dev tool
276
- # squatting 8071) instead of silently failing.
277
- holder="$(port_holder "$PORT" 2>/dev/null || true)"
278
- msg="[claude-smart] backend: port $PORT held by another process${holder:+ ($holder)}; skipping start"
279
- claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" "$msg"
280
- echo "$msg" >&2
281
- echo "Free port $PORT (or stop the process above) and run /claude-smart:restart again." >&2
847
+ if ! claude_smart_service_lock "backend"; then
848
+ if wait_for_health backend_current_or_compatible 3 1; then
849
+ embedding_current_or_compatible || log_embedding_degraded
850
+ fi
851
+ emit_ok; exit 0
852
+ fi
853
+ trap 'claude_smart_service_unlock "backend"' EXIT
854
+ listener_class="$(classify_backend_listener)"
855
+ case "$listener_class" in
856
+ claude_smart)
857
+ if backend_current_or_compatible; then
858
+ if ! backend_owned_by_current_vendor; then
859
+ claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" \
860
+ "[claude-smart] backend: accepted compatible claude-smart backend on port $PORT from another plugin root; not restarting"
861
+ fi
862
+ embedding_current_or_compatible || log_embedding_degraded
863
+ emit_ok; exit 0
864
+ fi
865
+ ;;
866
+ foreign)
867
+ holder="$(port_holder "$PORT" 2>/dev/null || true)"
868
+ msg="[claude-smart] backend: port $PORT held by another process${holder:+ ($holder)}; skipping start"
869
+ claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" "$msg"
870
+ echo "$msg" >&2
871
+ echo "Free port $PORT (or stop the process above) and run /claude-smart:restart again." >&2
872
+ emit_ok; exit 0
873
+ ;;
874
+ esac
875
+ if is_our_backend_running; then
876
+ embedding_current_or_compatible || log_embedding_degraded
877
+ emit_ok; exit 0
878
+ fi
879
+ if recorded_backend_pid_in_startup_grace; then
880
+ claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" \
881
+ "[claude-smart] backend: recorded backend process is still running but not healthy yet; skipping duplicate start"
282
882
  emit_ok; exit 0
283
883
  fi
884
+ # Everything above only accepts an existing healthy backend; everything
885
+ # below stops or replaces one. The bundled-runtime checks sit on that
886
+ # boundary: if this plugin cannot run its own backend, report that without
887
+ # tearing down a backend that is currently serving.
284
888
  if ! command -v uv >/dev/null 2>&1; then
285
889
  if [ "${CLAUDE_SMART_BOOTSTRAPPING:-}" != "1" ] && [ -x "$PLUGIN_ROOT/scripts/smart-install.sh" ]; then
286
890
  claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" "[claude-smart] backend: uv not on PATH; starting installer in background"
@@ -293,8 +897,35 @@ case "$CMD" in
293
897
  emit_ok; exit 0
294
898
  fi
295
899
  fi
900
+ if ! preflight_bundled_reflexio_runtime; then
901
+ claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" \
902
+ "[claude-smart] backend: $VENDOR_PREFLIGHT_FAILURE; existing services were not changed"
903
+ emit_start_failure "$VENDOR_PREFLIGHT_FAILURE"
904
+ exit 0
905
+ fi
906
+ if recorded_backend_pid_alive; then
907
+ claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" \
908
+ "[claude-smart] backend: recorded backend process exceeded startup grace without health; restarting"
909
+ kill_recorded_backend
910
+ sleep 1
911
+ fi
912
+ if stop_stale_backend_listener_if_owned; then
913
+ claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" \
914
+ "[claude-smart] backend: existing claude-smart backend is older than plugin $PLUGIN_VERSION; restarting local services"
915
+ fi
916
+ stop_stale_embedding_listener_if_owned || true
917
+ if port_occupied; then
918
+ # is_our_backend_running already ruled out a healthy Reflexio backend.
919
+ # Anything still holding the port would make uvicorn fail to bind, so
920
+ # surface the holder instead of silently starting into a collision.
921
+ holder="$(port_holder "$PORT" 2>/dev/null || true)"
922
+ msg="[claude-smart] backend: port $PORT held by another process${holder:+ ($holder)}; skipping start"
923
+ claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" "$msg"
924
+ echo "$msg" >&2
925
+ echo "Free port $PORT (or stop the process above) and run /claude-smart:restart again." >&2
926
+ emit_ok; exit 0
927
+ fi
296
928
  cd "$PLUGIN_ROOT"
297
- ensure_vendored_reflexio_active
298
929
 
299
930
  # Cap local interaction history to keep the SQLite store small for
300
931
  # claude-smart users. Reflexio's library defaults are much higher
@@ -318,21 +949,6 @@ case "$CMD" in
318
949
  # without touching the file on disk.
319
950
  export REFLEXIO_STORAGE="sqlite"
320
951
 
321
- backend_pythonpath="${PYTHONPATH:-}"
322
- if [ -d "$PLUGIN_ROOT/vendor/reflexio/reflexio" ]; then
323
- vendor_pythonpath="$PLUGIN_ROOT/vendor/reflexio"
324
- pythonpath_sep=":"
325
- if claude_smart_is_windows; then
326
- # Native Windows Python expects ;-separated Windows-style paths in
327
- # PYTHONPATH; MSYS does not auto-convert arbitrary env vars.
328
- pythonpath_sep=";"
329
- if command -v cygpath >/dev/null 2>&1; then
330
- vendor_pythonpath="$(cygpath -w "$vendor_pythonpath")"
331
- fi
332
- fi
333
- backend_pythonpath="$vendor_pythonpath${backend_pythonpath:+$pythonpath_sep$backend_pythonpath}"
334
- fi
335
-
336
952
  # (nohup; no process groups). backend-log-runner.sh owns stdout/stderr
337
953
  # capture so process output cannot grow backend.log past its cap.
338
954
  #
@@ -344,28 +960,41 @@ case "$CMD" in
344
960
  # CLAUDE_SMART_BACKEND_WORKERS for power users running concurrent
345
961
  # Claude Code sessions or wanting zero-downtime recycling.
346
962
  workers="${CLAUDE_SMART_BACKEND_WORKERS:-1}"
347
- backend_python="$(claude_smart_plugin_python "$PLUGIN_ROOT")"
963
+ # Sets backend_python / backend_pythonpath for the spawn below. Already run
964
+ # by the preflight above; repeated here so the spawn does not depend on a
965
+ # side effect of an earlier, reorderable step.
966
+ prepare_backend_import_context
967
+ # Record the spawned pid, not a pgid sampled with ps. On POSIX,
968
+ # setsid/python os.setsid make this pid the new process group leader;
969
+ # sampling immediately can race and capture the caller's pgid instead.
970
+ # On Windows, claude_smart_kill_tree translates the MSYS pid to WINPID.
971
+ set -- services start --only backend --no-reload --workers "$workers"
348
972
  claude_smart_spawn_detached bash "$HERE/backend-log-runner.sh" \
349
973
  "$LOG_FILE" "$LOG_MAX_BYTES" -- \
350
974
  env PYTHONIOENCODING="${PYTHONIOENCODING:-utf-8}" \
351
975
  PYTHONPATH="$backend_pythonpath" \
352
- "$backend_python" -m reflexio.cli services start --only backend --no-reload --workers "$workers"
976
+ CLAUDE_SMART_BACKEND=1 \
977
+ CLAUDE_SMART_PLUGIN_ROOT="$PLUGIN_ROOT_CANONICAL" \
978
+ CLAUDE_SMART_VERSION="$PLUGIN_VERSION" \
979
+ CLAUDE_SMART_REFLEXIO_VENDOR_ROOT="$VENDORED_REFLEXIO_FOR_PYTHON" \
980
+ "$backend_python" "$HERE/backend-python-runner.py" \
981
+ --claude-smart-backend=1 \
982
+ "--claude-smart-plugin-root=$PLUGIN_ROOT_CANONICAL" \
983
+ "--claude-smart-version=$PLUGIN_VERSION" \
984
+ "--claude-smart-reflexio-vendor-root=$VENDORED_REFLEXIO_FOR_PYTHON" \
985
+ -- "$@"
353
986
  svc_pid=$!
354
- # Record the spawned pid, not a pgid sampled with ps. On POSIX,
355
- # setsid/python os.setsid make this pid the new process group leader;
356
- # sampling immediately can race and capture the caller's pgid instead.
357
- # On Windows, claude_smart_kill_tree translates the MSYS pid to WINPID.
358
987
  echo "$svc_pid" > "$PID_FILE"
359
988
 
360
- # Give uvicorn up to ~10s to answer /health. The very first boot
361
- # after a fresh checkout may be slower (LiteLLM import, chromadb
362
- # warmup) dashboard auto-start does the same thing. We always
363
- # return ok; the backend catches up in background if it needs to.
364
- for _ in 1 2 3 4 5 6 7 8 9 10; do
365
- backend_healthy && break
366
- sleep 1
367
- done
368
- if ! backend_healthy; then
989
+ # Give uvicorn about 20s to answer /health. The first boot after a fresh
990
+ # checkout may be slower; if it catches up later, the next hook observes it.
991
+ if wait_for_health backend_owned_by_current_vendor 10 1; then
992
+ # The Node installer waits 45s for this script. Each curl probe is also
993
+ # bounded so a wedged listener cannot outlive the caller's timeout budget.
994
+ if ! wait_for_health embedding_owned_by_current_vendor 5 3; then
995
+ log_embedding_degraded
996
+ fi
997
+ else
369
998
  pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
370
999
  if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then
371
1000
  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:]]*//')
@@ -391,8 +1020,22 @@ case "$CMD" in
391
1020
  status)
392
1021
  if claude_smart_reflexio_url_is_remote; then
393
1022
  echo "remote configured at $REFLEXIO_URL"
394
- elif is_our_backend_running; then
395
- echo "running on http://localhost:$PORT"
1023
+ elif backend_owned_by_current_vendor; then
1024
+ if embedding_owned_by_current_vendor; then
1025
+ echo "running on http://localhost:$PORT (bundled Reflexio at $VENDORED_REFLEXIO)"
1026
+ else
1027
+ echo "running on http://localhost:$PORT (bundled Reflexio at $VENDORED_REFLEXIO; embedding degraded on http://127.0.0.1:$EMBEDDING_PORT)"
1028
+ fi
1029
+ elif backend_current_or_compatible; then
1030
+ if embedding_current_or_compatible; then
1031
+ echo "running on http://localhost:$PORT (compatible claude-smart backend)"
1032
+ else
1033
+ echo "running on http://localhost:$PORT (compatible claude-smart backend; embedding degraded on http://127.0.0.1:$EMBEDDING_PORT)"
1034
+ fi
1035
+ elif backend_owned_by_stale_claude_smart; then
1036
+ echo "stale claude-smart backend on http://localhost:$PORT (older than plugin $PLUGIN_VERSION)"
1037
+ elif backend_healthy; then
1038
+ echo "foreign or ambiguous backend on http://localhost:$PORT (not managed by claude-smart)"
396
1039
  else
397
1040
  echo "not running"
398
1041
  fi