shellmate-cli 3.0.0__py3-none-any.whl

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 (106) hide show
  1. shellmate/__init__.py +0 -0
  2. shellmate/adapters/__init__.py +67 -0
  3. shellmate/adapters/bash_adapter.sh +7 -0
  4. shellmate/adapters/ble_adapter.sh +350 -0
  5. shellmate/adapters/powershell_adapter.ps1 +417 -0
  6. shellmate/adapters/powershell_predictor.ps1 +97 -0
  7. shellmate/adapters/zsh_adapter.zsh +8 -0
  8. shellmate/agents/__init__.py +23 -0
  9. shellmate/agents/base.py +248 -0
  10. shellmate/agents/chain_patterns.yaml +98 -0
  11. shellmate/agents/command_chain.py +322 -0
  12. shellmate/agents/context_gatherer.py +175 -0
  13. shellmate/agents/environment.py +363 -0
  14. shellmate/agents/librarian.py +502 -0
  15. shellmate/agents/query.py +415 -0
  16. shellmate/agents/repomix_backend.py +171 -0
  17. shellmate/agents/watcher.py +266 -0
  18. shellmate/cache/__init__.py +0 -0
  19. shellmate/cache/semantic_cache.py +41 -0
  20. shellmate/cli/__init__.py +0 -0
  21. shellmate/cli/commands/__init__.py +27 -0
  22. shellmate/cli/commands/analyze.py +5 -0
  23. shellmate/cli/commands/config.py +319 -0
  24. shellmate/cli/commands/configure.py +118 -0
  25. shellmate/cli/commands/disable.py +11 -0
  26. shellmate/cli/commands/enable.py +49 -0
  27. shellmate/cli/commands/init.py +412 -0
  28. shellmate/cli/commands/install.py +242 -0
  29. shellmate/cli/commands/migrate.py +147 -0
  30. shellmate/cli/commands/status.py +31 -0
  31. shellmate/cli/commands/uninstall.py +78 -0
  32. shellmate/cli/main.py +114 -0
  33. shellmate/cli/parser.py +184 -0
  34. shellmate/cli/version.py +1 -0
  35. shellmate/completion/__init__.py +4 -0
  36. shellmate/completion/bash_adapter.py +145 -0
  37. shellmate/config/__init__.py +33 -0
  38. shellmate/config/credentials.py +120 -0
  39. shellmate/config/defaults.py +88 -0
  40. shellmate/config/manager.py +94 -0
  41. shellmate/config/paths.py +77 -0
  42. shellmate/config/secrets.py +66 -0
  43. shellmate/daemon/__init__.py +0 -0
  44. shellmate/daemon/lifecycle.py +308 -0
  45. shellmate/daemon/observability.py +82 -0
  46. shellmate/daemon/pipeline.py +119 -0
  47. shellmate/daemon/server.py +605 -0
  48. shellmate/daemon/session.py +170 -0
  49. shellmate/daemon/startup_ui.py +43 -0
  50. shellmate/environment/__init__.py +0 -0
  51. shellmate/environment/detector.py +130 -0
  52. shellmate/ipc/__init__.py +0 -0
  53. shellmate/ipc/client.py +210 -0
  54. shellmate/ipc/ps_client.py +190 -0
  55. shellmate/orchestration/__init__.py +3 -0
  56. shellmate/orchestration/analysis_graph.py +1274 -0
  57. shellmate/orchestration/state.py +35 -0
  58. shellmate/parser/__init__.py +0 -0
  59. shellmate/parser/command_parser.py +111 -0
  60. shellmate/plugins/__init__.py +0 -0
  61. shellmate/plugins/base.py +80 -0
  62. shellmate/plugins/loader.py +41 -0
  63. shellmate/providers/__init__.py +0 -0
  64. shellmate/providers/base.py +97 -0
  65. shellmate/providers/groq_command_guide.py +108 -0
  66. shellmate/providers/groq_provider.py +127 -0
  67. shellmate/providers/groq_supervisor.py +78 -0
  68. shellmate/providers/openrouter_provider.py +149 -0
  69. shellmate/providers/router.py +272 -0
  70. shellmate/providers/validate_keys.py +98 -0
  71. shellmate/providers/xai_provider.py +142 -0
  72. shellmate/rules/__init__.py +0 -0
  73. shellmate/rules/catastrophic.yaml +96 -0
  74. shellmate/rules/catastrophic_matcher.py +167 -0
  75. shellmate/rules/definitions/__init__.py +0 -0
  76. shellmate/rules/definitions/file_deletion.yaml +54 -0
  77. shellmate/rules/definitions/filesystem.yaml +43 -0
  78. shellmate/rules/definitions/network_execution.yaml +46 -0
  79. shellmate/rules/definitions/permissions.yaml +54 -0
  80. shellmate/rules/definitions/process_control.yaml +37 -0
  81. shellmate/rules/definitions/system_destruction.yaml +67 -0
  82. shellmate/rules/engine.py +200 -0
  83. shellmate/sanitizer/__init__.py +0 -0
  84. shellmate/sanitizer/typo_detector.py +114 -0
  85. shellmate/ui/__init__.py +10 -0
  86. shellmate/ui/analysis.py +164 -0
  87. shellmate/ui/annotations.py +232 -0
  88. shellmate/ui/banner.py +49 -0
  89. shellmate/ui/progress.py +126 -0
  90. shellmate/ui/runtime/__init__.py +15 -0
  91. shellmate/ui/runtime/console_factory.py +65 -0
  92. shellmate/ui/runtime/live_scene.py +116 -0
  93. shellmate/ui/runtime/motion_policy.py +69 -0
  94. shellmate/ui/runtime/tokens.py +47 -0
  95. shellmate/ui/scenes/__init__.py +24 -0
  96. shellmate/ui/scenes/enable.py +316 -0
  97. shellmate/ui/scenes/help.py +53 -0
  98. shellmate/ui/scenes/stages.py +128 -0
  99. shellmate/ui/scenes/status.py +181 -0
  100. shellmate/ui/status.py +3 -0
  101. shellmate_cli-3.0.0.dist-info/METADATA +352 -0
  102. shellmate_cli-3.0.0.dist-info/RECORD +106 -0
  103. shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
  104. shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
  105. shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
  106. shellmate_cli-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,605 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import logging.handlers
