@agentunion/kite 1.3.1 → 1.3.2

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.
@@ -6,6 +6,9 @@ Launcher handles process-level crashes; Watchdog handles app-level failures
6
6
 
7
7
  import asyncio
8
8
  import json
9
+ import os
10
+ import subprocess
11
+ import sys
9
12
  import time
10
13
  from datetime import datetime, timezone
11
14
 
@@ -85,6 +88,10 @@ class HealthMonitor:
85
88
  self._system_ready_event = asyncio.Event()
86
89
  self._crash_counts: dict[str, int] = {} # module_id -> consecutive crash count
87
90
 
91
+ # Launcher loss tracking
92
+ self._launcher_offline = False
93
+ self._launcher_had_exiting = False # True if module.exiting was received for launcher
94
+
88
95
  # ── Module discovery ──
89
96
 
90
97
  async def discover_modules(self):
@@ -364,6 +371,28 @@ class HealthMonitor:
364
371
  self._system_ready_event.set()
365
372
  return
366
373
 
374
+ # module.offline — track launcher state
375
+ if event_type == "module.offline":
376
+ if module_id == "launcher":
377
+ print("[watchdog] Received module.offline(launcher)")
378
+ self._launcher_offline = True
379
+ return
380
+
381
+ # module.shutdown with reason launcher_lost — decide whether to start new instance
382
+ if event_type == "module.shutdown":
383
+ reason = data.get("reason", "")
384
+ if reason == "launcher_lost":
385
+ print("[watchdog] Received module.shutdown(reason=launcher_lost)")
386
+ await self._handle_launcher_lost()
387
+ return
388
+ target = data.get("module_id", "")
389
+ if not target:
390
+ # Broadcast shutdown — check reason
391
+ if reason == "system_shutdown":
392
+ print(f"[watchdog] Received system_shutdown signal")
393
+ self._system_shutting_down = True
394
+ return
395
+
367
396
  if not module_id or module_id == "watchdog":
368
397
  return
369
398
 
@@ -381,17 +410,18 @@ class HealthMonitor:
381
410
  action = data.get("action", "none")
382
411
  print(f"[watchdog] Received module.exiting: {module_id}, action={action}")
383
412
  self._exit_intents[module_id] = action
413
+ # Track launcher exiting intent
414
+ if module_id == "launcher":
415
+ self._launcher_had_exiting = True
384
416
 
385
417
  elif event_type == "module.ready":
386
418
  graceful = bool(data.get("graceful_shutdown"))
387
419
  print(f"[watchdog] Received module.ready: {module_id}, graceful_shutdown={graceful}")
388
420
  self._graceful_modules[module_id] = graceful
389
-
390
- elif event_type == "module.shutdown":
391
- reason = data.get("reason", "")
392
- if reason == "system_shutdown":
393
- print(f"[watchdog] Received system_shutdown signal")
394
- self._system_shutting_down = True
421
+ # Reset launcher loss tracking when launcher reconnects
422
+ if module_id == "launcher":
423
+ self._launcher_offline = False
424
+ self._launcher_had_exiting = False
395
425
 
396
426
  async def _handle_module_stopped(self, module_id: str, data: dict):
