claude-smart 0.2.49 → 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 (177) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +10 -43
  3. package/bin/claude-smart.js +105 -0
  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/pyproject.toml +1 -1
  27. package/plugin/scripts/_lib.sh +197 -1
  28. package/plugin/scripts/backend-python-runner.py +46 -0
  29. package/plugin/scripts/backend-service.sh +757 -119
  30. package/plugin/scripts/codex-hook.js +63 -225
  31. package/plugin/scripts/dashboard-open.sh +6 -4
  32. package/plugin/scripts/dashboard-service.sh +117 -136
  33. package/plugin/scripts/hook_entry.sh +3 -0
  34. package/plugin/scripts/smart-install.sh +15 -1
  35. package/plugin/src/claude_smart/cli.py +14 -0
  36. package/plugin/src/claude_smart/context_inject.py +3 -0
  37. package/plugin/src/claude_smart/env_config.py +4 -1
  38. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  39. package/plugin/src/claude_smart/events/session_end.py +2 -1
  40. package/plugin/src/claude_smart/events/stop.py +3 -0
  41. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  42. package/plugin/src/claude_smart/internal_call.py +5 -2
  43. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  44. package/plugin/src/claude_smart/publish.py +59 -7
  45. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  46. package/plugin/src/claude_smart/runtime.py +15 -6
  47. package/plugin/src/claude_smart/state.py +211 -52
  48. package/plugin/uv.lock +1 -1
  49. package/plugin/vendor/reflexio/.env.example +13 -0
  50. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  51. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  52. package/plugin/vendor/reflexio/reflexio/client/client.py +126 -3
  53. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  54. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  55. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  56. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +177 -25
  57. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  58. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  59. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  60. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  61. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  62. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  63. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  64. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  65. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/api.py +66 -3
  67. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  68. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  69. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  70. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  71. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +62 -5
  72. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  73. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  74. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  75. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  76. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  77. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +20 -5
  78. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  79. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  80. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  81. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  82. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  83. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  84. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  85. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  86. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  90. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  91. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  92. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  93. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  94. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  95. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  96. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  98. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  99. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  100. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  101. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  103. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  104. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  105. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  106. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +85 -16
  109. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  110. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  111. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  112. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  113. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  114. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  115. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  116. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +510 -63
  118. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  120. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  122. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  124. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  125. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  126. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  127. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  128. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  129. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  130. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  131. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  132. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  133. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +122 -44
  134. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  135. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  139. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +1 -1
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +50 -0
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +36 -1
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +43 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +45 -7
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +146 -6
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +42 -7
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +1 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +50 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +59 -0
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +52 -2
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  172. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  173. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  174. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  175. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  176. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  177. 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).
@@ -63,6 +65,41 @@ 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
105
  if [ "${CLAUDE_SMART_HOST:-claude-code}" = "opencode" ]; then
@@ -71,7 +108,8 @@ if [ -z "${CLAUDE_SMART_CLI_PATH:-}" ]; then
71
108
  # resolves opencode from CLAUDE_SMART_OPENCODE_PATH or PATH; don't require
72
109
  # Git Bash's PATH to match the installer shell's PATH here.
73
110
  claude_smart_prepend_node_bins
74
- export CLAUDE_SMART_CLI_PATH="$(claude_smart_opencode_compat_path "$PLUGIN_ROOT")"
111
+ CLAUDE_SMART_CLI_PATH="$(claude_smart_opencode_compat_path "$PLUGIN_ROOT")"
112
+ export CLAUDE_SMART_CLI_PATH
75
113
  elif [ "${CLAUDE_SMART_HOST:-claude-code}" = "codex" ]; then
76
114
  # Reflexio's provider still calls CLAUDE_SMART_CLI_PATH with Claude CLI
77
115
  # flags. Use a small compatibility executable that translates that narrow
@@ -87,7 +125,12 @@ if [ -z "${CLAUDE_SMART_CLI_PATH:-}" ]; then
87
125
  fi
88
126
 
89
127
  STATE_DIR="$HOME/.claude-smart"