5
+ import os
6
+ import time
7
+ from shellmate.config.defaults import get_default_config, SHELLMATE_DIR
8
+ from shellmate.daemon.pipeline import run_pipeline
9
+ from shellmate.cache.semantic_cache import preload_embedder_async
10
+ from shellmate.daemon.pipeline import run_pipeline_async
11
+ from shellmate.daemon.session import read_session, is_session_stale, validate_session_ownership
12
+
13
+ logger = logging.getLogger("shellmate.daemon")
14
+
15
+ MAX_REQUEST_SIZE = 1024 * 1024
16
+ READ_TIMEOUT = 30.0
17
+ WRITE_TIMEOUT = 30.0
18
+
19
+ STARTUP_STAGES = [
20
+ "binding_ipc",
21
+ "watcher_starting",
22
+ "watcher_ready",
23
+ "librarian_scanning",
24
+ "librarian_ready",
25
+ "providers_checked",
26
+ "ready",
27
+ ]
28
+
29
+ _startup_stage = "binding_ipc"
30
+ _startup_stages_completed: list[str] = []
31
+ _start_time = time.time()
32
+ _last_health_check = None
33
+ _provider_warnings: list[dict] = []
34
+
35
+
36
+ def get_provider_warnings():
37
+ """Non-fatal provider/config warnings surfaced at startup (e.g. an
38
+ unreachable risk model that would silently fall back to a static rating)."""
39
+ return list(_provider_warnings)
40
+
41
+
42
+ def set_provider_warnings(warnings: list[dict]):
43
+ global _provider_warnings
44
+ _provider_warnings = list(warnings or [])
45
+
46
+
47
+ def get_startup_stage():
48
+ global _startup_stage
49
+ return _startup_stage
50
+
51
+
52
+ def get_startup_stages_completed():
53
+ """Ordered milestones reached so far (for UI ordered reveal)."""
54
+ return list(_startup_stages_completed)
55
+
56
+
57
+ def set_startup_stage(stage: str):
58
+ global _startup_stage, _startup_stages_completed
59
+ if stage in STARTUP_STAGES:
60
+ _startup_stage = stage
61
+ if stage not in _startup_stages_completed:
62
+ _startup_stages_completed.append(stage)
63
+ logger.info(f"Startup stage: {stage}")
64
+
65
+
66
+ class ShellMateDaemon:
67
+ def __init__(self):
68
+ self.config = get_default_config()
69
+ self.server = None
70
+ self._running = False
71
+
72
+ async def handle_client(self, reader, writer):
73
+ request_id = None
74
+ client_addr = writer.get_extra_info('peername')
75
+ try:
76
+ data = await asyncio.wait_for(reader.read(MAX_REQUEST_SIZE), timeout=READ_TIMEOUT)
77
+ if not data:
78
+ writer.close()
79
+ return
80
+
81
+ if len(data) > MAX_REQUEST_SIZE:
82
+ response = {"status": "error", "request_id": "unknown", "error": "Request too large"}
83
+ writer.write(json.dumps(response).encode("utf-8"))
84
+ await writer.drain()
85
+ writer.close()
86
+ return
87
+
88
+ try:
89
+ message = json.loads(data.decode("utf-8"))
90
+ except json.JSONDecodeError as e:
91
+ response = {"status": "error", "request_id": "unknown", "error": f"Invalid JSON: {e}"}
92
+ writer.write(json.dumps(response).encode("utf-8"))
93
+ await writer.drain()
94
+ writer.close()
95
+ return
96
+
97
+ request_id = message.get("request_id", "unknown")
98
+ command = message.get("command", "")
99
+
100
+ logger.debug(f"Received command from {client_addr}: {command[:50]} (id={request_id})")
101
+
102
+ is_valid, error_msg = self._validate_ipc_request(command)
103
+ if not is_valid:
104
+ response = {"status": "error", "request_id": request_id, "error": error_msg}
105
+ writer.write(json.dumps(response).encode("utf-8"))
106
+ await writer.drain()
107
+ writer.close()
108
+ return
109
+
110
+ if command == "/status":
111
+ result = self._handle_status()
112
+ elif command == "/context-gatherer":
113
+ result = await self._handle_context_gatherer(message)
114
+ elif command == "/analyze-partial":
115
+ result = await self._handle_analyze_partial(message)
116
+ elif command == "/instant-check":
117
+ result = self._handle_instant_check(message)
118
+ elif command == "shutdown":
119
+ result = {"status": "ok", "message": "Shutting down"}
120
+ asyncio.create_task(self._shutdown())
121
+ elif command == "health":
122
+ result = {"status": "ok", "healthy": True}
123
+ else:
124
+ logger.info(f"Processing command from {client_addr}: {command[:50]}")
125
+ try:
126
+ from shellmate.orchestration.analysis_graph import get_analysis_graph
127
+ graph = get_analysis_graph()
128
+
129
+ # Extract v3 fields from message
130
+ user_queries = message.get("user_queries", [])
131
+ overrides = message.get("overrides", {})
132
+
133
+ result = await graph.analyze_command(command, user_queries=user_queries, overrides=overrides)
134
+ logger.info(f"Graph analysis completed for: {command[:50]}")
135
+ except ImportError:
136
+ result = await run_pipeline_async(command)
137
+ logger.info(f"Pipeline completed for: {command[:50]}")
138
+ except Exception as e:
139
+ logger.error(f"Pipeline error: {e}", exc_info=True)
140
+ raise
141
+
142
+ response = {"status": "ok", "request_id": request_id, **result}
143
+
144
+ except json.JSONDecodeError as e:
145
+ response = {"status": "error", "request_id": request_id, "error": f"Invalid JSON: {e}"}
146
+ except Exception as e:
147
+ logger.error(f"Error processing request from {client_addr}: {e}")
148
+ response = {"status": "error", "request_id": request_id, "error": str(e)}
149
+
150
+ try:
151
+ writer.write(json.dumps(response).encode("utf-8"))
152
+ await asyncio.wait_for(writer.drain(), timeout=WRITE_TIMEOUT)
153
+ except Exception as e:
154
+ logger.error(f"Error writing response: {e}")
155
+ finally:
156
+ writer.close()
157
+ await writer.wait_closed()
158
+
159
+ async def _handle_context_gatherer(self, message):
160
+ command = message.get("command", "") or message.get("data", {}).get("command", "")
161
+ request_id = message.get("request_id", "unknown")
162
+
163
+ if not command:
164
+ return {
165
+ "watcher": {},
166
+ "librarian": {"matches": []},
167
+ "timed_out": [],
168
+ "errors": {},
169
+ "partial": False,
170
+ "latency_ms": 0,
171
+ "agent_latency_ms": {"watcher": 0, "librarian": 0},
172
+ }
173
+
174
+ try:
175
+ from shellmate.orchestration.analysis_graph import get_analysis_graph
176
+ graph = get_analysis_graph()
177
+ result = await graph.gather_context(command)
178
+ return result
179
+ except ImportError:
180
+ from shellmate.agents.context_gatherer import get_context_gatherer
181
+ gatherer = get_context_gatherer()
182
+ result = await gatherer.gather(command)
183
+ result["agent_latency_ms"] = {
184
+ "watcher": result.get("agent_latency", {}).get("watcher", 0),
185
+ "librarian": result.get("agent_latency", {}).get("librarian", 0),
186
+ }
187
+ return result
188
+
189
+ def _handle_instant_check(self, message):
190
+ """Instant catastrophic matcher — no API, target <5ms."""
191
+ cmd = message.get("partial") or message.get("command_text") or message.get("data", {}).get("command", "")
192
+ if not cmd:
193
+ return {"matched": False, "error": "No command"}
194
+ from shellmate.rules.catastrophic_matcher import match_catastrophic
195
+ result = match_catastrophic(cmd)
196
+ return {"pipeline": "instant_rules", **result}
197
+
198
+ async def _handle_analyze_partial(self, message):
199
+ """
200
+ Fast partial analysis for autocomplete mode using full agentic pipeline.
201
+
202
+ Pipeline flow for autocomplete:
203
+ 1. FAISS semantic cache (fastest ~10ms)
204
+ 2. Instant catastrophic rules (~1ms, no API)
205
+ 3. Full agentic pipeline with skip_context flag:
206
+ - Sanitizer (typo detection)
207
+ - Parser (command structure)
208
+ - Command chain agent (history patterns)
209
+ - Rules engine (static rules)
210
+ - Environment agent (production/dev detection, blast radius)
211
+ - Risk analyzer (API-based with debounce)
212
+ - Remediation (safe alternatives)
213
+ 4. Cache result in FAISS for future lookups
214
+ """
215
+ partial_cmd = message.get("partial", "")
216
+ overrides = dict(message.get("overrides", {}) or {})
217
+
218
+ if not partial_cmd:
219
+ return {"status": "error", "error": "No partial command"}
220
+
221
+ # Parse [@env=...], [?...], [!...] from typed partial (adapters pass raw buffer)
222
+ from shellmate.cli.parser import _extract_bracket_syntax
223
+
224
+ cleaned_partial, _queries, _directives, bracket_overrides = _extract_bracket_syntax(
225
+ partial_cmd
226
+ )
227
+ if bracket_overrides:
228
+ overrides.update(bracket_overrides)
229
+ if cleaned_partial:
230
+ partial_cmd = cleaned_partial
231
+
232
+ ascii_safe = bool(message.get("ascii_safe")) or os.name == "nt"
233
+
234
+ try:
235
+ # Step 1: Check FAISS semantic cache first (FASTEST ~10ms)
236
+ from shellmate.cache.semantic_cache import cache_lookup
237
+ cached_result = cache_lookup(partial_cmd)
238
+
239
+ if cached_result:
240
+ # Cache hit — ensure consequence_preview (synthesize for legacy entries)
241
+ consequence_preview = cached_result.get("consequence_preview")
242
+ if not consequence_preview or not consequence_preview.get("what_happens"):
243
+ from shellmate.parser.command_parser import parse_command
244
+ from shellmate.orchestration.analysis_graph import (
245
+ _build_what_happens,
246
+ _build_scope,
247
+ _is_reversible,
248
+ _map_risk_to_severity,
249
+ )
250
+ parsed = parse_command(partial_cmd) or {}
251
+ risk_analysis = {
252
+ "risk": cached_result.get("risk", "MEDIUM"),
253
+ "reason": cached_result.get("explanation", ""),
254
+ }
255
+ consequence_preview = {
256
+ "what_happens": _build_what_happens(parsed, risk_analysis, {}),
257
+ "scope": _build_scope({}, {}),
258
+ "reversible": _is_reversible(parsed, risk_analysis),
259
+ "affected_count": None,
260
+ "suggested_fix": None,
261
+ "severity": _map_risk_to_severity(risk_analysis["risk"]),
262
+ }
263
+ cache_env = cached_result.get("environment_context") or {}
264
+ if overrides.get("env") and not cache_env.get("type"):
265
+ cache_env = {**cache_env, "type": overrides["env"]}
266
+ annotation = self._format_pipeline_annotation(
267
+ {
268
+ "consequence_preview": consequence_preview,
269
+ "risk": cached_result.get("risk", "MEDIUM"),
270
+ "message": cached_result.get("explanation", ""),
271
+ "risk_analysis": {
272
+ "reason": cached_result.get("explanation", ""),
273
+ },
274
+ },
275
+ cache_env,
276
+ cached_result.get("chain_patterns") or {},
277
+ ascii_safe=ascii_safe,
278
+ )
279
+ if not annotation:
280
+ annotation = self._format_cached_annotation(
281
+ cached_result, ascii_safe=ascii_safe
282
+ )
283
+ return {
284
+ "annotation": annotation,
285
+ "severity": cached_result.get("risk", "MEDIUM"),
286
+ "instant": False,
287
+ "cached": True,
288
+ "pipeline": "cache",
289
+ "consequence_preview": consequence_preview,
290
+ "env_type": cache_env.get("type"),
291
+ }
292
+
293
+ # Step 2: Check instant tier (catastrophic commands) - no API dependency
294
+ from shellmate.rules.catastrophic_matcher import match_catastrophic
295
+ catastrophic = match_catastrophic(partial_cmd)
296
+
297
+ if catastrophic["matched"]:
298
+ from shellmate.parser.command_parser import parse_command
299
+ from shellmate.orchestration.analysis_graph import (
300
+ _build_what_happens,
301
+ _build_scope,
302
+ _is_reversible,
303
+ _map_risk_to_severity,
304
+ )
305
+ parsed = parse_command(partial_cmd) or {}
306
+ risk_analysis = {
307
+ "risk": "CRITICAL",
308
+ "reason": catastrophic.get("message", ""),
309
+ }
310
+ consequence_preview = {
311
+ "what_happens": _build_what_happens(parsed, risk_analysis, {}),
312
+ "scope": _build_scope({}, {}),
313
+ "reversible": _is_reversible(parsed, risk_analysis),
314
+ "affected_count": None,
315
+ "suggested_fix": None,
316
+ "severity": _map_risk_to_severity("CRITICAL"),
317
+ }
318
+ return {
319
+ "annotation": catastrophic["message"],
320
+ "severity": catastrophic["severity"],
321
+ "instant": True,
322
+ "cached": False,
323
+ "pipeline": "instant_rules",
324
+ "consequence_preview": consequence_preview,
325
+ }
326
+
327
+ # Step 3: Run full agentic pipeline with skip_context=True for speed
328
+ # This leverages all agents except file-system watcher/librarian:
329
+ # - Sanitizer: typo detection
330
+ # - Parser: command structure analysis
331
+ # - Command chain: recent command patterns
332
+ # - Rules: static rule matching
333
+ # - Environment: prod/dev detection, blast radius
334
+ # - Risk analyzer: AI-based risk classification
335
+ # - Remediation: safe alternatives generation
336
+
337
+ from shellmate.orchestration.analysis_graph import get_analysis_graph
338
+ graph = get_analysis_graph()
339
+
340
+ risk_result = await graph.analyze_command(
341
+ partial_cmd,
342
+ skip_context=True, # Skip watcher/librarian for speed
343
+ overrides=overrides,
344
+ user_queries=[] # No queries in autocomplete mode
345
+ )
346
+
347
+ # Step 4: Extract relevant data from pipeline result
348
+ env_context = risk_result.get("environment_context", {}) or {}
349
+ chain_patterns = risk_result.get("chain_patterns", {}) or {}
350
+
351
+ # Early-exit graph paths skip env_context_node — fetch badges directly
352
+ if not env_context.get("type"):
353
+ try:
354
+ from shellmate.agents.environment import get_environment_agent
355
+
356
+ env_agent = get_environment_agent()
357
+ env_response = await env_agent.send_request(
358
+ "detect_context",
359
+ data={"overrides": overrides},
360
+ timeout=0.05,
361
+ )
362
+ if env_response and env_response.status == "ok":
363
+ env_context = env_response.data or {}
364
+ risk_result["environment_context"] = env_context
365
+ except Exception as env_err:
366
+ logger.debug(f"Environment badge lookup failed: {env_err}")
367
+
368
+ # Step 5: Cache the result in FAISS for future lookups
369
+ from shellmate.cache.semantic_cache import cache_store
370
+ cache_store(partial_cmd, risk_result)
371
+
372
+ # Step 6: Format annotation with environment badges and chain warnings
373
+ annotation = self._format_pipeline_annotation(
374
+ risk_result,
375
+ env_context,
376
+ chain_patterns,
377
+ ascii_safe=ascii_safe,
378
+ )
379
+
380
+ return {
381
+ "annotation": annotation,
382
+ "severity": risk_result.get("risk", "MEDIUM"),
383
+ "instant": False,
384
+ "cached": False,
385
+ "pipeline": "full_agentic",
386
+ "env_type": env_context.get("type"),
387
+ "chain_detected": bool(chain_patterns.get("detected", [])),
388
+ "latency_ms": risk_result.get("total_ms", 0),
389
+ "consequence_preview": risk_result.get("consequence_preview"),
390
+ }
391
+
392
+ except Exception as e:
393
+ logger.error(f"Error in analyze_partial: {e}", exc_info=True)
394
+ # Silent fallback - return safe
395
+ return {
396
+ "annotation": "",
397
+ "severity": "SAFE",
398
+ "instant": False,
399
+ "cached": False,
400
+ "pipeline": "error",
401
+ "error": str(e),
402
+ "consequence_preview": {
403
+ "what_happens": "Analysis unavailable",
404
+ "scope": "unknown",
405
+ "reversible": True,
406
+ "affected_count": None,
407
+ "suggested_fix": None,
408
+ "severity": "info",
409
+ },
410
+ }
411
+
412
+ def _format_cached_annotation(self, cached_result: dict, *, ascii_safe: bool = False) -> str:
413
+ """Format annotation from cached result."""
414
+ from shellmate.ui.annotations import format_cached_annotation
415
+
416
+ return format_cached_annotation(cached_result, ascii_safe=ascii_safe)
417
+
418
+ def _format_pipeline_annotation(
419
+ self,
420
+ risk_result: dict,
421
+ env_context: dict,
422
+ chain_patterns: dict,
423
+ *,
424
+ ascii_safe: bool = False,
425
+ ) -> str:
426
+ """Format annotation from pipeline result with consequence preview."""
427
+ from shellmate.ui.annotations import format_pipeline_annotation
428
+
429
+ return format_pipeline_annotation(
430
+ risk_result, env_context, chain_patterns, ascii_safe=ascii_safe
431
+ )
432
+
433
+ def _format_legacy_annotation(
434
+ self,
435
+ risk_result: dict,
436
+ env_context: dict,
437
+ chain_patterns: dict,
438
+ *,
439
+ ascii_safe: bool = False,
440
+ ) -> str:
441
+ """Format annotation from full pipeline result with environment/chain context."""
442
+ from shellmate.ui.annotations import format_legacy_pipeline_annotation
443
+
444
+ return format_legacy_pipeline_annotation(
445
+ risk_result, env_context, chain_patterns, ascii_safe=ascii_safe
446
+ )
447
+
448
+ def _validate_ipc_request(self, command: str) -> tuple:
449
+ exempt_commands = ("/status", "health")
450
+ if command in exempt_commands:
451
+ return True, ""
452
+ session = read_session()
453
+ if not session:
454
+ return False, "No active session. Run 'sm enable' first."
455
+ if is_session_stale(session):
456
+ return False, "Session is stale (daemon process not running). Run 'sm enable' again."
457
+ if not validate_session_ownership(session):
458
+ return False, "Session owner mismatch. Run 'sm disable' and 'sm enable' again."
459
+ return True, ""
460
+
461
+ def _handle_status(self):
462
+ global _last_health_check
463
+ now = time.time()
464
+ _last_health_check = now
465
+ return {
466
+ "status": "ok",
467
+ "data": {
468
+ "startup_stage": get_startup_stage(),
469
+ "startup_stages_completed": get_startup_stages_completed(),
470
+ "uptime_seconds": now - _start_time,
471
+ "last_health_check": now,
472
+ "daemon_pid": os.getpid(),
473
+ "provider_warnings": get_provider_warnings(),
474
+ }
475
+ }
476
+
477
+ async def start(self):
478
+ cfg = self.config["daemon"]
479
+ host = cfg["host"]
480
+ port = cfg["port"]
481
+
482
+ self.server = await asyncio.start_server(
483
+ self.handle_client, host, port
484
+ )
485
+
486
+ addr = self.server.sockets[0].getsockname()
487
+ logger.info(f"Daemon listening on {addr[0]}:{addr[1]}")
488
+ self._running = True
489
+
490
+ async def serve(self):
491
+ async with self.server:
492
+ await self.server.serve_forever()
493
+
494
+ async def stop(self):
495
+ self._running = False
496
+ if self.server:
497
+ self.server.close()
498
+ await self.server.wait_closed()
499
+ logger.info("Daemon stopped")
500
+
501
+ async def _shutdown(self):
502
+ await self.stop()
503
+
504
+ @property
505
+ def is_running(self):
506
+ return self._running
507
+
508
+
509
+ def run_daemon():
510
+ log_dir = os.path.join(SHELLMATE_DIR, "logs")
511
+ os.makedirs(log_dir, exist_ok=True)
512
+
513
+ log_file = os.path.join(log_dir, "daemon.log")
514
+ file_handler = logging.handlers.RotatingFileHandler(
515
+ log_file, maxBytes=10485760, backupCount=5
516
+ )
517
+ file_handler.setFormatter(logging.Formatter(
518
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
519
+ ))
520
+
521
+ logging.basicConfig(
522
+ level=logging.INFO,
523
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
524
+ handlers=[file_handler, logging.StreamHandler()],
525
+ )
526
+
527
+ daemon = ShellMateDaemon()
528
+
529
+ async def main():
530
+ from shellmate.agents.base import get_agent_manager, AgentManager
531
+ from shellmate.agents.watcher import WatcherAgent
532
+ from shellmate.agents.librarian import LibrarianAgent
533
+ from shellmate.agents.environment import EnvironmentAgent
534
+ from shellmate.agents.command_chain import CommandChainAgent
535
+ from shellmate.agents.query import QueryAgent
536
+
537
+ agent_mgr = get_agent_manager()
538
+ watcher = WatcherAgent()
539
+ librarian = LibrarianAgent()
540
+ environment = EnvironmentAgent()
541
+ command_chain = CommandChainAgent()
542
+ query = QueryAgent()
543
+
544
+ agent_mgr.register(watcher)
545
+ agent_mgr.register(librarian)
546
+ agent_mgr.register(environment)
547
+ agent_mgr.register(command_chain)
548
+ agent_mgr.register(query)
549
+
550
+ set_startup_stage("binding_ipc")
551
+ await daemon.start()
552
+
553
+ set_startup_stage("watcher_starting")
554
+ set_startup_stage("librarian_scanning")
555
+
556
+ async def _start_watcher():
557
+ await watcher.start()
558
+ set_startup_stage("watcher_ready")
559
+
560
+ async def _start_librarian():
561
+ preload_embedder_async()
562
+ await librarian.start()
563
+ # Cap wait so a stuck indexer cannot block daemon READY forever
564
+ await librarian.wait_until_indexed(timeout=15.0)
565
+ set_startup_stage("librarian_ready")
566
+
567
+ async def _start_environment():
568
+ await environment.start()
569
+
570
+ async def _start_command_chain():
571
+ await command_chain.start()
572
+
573
+ async def _start_query():
574
+ await query.start()
575
+
576
+ await asyncio.gather(_start_watcher(), _start_librarian(), _start_environment(), _start_command_chain(), _start_query())
577
+
578
+ set_startup_stage("providers_checked")
579
+ # Verify the configured risk model is actually reachable. Best-effort and
580
+ # time-boxed so a slow/hanging provider API can never block READY.
581
+ try:
582
+ from shellmate.providers.router import check_risk_model_reachable
583
+ warning = await asyncio.wait_for(check_risk_model_reachable(), timeout=6.0)
584
+ if warning:
585
+ set_provider_warnings([warning])
586
+ logger.warning("Provider check: %s", warning.get("message"))
587
+ except Exception as e:
588
+ logger.debug(f"Risk-model reachability check skipped: {e}")
589
+
590
+ set_startup_stage("ready")
591
+
592
+ try:
593
+ await daemon.serve()
594
+ logger.info("Daemon server stopped")
595
+ except KeyboardInterrupt:
596
+ logger.info("Keyboard interrupt received")
597
+ await agent_mgr.stop_all()
598
+ await daemon.stop()
599
+ except Exception as e:
600
+ logger.error(f"Daemon error: {e}", exc_info=True)
601
+ await agent_mgr.stop_all()
602
+ await daemon.stop()
603
+ raise
604
+
605
+ asyncio.run(main())