397
427
  """Restart decision engine — called when module.stopped is received.
@@ -443,6 +473,82 @@ class HealthMonitor:
443
473
  "message": f"{module_id} exceeded {self.MAX_RESTARTS} crash restarts",
444
474
  })
445
475
 
476
+ async def _handle_launcher_lost(self):
477
+ """Handle launcher_lost: decide whether to start a new Kite instance.
478
+
479
+ - If launcher sent module.exiting before going offline → normal exit, follow suit
480
+ - If launcher did NOT send module.exiting → crash/unexpected, start new instance
481
+ """
482
+ if self._launcher_had_exiting:
483
+ print("[watchdog] Launcher had sent module.exiting before loss → normal exit, following suit")
484
+ sys.exit(0)
485
+ else:
486
+ print("[watchdog] Launcher lost without module.exiting → crash detected, starting new instance")
487
+ self._start_new_instance()
488
+ print("[watchdog] New instance started, exiting")
489
+ sys.exit(0)
490
+
491
+ def _start_new_instance(self):
492
+ """Start a new Kite instance by running python main.py in the project directory."""
493
+ project_dir = os.environ.get("KITE_PROJECT", "")
494
+ if not project_dir:
495
+ print("[watchdog] ERROR: KITE_PROJECT not set, cannot start new instance")
496
+ return
497
+
498
+ main_py = os.path.join(project_dir, "main.py")
499
+ if not os.path.exists(main_py):
500
+ print(f"[watchdog] ERROR: {main_py} not found, cannot start new instance")
501
+ return
502
+
503
+ print(f"[watchdog] Starting new Kite instance: python {main_py}")
504
+ try:
505
+ # Start detached process with new console window
506
+ if sys.platform == "win32":
507
+ # Use 'start' command to force visible console window
508
+ subprocess.Popen(
509
+ ["cmd", "/c", "start", "Kite", sys.executable, main_py],
510
+ cwd=project_dir,
511
+ shell=False,
512
+ )
513
+ elif sys.platform == "darwin":
514
+ # macOS: use 'open -a Terminal' to launch in new Terminal window
515
+ subprocess.Popen(
516
+ ["open", "-a", "Terminal", main_py],
517
+ cwd=project_dir,
518
+ )
519
+ else:
520
+ # Linux: try common terminal emulators
521
+ terminals = [
522
+ ["x-terminal-emulator", "-e"], # Debian/Ubuntu default
523
+ ["gnome-terminal", "--"], # GNOME
524
+ ["konsole", "-e"], # KDE
525
+ ["xterm", "-e"], # Fallback
526
+ ]
527
+ launched = False
528
+ for term_cmd in terminals:
529
+ try:
530
+ subprocess.Popen(
531
+ term_cmd + [sys.executable, main_py],
532
+ cwd=project_dir,
533
+ start_new_session=True,
534
+ )
535
+ launched = True
536
+ break
537
+ except FileNotFoundError:
538
+ continue
539
+ if not launched:
540
+ # Fallback: headless start
541
+ print("[watchdog] WARNING: No terminal emulator found, starting headless")
542
+ subprocess.Popen(
543
+ [sys.executable, main_py],
544
+ cwd=project_dir,
545
+ start_new_session=True,
546
+ stdin=subprocess.DEVNULL,
547
+ )
548
+ print("[watchdog] New Kite instance started successfully")
549
+ except Exception as e:
550
+ print(f"[watchdog] Failed to start new instance: {e}")
551
+
446
552
  async def _restart_module_by_id(self, module_id: str, reason: str = "restart"):
447
553
  """Restart a module via Launcher RPC by module_id."""
448
554
  print(f"[watchdog] Requesting restart for {module_id} (reason={reason})")
@@ -0,0 +1,249 @@
1
+ """Routes for module management — scan, view, and edit module metadata + config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+ from fastapi import APIRouter, HTTPException
13
+
14
+ from routes.schemas import ModuleConfigUpdate, ModuleMetadataUpdate
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ router = APIRouter(tags=["modules"])
19
+
20
+ # Fields that may NOT be changed via the metadata API
21
+ _READONLY_FIELDS = frozenset({"name", "type", "runtime", "entry"})
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Helpers
26
+ # ---------------------------------------------------------------------------
27
+
28
+ def _get_project_root() -> Path:
29
+ """Return the Kite project root directory."""
30
+ env = os.environ.get("KITE_PROJECT")
31
+ if env:
32
+ return Path(env)
33
+ # Fallback: __file__ is routes/routes_modules.py → up 5 levels to project root
34
+ # routes_modules.py → routes/ → web/ → services/ → extensions/ → Kite/
35
+ return Path(__file__).resolve().parents[4]
36
+
37
+
38
+ def _scan_modules() -> list[dict[str, Any]]:
39
+ """Discover all modules by scanning kernel/ (depth 0) and extensions/ (depth 2)."""
40
+ root = _get_project_root()
41
+ found: list[dict[str, Any]] = []
42
+
43
+ # 1. kernel/module.md
44
+ kernel_md = root / "kernel" / "module.md"
45
+ if kernel_md.is_file():
46
+ meta, _ = _parse_module_md(kernel_md)
47
+ if meta:
48
+ meta["_path"] = str(kernel_md)
49
+ meta["_dir"] = str(kernel_md.parent)
50
+ found.append(meta)
51
+
52
+ # 2. launcher/module.md
53
+ launcher_md = root / "launcher" / "module.md"
54
+ if launcher_md.is_file():
55
+ meta, _ = _parse_module_md(launcher_md)
56
+ if meta:
57
+ meta["_path"] = str(launcher_md)
58
+ meta["_dir"] = str(launcher_md.parent)
59
+ found.append(meta)
60
+
61
+ # 3. extensions/{category}/{name}/module.md (depth 2)
62
+ ext_dir = root / "extensions"
63
+ if ext_dir.is_dir():
64
+ for category in sorted(ext_dir.iterdir()):
65
+ if not category.is_dir() or category.name.startswith("."):
66
+ continue
67
+ for mod_dir in sorted(category.iterdir()):
68
+ if not mod_dir.is_dir() or mod_dir.name.startswith("."):
69
+ continue
70
+ md_path = mod_dir / "module.md"
71
+ if md_path.is_file():
72
+ meta, _ = _parse_module_md(md_path)
73
+ if meta:
74
+ meta["_path"] = str(md_path)
75
+ meta["_dir"] = str(mod_dir)
76
+ found.append(meta)
77
+
78
+ return found
79
+
80
+
81
+ _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)", re.DOTALL)
82
+
83
+
84
+ def _parse_module_md(path: Path) -> tuple[dict[str, Any], str]:
85
+ """Parse YAML frontmatter from a module.md file. Returns (frontmatter_dict, body_str)."""
86
+ try:
87
+ text = path.read_text(encoding="utf-8")
88
+ except Exception as exc:
89
+ logger.warning("Failed to read %s: %s", path, exc)
90
+ return {}, ""
91
+
92
+ m = _FRONTMATTER_RE.match(text)
93
+ if not m:
94
+ return {}, text
95
+
96
+ try:
97
+ fm = yaml.safe_load(m.group(1)) or {}
98
+ except yaml.YAMLError as exc:
99
+ logger.warning("Failed to parse frontmatter in %s: %s", path, exc)
100
+ return {}, text
101
+
102
+ return fm, m.group(2)
103
+
104
+
105
+ def _write_module_md(path: Path, frontmatter: dict[str, Any], body: str) -> None:
106
+ """Write frontmatter + body back to module.md."""
107
+ fm_str = yaml.dump(frontmatter, allow_unicode=True, sort_keys=False, default_flow_style=False).rstrip()
108
+ content = f"---\n{fm_str}\n---\n{body}"
109
+ path.write_text(content, encoding="utf-8")
110
+
111
+
112
+ def _find_module(name: str) -> dict[str, Any] | None:
113
+ """Find a single module by name."""
114
+ for m in _scan_modules():
115
+ if m.get("name") == name:
116
+ return m
117
+ return None
118
+
119
+
120
+ def _deep_merge(base: dict, overlay: dict) -> dict:
121
+ """Recursively merge overlay into base (mutates base)."""
122
+ for k, v in overlay.items():
123
+ if k in base and isinstance(base[k], dict) and isinstance(v, dict):
124
+ _deep_merge(base[k], v)
125
+ else:
126
+ base[k] = v
127
+ return base
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # API endpoints
132
+ # ---------------------------------------------------------------------------
133
+
134
+ @router.get("/modules")
135
+ async def list_modules():
136
+ """List all discovered modules."""
137
+ modules = _scan_modules()
138
+ result = []
139
+ for m in modules:
140
+ mod_dir = Path(m["_dir"])
141
+ has_config = (mod_dir / "config.yaml").is_file()
142
+ result.append({
143
+ "name": m.get("name", ""),
144
+ "display_name": m.get("display_name", ""),
145
+ "type": m.get("type", ""),
146
+ "state": m.get("state", "enabled"),
147
+ "version": m.get("version", ""),
148
+ "runtime": m.get("runtime", ""),
149
+ "preferred_port": m.get("preferred_port"),
150
+ "monitor": m.get("monitor"),
151
+ "has_config": has_config,
152
+ })
153
+ return result
154
+
155
+
156
+ @router.get("/modules/{name}")
157
+ async def get_module(name: str):
158
+ """Get module details including config.yaml content (if any)."""
159
+ m = _find_module(name)
160
+ if not m:
161
+ raise HTTPException(404, f"Module not found: {name}")
162
+
163
+ mod_dir = Path(m["_dir"])
164
+ md_path = Path(m["_path"])
165
+
166
+ # Parse full frontmatter
167
+ frontmatter, _ = _parse_module_md(md_path)
168
+
169
+ # Read config.yaml if present
170
+ config = None
171
+ config_path = mod_dir / "config.yaml"
172
+ if config_path.is_file():
173
+ try:
174
+ config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
175
+ except Exception as exc:
176
+ logger.warning("Failed to read config.yaml for %s: %s", name, exc)
177
+
178
+ return {
179
+ "name": frontmatter.get("name", name),
180
+ "display_name": frontmatter.get("display_name", ""),
181
+ "type": frontmatter.get("type", ""),
182
+ "state": frontmatter.get("state", "enabled"),
183
+ "version": frontmatter.get("version", ""),
184
+ "runtime": frontmatter.get("runtime", ""),
185
+ "entry": frontmatter.get("entry", ""),
186
+ "preferred_port": frontmatter.get("preferred_port"),
187
+ "advertise_ip": frontmatter.get("advertise_ip"),
188
+ "monitor": frontmatter.get("monitor"),
189
+ "events": frontmatter.get("events"),
190
+ "subscriptions": frontmatter.get("subscriptions"),
191
+ "depends_on": frontmatter.get("depends_on"),
192
+ "has_config": config is not None,
193
+ "config": config,
194
+ }
195
+
196
+
197
+ @router.put("/modules/{name}/metadata")
198
+ async def update_module_metadata(name: str, update: ModuleMetadataUpdate):
199
+ """Update module.md frontmatter (whitelisted fields only)."""
200
+ m = _find_module(name)
201
+ if not m:
202
+ raise HTTPException(404, f"Module not found: {name}")
203
+
204
+ md_path = Path(m["_path"])
205
+ frontmatter, body = _parse_module_md(md_path)
206
+ if not frontmatter:
207
+ raise HTTPException(500, "Failed to parse module.md frontmatter")
208
+
209
+ # Apply only non-None fields, excluding readonly
210
+ changes = update.model_dump(exclude_none=True)
211
+ for key in list(changes.keys()):
212
+ if key in _READONLY_FIELDS:
213
+ continue
214
+ frontmatter[key] = changes[key]
215
+
216
+ _write_module_md(md_path, frontmatter, body)
217
+ return {"ok": True, "name": name}
218
+
219
+
220
+ @router.put("/modules/{name}/config")
221
+ async def update_module_config(name: str, update: ModuleConfigUpdate):
222
+ """Deep-merge update into module's config.yaml."""
223
+ m = _find_module(name)
224
+ if not m:
225
+ raise HTTPException(404, f"Module not found: {name}")
226
+
227
+ mod_dir = Path(m["_dir"])
228
+ config_path = mod_dir / "config.yaml"
229
+
230
+ # Read existing config
231
+ existing: dict[str, Any] = {}
232
+ if config_path.is_file():
233
+ try:
234
+ existing = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
235
+ except Exception:
236
+ pass
237
+
238
+ # Deep merge
239
+ overlay = update.model_dump(exclude_none=True)
240
+ # Remove pydantic internal keys
241
+ overlay.pop("model_config", None)
242
+ _deep_merge(existing, overlay)
243
+
244
+ # Write back
245
+ config_path.write_text(
246
+ yaml.dump(existing, allow_unicode=True, sort_keys=False, default_flow_style=False),
247
+ encoding="utf-8",
248
+ )
249
+ return {"ok": True, "name": name, "config": existing}
@@ -214,3 +214,25 @@ class ConfigUpdate(BaseModel):
214
214
  """Arbitrary config update payload — accepts any key/value pairs."""