90
- 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"
91
134
  LOG_FILE="$STATE_DIR/backend.log"
92
135
  LOG_MAX_BYTES="$(claude_smart_log_max_bytes)"
93
136
  mkdir -p "$STATE_DIR"
@@ -95,14 +138,19 @@ claude_smart_trim_log_file "$LOG_FILE" "$LOG_MAX_BYTES"
95
138
 
96
139
  emit_ok() { claude_smart_emit_continue; }
97
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
+
98
145
  emit_start_failure() {
99
146
  reason="$1"
100
147
  if py=$(claude_smart_resolve_python 2>/dev/null); then
101
- "$py" - "$reason" <<'PY'
148
+ "$py" - "$reason" "$VENDOR_PREFLIGHT_FAILURE" <<'PY'
102
149
  import json
103
150
  import sys
104
151
 
105
152
  reason = sys.argv[1].strip()
153
+ vendor_preflight_failure = sys.argv[2]
106
154
  message = (
107
155
  "> **claude-smart learning backend is not running.** "
108
156
  "Interactions are being buffered locally, but learning will not publish "
@@ -110,11 +158,19 @@ message = (
110
158
  )
111
159
  if reason:
112
160
  message += f">\n> Last startup error: `{reason}`\n"
113
- message += (
114
- ">\n> Make sure the local model provider is available: Claude Code needs "
115
- "`claude`, Codex needs `codex`, and OpenCode needs `opencode`. "
116
- "Then run `/claude-smart:restart`."
117
- )
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
+ )
118
174
  print(json.dumps({
119
175
  "hookSpecificOutput": {
120
176
  "hookEventName": "SessionStart",
@@ -133,29 +189,28 @@ kill_group() {
133
189
  claude_smart_kill_tree "$1"
134
190
  }
135
191
 
136
- # True if /health returns 200. Reflexio's /health is a plain GET with no
137
- # marker header, so we can't distinguish our backend from someone else's
138
- # reflexio on the same port — if you run two reflexio instances on 8071
139
- # 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.
140
194
  backend_healthy() {
141
195
  command -v curl >/dev/null 2>&1 || return 1
142
- 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
143
197
  }
144
198
 
145
- # True only if the recorded PID is alive AND /health responds. A stale
146
- # PID file from a crashed backend is not enough — we must see the port
147
- # actually answer, so next hook retries cleanly.
148
- is_our_backend_running() {
149
- if [ -f "$PID_FILE" ]; then
150
- pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
151
- if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
152
- backend_healthy && return 0
153
- fi
154
- fi
155
- # Recover from a missing PID file if a foreign-but-functional reflexio
156
- # is already serving — no need to start a second one.
157
- backend_healthy && return 0
158
- return 1
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"
159
214
  }
160
215
 
161
216
  # Best-effort port probe. curl catches responsive HTTP listeners; /dev/tcp
@@ -168,48 +223,558 @@ port_occupied() {
168
223
  (echo >"/dev/tcp/127.0.0.1/$PORT") 2>/dev/null
169
224
  }
170
225
 
171
- # Reap listeners still holding the given port after the PID file kill
172
- # when their command line matches one of the supplied patterns. Filters
173
- # by cmdline so we don't knock over an
174
- # unrelated service a user has bound there — symmetric with start's
175
- # refusal to stomp on a foreign listener. Silent on failure.
176
- reap_port_listeners() {
177
- port="${1:-$PORT}"
178
- shift || true
179
- [ "$#" -eq 0 ] && return 0
180
- command -v lsof >/dev/null 2>&1 || return 0
181
- candidates=$(lsof -ti:"$port" 2>/dev/null) || candidates=""
182
- [ -z "$candidates" ] && return 0
183
- ours=""
184
- for pid in $candidates; do
185
- cmdline=$(ps -p "$pid" -o command= 2>/dev/null || true)
186
- for pattern in "$@"; do
187
- if [[ "$cmdline" == $pattern ]]; then
188
- ours="$ours $pid"
189
- break
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
190
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
191
278
  done
192
279
  done
193
- [ -z "$ours" ] && return 0
194
- # shellcheck disable=SC2086
195
- kill -TERM $ours 2>/dev/null || true
196
- sleep 1
197
- remaining=""
198
- for pid in $ours; do
199
- kill -0 "$pid" 2>/dev/null && remaining="$remaining $pid"
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
200
502
  done
201
- [ -z "$remaining" ] && return 0
202
- # shellcheck disable=SC2086
203
- kill -KILL $remaining 2>/dev/null || true
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")"
594
+ }
595
+
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.
607
+ is_our_backend_running() {
608
+ if [ -f "$PID_FILE" ]; then
609
+ pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
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
616
+ fi
617
+ fi
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
622
+ return 1
623
+ }
624
+
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"
636
+ }
637
+
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
645
+ done
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
659
+ done
660
+ [ -n "$ours" ] || return 0
661
+ stop_backend_pids "$ours"
204
662
  }
205
663
 
206
664
  # Describe what (if anything) is currently listening on $1. Returns
207
- # "<command> (pid <pid>)" or empty if the port is free or lsof is
208
- # 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.
209
667
  port_holder() {
210
- command -v lsof >/dev/null 2>&1 || return 0
211
- lsof -i:"$1" -sTCP:LISTEN -P -n 2>/dev/null \
212
- | 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
213
778
  }
214
779
 
215
780
  ensure_vendored_reflexio_active() {
@@ -241,27 +806,31 @@ PY
241
806
  uv pip install --project "$PLUGIN_ROOT" --python "$plugin_python" --quiet --reinstall --no-deps "$vendor" >&2
242
807
  }
243
808
 
244
- # Full shutdown: kill the recorded process group (if any) then sweep
245
- # both the backend port and the embedding-service port for surviving
246
- # reflexio listeners. Used by both `stop` and the opt-in `session-end`
247
- # path so a stale/missing PID file doesn't produce a silent no-op, and
248
- # so a stale embedding service on EMBEDDING_PORT doesn't block the next
249
- # fresh boot (e.g. when codex-hook.js falls back to 8072 for the main
250
- # 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.
251
812
  full_stop() {
252
- if [ -f "$PID_FILE" ]; then
253
- kill_group "$(cat "$PID_FILE" 2>/dev/null)"
254
- rm -f "$PID_FILE"
255
- fi
256
- reap_port_listeners "$PORT" '*reflexio*' '*uvicorn*'
813
+ kill_recorded_backend
814
+ stop_backend_listener_if_owned || true
257
815
  if [ -n "${EMBEDDING_PORT:-}" ] && [ "$EMBEDDING_PORT" != "$PORT" ]; then
258
- reap_port_listeners "$EMBEDDING_PORT" \
259
- '*reflexio.server.llm.embedding_service:app*' \
260
- '*reflexio*embedding_service*'
816
+ stop_stale_embedding_listener_if_owned || true
261
817
  fi
262
818
  }
263
819
 
264
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
+ ;;
265
834
  start)
266
835
  if claude_smart_is_internal_invocation_env; then
267
836
  emit_ok; exit 0
@@ -275,19 +844,47 @@ case "$CMD" in
275
844
  if [ "${CLAUDE_SMART_BACKEND_AUTOSTART:-1}" = "0" ]; then
276
845
  emit_ok; exit 0
277
846
  fi
278
- if is_our_backend_running; then emit_ok; exit 0; fi
279
- if port_occupied; then
280
- # Something answered the TCP probe but /health didn't — don't
281
- # start a second uvicorn on top of it. Surface the holder so the
282
- # user knows which process to quit (common case: editor/dev tool
283
- # squatting 8071) instead of silently failing.
284
- holder="$(port_holder "$PORT" 2>/dev/null || true)"
285
- msg="[claude-smart] backend: port $PORT held by another process${holder:+ ($holder)}; skipping start"
286
- claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" "$msg"
287
- echo "$msg" >&2
288
- 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
289
851
  emit_ok; exit 0
290
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"
882
+ emit_ok; exit 0
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.
291
888
  if ! command -v uv >/dev/null 2>&1; then
292
889
  if [ "${CLAUDE_SMART_BOOTSTRAPPING:-}" != "1" ] && [ -x "$PLUGIN_ROOT/scripts/smart-install.sh" ]; then
293
890
  claude_smart_append_capped_log "$LOG_FILE" "$LOG_MAX_BYTES" "[claude-smart] backend: uv not on PATH; starting installer in background"
@@ -300,8 +897,35 @@ case "$CMD" in
300
897
  emit_ok; exit 0
301
898
  fi
302
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
303
928
  cd "$PLUGIN_ROOT"
304
- ensure_vendored_reflexio_active
305
929
 
306
930
  # Cap local interaction history to keep the SQLite store small for
307
931
  # claude-smart users. Reflexio's library defaults are much higher
@@ -325,19 +949,6 @@ case "$CMD" in
325
949
  # without touching the file on disk.
326
950
  export REFLEXIO_STORAGE="sqlite"
327
951
 
328
- backend_pythonpath="${PYTHONPATH:-}"
329
- if [ -d "$PLUGIN_ROOT/vendor/reflexio/reflexio" ]; then
330
- vendor_pythonpath="$PLUGIN_ROOT/vendor/reflexio"
331
- pythonpath_sep=":"
332
- if claude_smart_is_windows; then
333
- # Native Windows Python expects ;-separated Windows-style paths in
334
- # PYTHONPATH; MSYS does not auto-convert arbitrary env vars.
335
- pythonpath_sep=";"
336
- vendor_pythonpath="$(claude_smart_to_windows_path "$vendor_pythonpath")"
337
- fi
338
- backend_pythonpath="$vendor_pythonpath${backend_pythonpath:+$pythonpath_sep$backend_pythonpath}"
339
- fi
340
-
341
952
  # (nohup; no process groups). backend-log-runner.sh owns stdout/stderr
342
953
  # capture so process output cannot grow backend.log past its cap.
343
954
  #
@@ -349,28 +960,41 @@ case "$CMD" in
349
960
  # CLAUDE_SMART_BACKEND_WORKERS for power users running concurrent
350
961
  # Claude Code sessions or wanting zero-downtime recycling.
351
962
  workers="${CLAUDE_SMART_BACKEND_WORKERS:-1}"
352
- 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"
353
972
  claude_smart_spawn_detached bash "$HERE/backend-log-runner.sh" \
354
973
  "$LOG_FILE" "$LOG_MAX_BYTES" -- \
355
974
  env PYTHONIOENCODING="${PYTHONIOENCODING:-utf-8}" \
356
975
  PYTHONPATH="$backend_pythonpath" \
357
- "$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
+ -- "$@"
358
986
  svc_pid=$!
359
- # Record the spawned pid, not a pgid sampled with ps. On POSIX,
360
- # setsid/python os.setsid make this pid the new process group leader;
361
- # sampling immediately can race and capture the caller's pgid instead.
362
- # On Windows, claude_smart_kill_tree translates the MSYS pid to WINPID.
363
987
  echo "$svc_pid" > "$PID_FILE"
364
988
 
365
- # Give uvicorn up to ~10s to answer /health. The very first boot
366
- # after a fresh checkout may be slower (LiteLLM import, chromadb
367
- # warmup) dashboard auto-start does the same thing. We always
368
- # return ok; the backend catches up in background if it needs to.
369
- for _ in 1 2 3 4 5 6 7 8 9 10; do
370
- backend_healthy && break
371
- sleep 1
372
- done
373
- 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
374
998
  pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
375
999
  if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then
376
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:]]*//')
@@ -396,8 +1020,22 @@ case "$CMD" in
396
1020
  status)
397
1021
  if claude_smart_reflexio_url_is_remote; then
398
1022
  echo "remote configured at $REFLEXIO_URL"
399
- elif is_our_backend_running; then
400
- 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)"
401
1039
  else
402
1040
  echo "not running"
403
1041
  fi