215
215
 
216
216
  model_config = {"extra": "allow"}
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # Modules
221
+ # ---------------------------------------------------------------------------
222
+
223
+ class ModuleMetadataUpdate(BaseModel):
224
+ """可更新的 module.md frontmatter 字段."""
225
+ display_name: Optional[str] = None
226
+ version: Optional[str] = None
227
+ state: Optional[str] = None # enabled | manual | disabled
228
+ preferred_port: Optional[int] = None
229
+ advertise_ip: Optional[str] = None
230
+ monitor: Optional[bool] = None
231
+ events: Optional[list[str]] = None
232
+ subscriptions: Optional[list[str]] = None
233
+ depends_on: Optional[list[str]] = None
234
+
235
+
236
+ class ModuleConfigUpdate(BaseModel):
237
+ """config.yaml 更新,接受任意 key/value."""
238
+ model_config = {"extra": "allow"}
@@ -30,6 +30,7 @@ from routes.routes_contacts import router as contacts_router
30
30
  from routes.routes_stats import router as stats_router
31
31
  from routes.routes_voicechat import router as voicechat_router
32
32
  from routes.routes_devlog import router as devlog_router
33
+ from routes.routes_modules import router as modules_router
33
34
 
34
35
  logger = logging.getLogger(__name__)
35
36
 
@@ -46,7 +47,6 @@ class WebServer:
46
47
  self._ws_task: asyncio.Task | None = None
47
48
  self._test_task: asyncio.Task | None = None
48
49
  self._ws: object | None = None
49
- self._ready_sent = False
50
50
  self._shutting_down = False
51
51
  self._uvicorn_server = None # set by entry.py for graceful shutdown
52
52
  self._start_time = time.time()
@@ -142,6 +142,7 @@ class WebServer:
142
142
  app.include_router(stats_router, prefix="/api")
143
143
  app.include_router(voicechat_router) # no prefix (has own /ws/ and /api/ paths)
144
144
  app.include_router(devlog_router, prefix="/api")
145
+ app.include_router(modules_router, prefix="/api")
145
146
 
146
147
  # Serve frontend static files
147
148
  static_dir = Path(__file__).parent / "static"
@@ -154,21 +155,34 @@ class WebServer:
154
155
 
155
156
  async def _ws_loop(self):
156
157
  """Connect to Kernel, subscribe, register, and listen. Reconnect on failure."""
157
- retry_delay = 0.5 # start with 0.5s
158
- max_delay = 30 # cap at 30s
158
+ retry_delay = 0.3
159
+ max_delay = 5.0
160
+ max_retries = 10
161
+ attempt = 0
159
162
  while not self._shutting_down:
160
163
  try:
161
164
  await self._ws_connect()
162
- retry_delay = 0.5 # reset on successful connection
165
+ retry_delay = 0.3 # reset on successful connection
166
+ attempt = 0
163
167
  except asyncio.CancelledError:
164
168
  return
165
169
  except Exception as e:
166
- print(f"[web] Kernel connection error: {e}, retrying in {retry_delay:.1f}s")
170
+ attempt += 1
171
+ # Auth failure — don't retry
172
+ if hasattr(e, 'rcvd') and e.rcvd is not None:
173
+ code = e.rcvd.code if hasattr(e.rcvd, 'code') else 0
174
+ if code in (4001, 4003):
175
+ print(f"[web] Kernel 认证失败 (code {code}),退出")
176
+ import sys; sys.exit(1)
177
+ if attempt >= max_retries:
178
+ print(f"[web] Kernel 重连失败 {max_retries} 次,退出")
179
+ import sys; sys.exit(1)
180
+ print(f"[web] Kernel connection error: {e}, retrying in {retry_delay:.1f}s ({attempt}/{max_retries})")
167
181
  self._ws = None
168
182
  if self._shutting_down:
169
183
  return
170
184
  await asyncio.sleep(retry_delay)
171
- retry_delay = min(retry_delay * 2, max_delay) # exponential backoff
185
+ retry_delay = min(retry_delay * 2, max_delay)
172
186
 
173
187
  async def _ws_connect(self):
174
188
  """Single WebSocket session: connect, register, subscribe, receive loop."""
@@ -207,8 +221,8 @@ class WebServer:
207
221
  })
208
222
  print(f"[web] Registered to Kernel{elapsed_str}")
209
223
 
210
- # Send module.ready (once) so Launcher knows we're up
211
- if not self._ready_sent:
224
+ # Send module.ready (every reconnect, not just first time)
225
+ if not self._shutting_down:
212
226
  await self._rpc_call(ws, "event.publish", {
213
227
  "event_id": str(uuid.uuid4()),
214
228
  "event": "module.ready",
@@ -217,7 +231,6 @@ class WebServer:
217
231
  "graceful_shutdown": True,
218
232
  },
219
233
  })
220
- self._ready_sent = True
221
234
  elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
222
235
  elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
223
236
  print(f"[web] module.ready sent{elapsed_str}")
@@ -269,10 +282,14 @@ class WebServer:
269
282
  event_type = params.get("event", "")
270
283
  data = params.get("data", {})
271
284
 
272
- # Special handling for module.shutdown targeting web
273
- if event_type == "module.shutdown" and data.get("module_id") == "web":
274
- await self._handle_shutdown()
275
- return
285
+ # Special handling for module.shutdown
286
+ if event_type == "module.shutdown":
287
+ target = data.get("module_id", "")
288
+ reason = data.get("reason", "")
289
+ # Handle both targeted shutdown (module_id == "web") and broadcast shutdown (no module_id or launcher_lost)
290
+ if target == "web" or not target or reason == "launcher_lost":
291
+ await self._handle_shutdown()
292
+ return
276
293
 
277
294
  # Log other events
278
295
  print(f"[web] Event received: {event_type}")
@@ -321,10 +338,16 @@ class WebServer:
321
338
  }
322
339
 
323
340
  async def _handle_shutdown(self):
324
- """Handle module.shutdown: ack → cleanup → ready → exit."""
341
+ """Handle module.shutdown: exiting → ack → cleanup → ready → exit."""
325
342
  print("[web] Received module.shutdown")
326
343
  self._shutting_down = True
327
344
 
345
+ # Step 0: Send module.exiting
346
+ await self._publish_event({
347
+ "event": "module.exiting",
348
+ "data": {"module_id": "web", "action": "none"},
349
+ })
350
+
328
351
  # Step 1: Send ack
329
352
  await self._publish_event({
330
353
  "event": "module.shutdown.ack",
@@ -1062,3 +1062,100 @@ html, body {
1062
1062
  .toggle-switch-label input:checked + .toggle-switch::after {
1063
1063
  transform: translateX(16px);
1064
1064
  }
1065
+
1066
+ /* ===== MODULES PAGE ===== */
1067
+ .modules-grid {
1068
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
1069
+ gap: 16px;
1070
+ }
1071
+ .module-card {
1072
+ background: var(--white); border-radius: var(--radius);
1073
+ box-shadow: var(--shadow); padding: 16px; cursor: pointer;
1074
+ border: 2px solid transparent; transition: all .15s;
1075
+ }
1076
+ .module-card:hover {
1077
+ border-color: var(--primary); box-shadow: var(--shadow-md);
1078
+ }
1079
+ .module-card-header {
1080
+ display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
1081
+ }
1082
+ .module-card-name {
1083
+ font-size: 16px; font-weight: 600; color: var(--gray-900);
1084
+ margin-bottom: 2px;
1085
+ }
1086
+ .module-card-display-name {
1087
+ font-size: 12px; color: var(--gray-500); margin-bottom: 10px;
1088
+ min-height: 16px;
1089
+ }
1090
+ .module-card-footer {
1091
+ display: flex; align-items: center; gap: 8px;
1092
+ min-height: 20px;
1093
+ }
1094
+ .module-version {
1095
+ font-size: 11px; color: var(--gray-400); font-weight: 500;
1096
+ }
1097
+
1098
+ /* Module type badges */
1099
+ .module-type-badge {
1100
+ display: inline-block; padding: 2px 8px; border-radius: 10px;
1101
+ font-size: 11px; font-weight: 600; text-transform: lowercase;
1102
+ background: var(--gray-100); color: var(--gray-500);
1103
+ }
1104
+ .module-type-badge.type-infrastructure { background: #f3e8ff; color: #7c3aed; }
1105
+ .module-type-badge.type-service { background: var(--primary-light); color: var(--primary); }
1106
+ .module-type-badge.type-channel { background: var(--success-light); color: var(--success); }
1107
+ .module-type-badge.type-agent { background: #fff7ed; color: #ea580c; }
1108
+ .module-type-badge.type-tool { background: var(--gray-100); color: var(--gray-500); }
1109
+
1110
+ /* Module state dots */
1111
+ .module-state-dot {
1112
+ display: inline-block; width: 10px; height: 10px; border-radius: 50%;
1113
+ flex-shrink: 0; background: var(--gray-300);
1114
+ }
1115
+ .module-state-dot.enabled { background: var(--success); }
1116
+ .module-state-dot.manual { background: var(--warning); }
1117
+ .module-state-dot.disabled { background: var(--gray-300); }
1118
+
1119
+ /* Config tree */
1120
+ .config-tree-group {
1121
+ margin-bottom: 4px;
1122
+ }
1123
+ .config-tree-key {
1124
+ font-size: 13px; font-weight: 600; color: var(--gray-700);
1125
+ padding: 6px 8px; cursor: pointer; border-radius: 4px;
1126
+ user-select: none; display: flex; align-items: center; gap: 4px;
1127
+ }
1128
+ .config-tree-key::before {
1129
+ content: '\25BE'; font-size: 10px; color: var(--gray-400);
1130
+ display: inline-block; transition: transform .15s;
1131
+ }
1132
+ .config-tree-group.collapsed .config-tree-key::before {
1133
+ transform: rotate(-90deg);
1134
+ }
1135
+ .config-tree-key:hover { background: var(--gray-50); }
1136
+ .config-tree-nested {
1137
+ margin-left: 12px; padding-left: 12px;
1138
+ border-left: 2px solid var(--gray-200);
1139
+ }
1140
+ .config-tree-group.collapsed .config-tree-nested { display: none; }
1141
+ .config-tree-leaf {
1142
+ display: flex; align-items: center; gap: 10px;
1143
+ padding: 4px 8px; margin-bottom: 2px;
1144
+ }
1145
+ .config-tree-label {
1146
+ font-size: 13px; color: var(--gray-600); min-width: 140px;
1147
+ font-weight: 500; flex-shrink: 0;
1148
+ }
1149
+ .config-tree-input {
1150
+ flex: 1; padding: 5px 10px; border: 1px solid var(--gray-300);
1151
+ border-radius: 6px; font-size: 13px; color: var(--gray-800);
1152
+ background: var(--white); transition: border-color .15s;
1153
+ min-width: 0;
1154
+ }
1155
+ .config-tree-input:focus {
1156
+ outline: none; border-color: var(--primary);
1157
+ box-shadow: 0 0 0 3px rgba(59,130,246,.15);
1158
+ }
1159
+ .config-tree-checkbox {
1160
+ width: 16px; height: 16px; cursor: pointer;
1161
+ }