@oneciel-ai/claude-any 0.1.75 → 0.1.77

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 (2) hide show
  1. package/claude_any.py +1008 -65
  2. package/package.json +1 -1
package/claude_any.py CHANGED
@@ -24,7 +24,7 @@ import urllib.request
24
24
  from email.utils import parsedate_to_datetime
25
25
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
26
26
  from pathlib import Path
27
- from typing import Any, Callable
27
+ from typing import Any, Callable, Iterable
28
28
 
29
29
  from claude_any_support.observability import EventBus, render_events_html
30
30
  from claude_any_support.transcript_filter import (
@@ -57,6 +57,8 @@ PID_PATH = CONFIG_DIR / "router.pid"
57
57
  MODEL_LIST_CACHE_PATH = CONFIG_DIR / "model-list-cache.json"
58
58
  WEB_TOOLS_MCP_CONFIG = CONFIG_DIR / "web-tools-mcp.json"
59
59
  DUCKDUCKGO_MCP_CONFIG = CONFIG_DIR / "duckduckgo-mcp.json"
60
+ CHANNEL_MCP_CONFIG = CONFIG_DIR / "channel-mcp.json"
61
+ MCP_PROXY_CONFIG = CONFIG_DIR / "mcp-proxy.json"
60
62
  ROUTER_HOST = os.environ.get("CLAUDE_ANY_ROUTER_CLIENT_HOST", "127.0.0.1").strip() or "127.0.0.1"
61
63
  ROUTER_PORT = 8799
62
64
  ROUTER_BASE = f"http://{ROUTER_HOST}:{ROUTER_PORT}"
@@ -102,7 +104,7 @@ OFFICIAL_CHANNEL_PLUGINS = {
102
104
  "fakechat": "plugin:fakechat@claude-plugins-official",
103
105
  }
104
106
  APP_NAME = "Claude Any"
105
- VERSION = "0.1.75"
107
+ VERSION = "0.1.77"
106
108
  CREDITS = "Credits: One Ciel LLC"
107
109
 
108
110
  LOG_LEVELS = {"SILENT": 0, "ERROR": 1, "WARN": 2, "INFO": 3, "DEBUG": 4, "TRACE": 5}
@@ -120,6 +122,8 @@ _CHAT_CONDITION = threading.Condition()
120
122
  _CHAT_NEXT_ID: int | None = None
121
123
  _CHANNEL_SSE_LOCK = threading.Lock()
122
124
  _CHANNEL_SSE_CONNECTIONS: dict[str, dict[str, Any]] = {}
125
+ _CHANNEL_MCP_LOCK = threading.Lock()
126
+ _CHANNEL_MCP_SESSIONS: dict[str, dict[str, Any]] = {}
123
127
  EVENT_BUS = EventBus()
124
128
  ADVISOR_FEEDBACK_MARKER = "CLAUDE_ANY_ADVISOR_FEEDBACK"
125
129
  PLAN_GUARD_MARKER = "[claude-any-plan-guard]"
@@ -1048,6 +1052,7 @@ UI_TEXT = {
1048
1052
  "advisor_model": "Advisor Model",
1049
1053
  "test": "Test compatibility",
1050
1054
  "options": "LLM options",
1055
+ "log_level": "Log level",
1051
1056
  "presets": "LLM presets",
1052
1057
  "context_setup": "Context setup",
1053
1058
  "timeout_preset": "Timeout preset",
@@ -1068,6 +1073,7 @@ UI_TEXT = {
1068
1073
  "advisor_model": "Advisor Model",
1069
1074
  "test": "호환성 테스트",
1070
1075
  "options": "LLM 옵션",
1076
+ "log_level": "로그 레벨",
1071
1077
  "presets": "LLM 프리셋",
1072
1078
  "context_setup": "컨텍스트 설정",
1073
1079
  "timeout_preset": "타임아웃 프리셋",
@@ -1088,6 +1094,7 @@ UI_TEXT = {
1088
1094
  "advisor_model": "Advisor Model",
1089
1095
  "test": "互換性テスト",
1090
1096
  "options": "LLMオプション",
1097
+ "log_level": "ログレベル",
1091
1098
  "presets": "LLMプリセット",
1092
1099
  "context_setup": "コンテキスト設定",
1093
1100
  "timeout_preset": "timeout プリセット",
@@ -1108,6 +1115,7 @@ UI_TEXT = {
1108
1115
  "advisor_model": "Advisor Model",
1109
1116
  "test": "兼容性测试",
1110
1117
  "options": "LLM 选项",
1118
+ "log_level": "日志级别",
1111
1119
  "presets": "LLM 预设",
1112
1120
  "context_setup": "上下文设置",
1113
1121
  "timeout_preset": "Timeout 预设",
@@ -2247,6 +2255,74 @@ def current_log_level() -> int:
2247
2255
  return level
2248
2256
 
2249
2257
 
2258
+ def reset_log_level_cache() -> None:
2259
+ _LOG_LEVEL_CACHE.update({"value": None, "checked_at": 0.0, "file_mtime": 0.0})
2260
+
2261
+
2262
+ def log_level_name(value: int | None = None) -> str:
2263
+ if value is None:
2264
+ value = current_log_level()
2265
+ return str(LOG_LEVEL_NAMES.get(int(value), value))
2266
+
2267
+
2268
+ def log_level_source() -> str:
2269
+ if LOG_LEVEL_PATH.exists():
2270
+ return "file"
2271
+ if os.environ.get("CLAUDE_ANY_LOG_LEVEL", "").strip():
2272
+ return "env"
2273
+ return "default"
2274
+
2275
+
2276
+ def log_level_status() -> str:
2277
+ return f"{log_level_name()} ({log_level_source()})"
2278
+
2279
+
2280
+ def normalize_log_level(value: str) -> str | None:
2281
+ raw = str(value or "").strip()
2282
+ if not raw:
2283
+ raise ValueError("log level is empty")
2284
+ upper = raw.upper()
2285
+ aliases = {
2286
+ "OFF": "SILENT",
2287
+ "NONE": "SILENT",
2288
+ "QUIET": "SILENT",
2289
+ "WARNING": "WARN",
2290
+ "WARNINGS": "WARN",
2291
+ }
2292
+ upper = aliases.get(upper, upper)
2293
+ if upper in ("DEFAULT", "RESET", "UNSET", "AUTO"):
2294
+ return None
2295
+ if upper in LOG_LEVELS:
2296
+ return upper
2297
+ if upper.isdigit():
2298
+ numeric = max(0, min(5, int(upper)))
2299
+ return LOG_LEVEL_NAMES[numeric]
2300
+ raise ValueError(f"unknown log level: {value}")
2301
+
2302
+
2303
+ def set_log_level_config(value: str) -> list[str]:
2304
+ try:
2305
+ level = normalize_log_level(value)
2306
+ except ValueError as exc:
2307
+ known = ", ".join(LOG_LEVELS)
2308
+ return [f"{exc}. Known levels: {known}, DEFAULT."]
2309
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
2310
+ if level is None:
2311
+ try:
2312
+ LOG_LEVEL_PATH.unlink()
2313
+ except FileNotFoundError:
2314
+ pass
2315
+ reset_log_level_cache()
2316
+ return [f"Log level reset to {log_level_status()}."]
2317
+ LOG_LEVEL_PATH.write_text(level + "\n", encoding="utf-8")
2318
+ try:
2319
+ os.chmod(LOG_LEVEL_PATH, 0o600)
2320
+ except Exception:
2321
+ pass
2322
+ reset_log_level_cache()
2323
+ return [f"Log level set to {level}."]
2324
+
2325
+
2250
2326
  def router_log(level: str, message: str) -> None:
2251
2327
  """Append a line to router.log if the active level allows it.
2252
2328
  Rotates router.log when it exceeds ROUTER_LOG_MAX_BYTES."""
@@ -4677,6 +4753,134 @@ def stop_channel_sse_connection(name: str | None = None) -> dict[str, Any]:
4677
4753
  return {"stopped": stopped, "connections": channel_sse_status()}
4678
4754
 
4679
4755
 
4756
+ def _channel_mcp_session_id() -> str:
4757
+ return f"s{os.getpid()}-{time.time_ns()}"
4758
+
4759
+
4760
+ def _channel_mcp_notification(message: dict[str, Any]) -> dict[str, Any]:
4761
+ text = re.sub(r"\s+", " ", str(message.get("message") or "")).strip()
4762
+ channel = str(message.get("channel") or "default")
4763
+ sender = str(message.get("sender_id") or "channel")
4764
+ prefix = f"[{channel}] {sender}"
4765
+ content = f"{prefix}: {text}" if text else prefix
4766
+ meta = message.get("meta") if isinstance(message.get("meta"), dict) else {}
4767
+ merged_meta = {
4768
+ **meta,
4769
+ "claude_any_message_id": message.get("id"),
4770
+ "channel": channel,
4771
+ "sender_id": sender,
4772
+ "thread_id": message.get("thread_id"),
4773
+ "parent_id": message.get("parent_id"),
4774
+ }
4775
+ return {
4776
+ "jsonrpc": "2.0",
4777
+ "method": "notifications/claude/channel",
4778
+ "params": {
4779
+ "content": content,
4780
+ "meta": merged_meta,
4781
+ },
4782
+ }
4783
+
4784
+
4785
+ def _write_sse_event(handler: BaseHTTPRequestHandler, event: str, data: Any, event_id: int | None = None) -> None:
4786
+ if event_id is not None:
4787
+ handler.wfile.write(f"id: {event_id}\n".encode("utf-8"))
4788
+ handler.wfile.write(f"event: {event}\n".encode("utf-8"))
4789
+ payload = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False, separators=(",", ":"))
4790
+ for line in payload.splitlines() or [""]:
4791
+ handler.wfile.write(f"data: {line}\n".encode("utf-8"))
4792
+ handler.wfile.write(b"\n")
4793
+ handler.wfile.flush()
4794
+
4795
+
4796
+ def handle_channel_mcp_get(handler: BaseHTTPRequestHandler, path: str) -> bool:
4797
+ if path == "/ca/mcp/health":
4798
+ write_json(handler, {"ok": True, "name": "claude-any-router", "sse": "/ca/mcp/sse"})
4799
+ return True
4800
+ if path != "/ca/mcp/sse":
4801
+ return False
4802
+ session = _channel_mcp_session_id()
4803
+ last_id = _chat_init_next_id() - 1
4804
+ with _CHANNEL_MCP_LOCK:
4805
+ _CHANNEL_MCP_SESSIONS[session] = {"created_at": time.time(), "last_id": last_id, "initialized": False}
4806
+ handler.send_response(200)
4807
+ handler.send_header("content-type", "text/event-stream")
4808
+ handler.send_header("cache-control", "no-cache")
4809
+ handler.send_header("connection", "close")
4810
+ handler.end_headers()
4811
+ _write_sse_event(handler, "endpoint", f"/ca/mcp/messages?session={urllib.parse.quote(session)}")
4812
+ try:
4813
+ while True:
4814
+ with _CHANNEL_MCP_LOCK:
4815
+ state = _CHANNEL_MCP_SESSIONS.get(session)
4816
+ if not state:
4817
+ return True
4818
+ last_id = int(state.get("last_id") or 0)
4819
+ messages = read_chat_messages(last_id, None, None, 100)
4820
+ if messages:
4821
+ for message in messages:
4822
+ last_id = max(last_id, int(message.get("id") or 0))
4823
+ _write_sse_event(handler, "message", _channel_mcp_notification(message), last_id)
4824
+ with _CHANNEL_MCP_LOCK:
4825
+ state = _CHANNEL_MCP_SESSIONS.get(session)
4826
+ if state:
4827
+ state["last_id"] = last_id
4828
+ continue
4829
+ handler.wfile.write(b": keepalive\n\n")
4830
+ handler.wfile.flush()
4831
+ with _CHAT_CONDITION:
4832
+ _CHAT_CONDITION.wait(timeout=15.0)
4833
+ except (BrokenPipeError, ConnectionError, ConnectionResetError):
4834
+ return True
4835
+ finally:
4836
+ with _CHANNEL_MCP_LOCK:
4837
+ _CHANNEL_MCP_SESSIONS.pop(session, None)
4838
+
4839
+
4840
+ def handle_channel_mcp_post(handler: BaseHTTPRequestHandler, path: str, body: dict[str, Any]) -> bool:
4841
+ if path != "/ca/mcp/messages":
4842
+ return False
4843
+ params = _query_params(handler)
4844
+ session = _first_param(params, "session")
4845
+ with _CHANNEL_MCP_LOCK:
4846
+ if session and session in _CHANNEL_MCP_SESSIONS:
4847
+ _CHANNEL_MCP_SESSIONS[session]["last_seen_at"] = time.time()
4848
+ method = str(body.get("method") or "")
4849
+ request_id = body.get("id")
4850
+ if method == "initialize":
4851
+ protocol = "2024-11-05"
4852
+ req_params = body.get("params") if isinstance(body.get("params"), dict) else {}
4853
+ if req_params.get("protocolVersion"):
4854
+ protocol = str(req_params["protocolVersion"])
4855
+ with _CHANNEL_MCP_LOCK:
4856
+ if session and session in _CHANNEL_MCP_SESSIONS:
4857
+ _CHANNEL_MCP_SESSIONS[session]["initialized"] = True
4858
+ write_json(
4859
+ handler,
4860
+ {
4861
+ "jsonrpc": "2.0",
4862
+ "id": request_id,
4863
+ "result": {
4864
+ "protocolVersion": protocol,
4865
+ "capabilities": {"tools": {"listChanged": False}},
4866
+ "serverInfo": {"name": "claude-any-router", "version": VERSION},
4867
+ },
4868
+ },
4869
+ )
4870
+ return True
4871
+ if method == "tools/list":
4872
+ write_json(handler, {"jsonrpc": "2.0", "id": request_id, "result": {"tools": []}})
4873
+ return True
4874
+ if method == "ping":
4875
+ write_json(handler, {"jsonrpc": "2.0", "id": request_id, "result": {}})
4876
+ return True
4877
+ if request_id is None:
4878
+ write_json(handler, {"ok": True})
4879
+ return True
4880
+ write_json(handler, {"jsonrpc": "2.0", "id": request_id, "result": {}})
4881
+ return True
4882
+
4883
+
4680
4884
  def _query_params(handler: BaseHTTPRequestHandler) -> dict[str, list[str]]:
4681
4885
  return urllib.parse.parse_qs(urllib.parse.urlparse(handler.path).query, keep_blank_values=True)
4682
4886
 
@@ -8123,6 +8327,8 @@ class RouterHandler(BaseHTTPRequestHandler):
8123
8327
  return
8124
8328
  if handle_llm_config_get(self, path):
8125
8329
  return
8330
+ if handle_channel_mcp_get(self, path):
8331
+ return
8126
8332
  if handle_chat_get(self, path) or handle_plan_get(self, path):
8127
8333
  return
8128
8334
  provider, pcfg = get_current_provider(cfg)
@@ -8153,6 +8359,8 @@ class RouterHandler(BaseHTTPRequestHandler):
8153
8359
  body = parse_json_body(raw)
8154
8360
  if handle_llm_config_post(self, path, body):
8155
8361
  return
8362
+ if handle_channel_mcp_post(self, path, body):
8363
+ return
8156
8364
  if handle_chat_post(self, path, body) or handle_plan_post(self, path, body):
8157
8365
  return
8158
8366
  provider, pcfg = get_current_provider(cfg)
@@ -8645,6 +8853,7 @@ def status_lines() -> list[str]:
8645
8853
  *([f"request_timeout_ms: {pcfg.get('request_timeout_ms', 'default')}"] if provider in ("vllm", "nvidia-hosted", "self-hosted-nim") else []),
8646
8854
  *([f"stream_idle_timeout_ms: {pcfg.get('stream_idle_timeout_ms', 'auto')}"] if provider in ("vllm", "nvidia-hosted", "self-hosted-nim") else []),
8647
8855
  f"claude_model: {current_upstream_model_id(provider, pcfg) if direct_native else current_alias(cfg)}",
8856
+ f"log_level: {log_level_status()}",
8648
8857
  f"channels: {channel_status_text(cfg)}",
8649
8858
  f"router: {'bypassed for native provider compatibility' if direct_native else (('up' if router_up() else 'down') + ' ' + ROUTER_BASE)}",
8650
8859
  f"config: {CONFIG_PATH}",
@@ -8655,6 +8864,20 @@ def cmd_status(_: argparse.Namespace) -> None:
8655
8864
  print("\n".join(status_lines()))
8656
8865
 
8657
8866
 
8867
+ def cmd_log_level(args: argparse.Namespace) -> None:
8868
+ value = getattr(args, "value", None)
8869
+ if not value:
8870
+ print(f"log_level: {log_level_status()}")
8871
+ for numeric in sorted(LOG_LEVEL_NAMES):
8872
+ name = LOG_LEVEL_NAMES[numeric]
8873
+ mark = "*" if name == log_level_name() else " "
8874
+ print(f" {mark} {name:<6} {numeric}")
8875
+ print(" DEFAULT reset to environment/default")
8876
+ return
8877
+ for line in set_log_level_config(str(value)):
8878
+ print(line)
8879
+
8880
+
8658
8881
  def cmd_language(args: argparse.Namespace) -> None:
8659
8882
  cfg = load_config()
8660
8883
  if not args.value:
@@ -8759,13 +8982,300 @@ def channel_specs(cfg: dict[str, Any] | None = None) -> list[str]:
8759
8982
  return channels
8760
8983
 
8761
8984
 
8762
- def is_channel_spec_tagged(spec: str) -> bool:
8763
- return spec.startswith("plugin:") or spec.startswith("server:")
8985
+ def _dedupe_strings(values: Iterable[str]) -> list[str]:
8986
+ out: list[str] = []
8987
+ seen: set[str] = set()
8988
+ for value in values:
8989
+ text = str(value or "").strip()
8990
+ if not text or text in seen:
8991
+ continue
8992
+ seen.add(text)
8993
+ out.append(text)
8994
+ return out
8764
8995
 
8765
8996
 
8766
- def channel_development_enabled(cfg: dict[str, Any] | None = None) -> bool:
8767
- cfg = cfg or load_config()
8768
- return bool(cfg.setdefault("claude_code", {}).get("development_channels", False))
8997
+ def _path_for_compare(path: Path | str) -> str:
8998
+ try:
8999
+ return str(Path(path).expanduser().resolve()).replace("\\", "/").rstrip("/").casefold()
9000
+ except Exception:
9001
+ return str(path).replace("\\", "/").rstrip("/").casefold()
9002
+
9003
+
9004
+ def _project_key_matches_cwd(project_key: str, cwd: Path) -> bool:
9005
+ key = str(project_key or "").strip()
9006
+ if not key:
9007
+ return False
9008
+ try:
9009
+ project_path = Path(key).expanduser()
9010
+ except Exception:
9011
+ return False
9012
+ if not project_path.is_absolute():
9013
+ return False
9014
+ project = _path_for_compare(project_path)
9015
+ current = _path_for_compare(cwd)
9016
+ return current == project or current.startswith(project + "/")
9017
+
9018
+
9019
+ def _mcp_server_names_from_mapping(mapping: Any) -> list[str]:
9020
+ if not isinstance(mapping, dict):
9021
+ return []
9022
+ names: list[str] = []
9023
+ for key in ("mcpServers", "servers"):
9024
+ servers = mapping.get(key)
9025
+ if isinstance(servers, dict):
9026
+ names.extend(str(name).strip() for name in servers if str(name).strip())
9027
+ return _dedupe_strings(names)
9028
+
9029
+
9030
+ def _mcp_servers_from_mapping(mapping: Any) -> list[tuple[str, dict[str, Any]]]:
9031
+ if not isinstance(mapping, dict):
9032
+ return []
9033
+ found: list[tuple[str, dict[str, Any]]] = []
9034
+ seen: set[str] = set()
9035
+ for key in ("mcpServers", "servers"):
9036
+ servers = mapping.get(key)
9037
+ if not isinstance(servers, dict):
9038
+ continue
9039
+ for raw_name, raw_server in servers.items():
9040
+ name = str(raw_name or "").strip()
9041
+ if not name or name in seen or not isinstance(raw_server, dict):
9042
+ continue
9043
+ seen.add(name)
9044
+ found.append((name, dict(raw_server)))
9045
+ return found
9046
+
9047
+
9048
+ def _read_mcp_server_names_from_json(path: Path, cwd: Path) -> list[str]:
9049
+ try:
9050
+ data = json.loads(path.read_text(encoding="utf-8"))
9051
+ except Exception:
9052
+ return []
9053
+ names = _mcp_server_names_from_mapping(data)
9054
+ if path.name == ".claude.json" and isinstance(data, dict):
9055
+ projects = data.get("projects")
9056
+ if isinstance(projects, dict):
9057
+ for project_key, project_data in projects.items():
9058
+ if _project_key_matches_cwd(str(project_key), cwd):
9059
+ names.extend(_mcp_server_names_from_mapping(project_data))
9060
+ return _dedupe_strings(names)
9061
+
9062
+
9063
+ def _read_mcp_servers_from_json(path: Path, cwd: Path) -> list[tuple[str, dict[str, Any]]]:
9064
+ try:
9065
+ data = json.loads(path.read_text(encoding="utf-8"))
9066
+ except Exception:
9067
+ return []
9068
+ servers = _mcp_servers_from_mapping(data)
9069
+ if path.name == ".claude.json" and isinstance(data, dict):
9070
+ projects = data.get("projects")
9071
+ if isinstance(projects, dict):
9072
+ for project_key, project_data in projects.items():
9073
+ if _project_key_matches_cwd(str(project_key), cwd):
9074
+ servers.extend(_mcp_servers_from_mapping(project_data))
9075
+ out: list[tuple[str, dict[str, Any]]] = []
9076
+ seen: set[str] = set()
9077
+ for name, server in servers:
9078
+ if name in seen:
9079
+ continue
9080
+ seen.add(name)
9081
+ out.append((name, server))
9082
+ return out
9083
+
9084
+
9085
+ def _mcp_server_is_stdio(server: dict[str, Any]) -> bool:
9086
+ if not isinstance(server, dict):
9087
+ return False
9088
+ server_type = str(server.get("type") or "").strip().lower()
9089
+ if server_type and server_type not in ("stdio", "command"):
9090
+ return False
9091
+ command = str(server.get("command") or "").strip()
9092
+ if not command:
9093
+ return False
9094
+ joined = " ".join([command, *[str(item) for item in server.get("args", []) if item is not None]])
9095
+ return "mcp-proxy" not in joined
9096
+
9097
+
9098
+ def _mcp_config_passthrough_values(passthrough: list[str]) -> list[str]:
9099
+ values: list[str] = []
9100
+ i = 0
9101
+ while i < len(passthrough):
9102
+ arg = passthrough[i]
9103
+ if arg == "--mcp-config":
9104
+ i += 1
9105
+ while i < len(passthrough) and not passthrough[i].startswith("-"):
9106
+ values.append(passthrough[i])
9107
+ i += 1
9108
+ continue
9109
+ if arg.startswith("--mcp-config="):
9110
+ value = arg.split("=", 1)[1].strip()
9111
+ if value:
9112
+ values.append(value)
9113
+ i += 1
9114
+ return values
9115
+
9116
+
9117
+ def strip_mcp_config_passthrough(passthrough: list[str]) -> list[str]:
9118
+ stripped: list[str] = []
9119
+ i = 0
9120
+ while i < len(passthrough):
9121
+ arg = passthrough[i]
9122
+ if arg == "--mcp-config":
9123
+ i += 1
9124
+ while i < len(passthrough) and not passthrough[i].startswith("-"):
9125
+ i += 1
9126
+ continue
9127
+ if arg.startswith("--mcp-config="):
9128
+ i += 1
9129
+ continue
9130
+ stripped.append(arg)
9131
+ i += 1
9132
+ return stripped
9133
+
9134
+
9135
+ def _safe_mcp_proxy_name(name: str) -> str:
9136
+ safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", name.strip())
9137
+ return safe[:80] or "server"
9138
+
9139
+
9140
+ def _mcp_config_paths_from_passthrough(passthrough: list[str]) -> list[Path]:
9141
+ return [Path(value).expanduser() for value in _mcp_config_passthrough_values(passthrough)]
9142
+
9143
+
9144
+ def claude_mcp_config_paths(passthrough: list[str] | None = None, cwd: Path | None = None, home: Path | None = None) -> list[Path]:
9145
+ cwd = cwd or Path.cwd()
9146
+ home = home or HOME
9147
+ paths: list[Path] = []
9148
+ paths.extend(_mcp_config_paths_from_passthrough(passthrough or []))
9149
+ current = cwd
9150
+ visited: set[str] = set()
9151
+ while True:
9152
+ key = _path_for_compare(current)
9153
+ if key in visited:
9154
+ break
9155
+ visited.add(key)
9156
+ paths.append(current / ".mcp.json")
9157
+ if current == current.parent:
9158
+ break
9159
+ current = current.parent
9160
+ paths.extend([
9161
+ home / ".mcp.json",
9162
+ home / ".claude" / "settings.json",
9163
+ home / ".claude.json",
9164
+ ])
9165
+ out: list[Path] = []
9166
+ seen: set[str] = set()
9167
+ for path in paths:
9168
+ key = _path_for_compare(path)
9169
+ if key in seen:
9170
+ continue
9171
+ seen.add(key)
9172
+ out.append(path)
9173
+ return out
9174
+
9175
+
9176
+ def auto_discovered_mcp_channel_specs(
9177
+ passthrough: list[str] | None = None,
9178
+ cwd: Path | None = None,
9179
+ home: Path | None = None,
9180
+ ) -> list[str]:
9181
+ cwd = cwd or Path.cwd()
9182
+ specs: list[str] = []
9183
+ for path in claude_mcp_config_paths(passthrough, cwd, home):
9184
+ if not path.exists() or not path.is_file():
9185
+ continue
9186
+ for name in _read_mcp_server_names_from_json(path, cwd):
9187
+ if re.search(r"\s", name):
9188
+ continue
9189
+ specs.append(f"server:{name}" if not is_channel_spec_tagged(name) else name)
9190
+ return _dedupe_strings(specs)
9191
+
9192
+
9193
+ def _mcp_sse_servers_from_mapping(mapping: Any) -> list[dict[str, Any]]:
9194
+ if not isinstance(mapping, dict):
9195
+ return []
9196
+ found: list[dict[str, Any]] = []
9197
+ for key in ("mcpServers", "servers"):
9198
+ servers = mapping.get(key)
9199
+ if not isinstance(servers, dict):
9200
+ continue
9201
+ for raw_name, raw_server in servers.items():
9202
+ name = str(raw_name or "").strip()
9203
+ if not name or not isinstance(raw_server, dict):
9204
+ continue
9205
+ url = str(raw_server.get("url") or raw_server.get("endpoint") or "").strip()
9206
+ if not url.startswith(("http://", "https://")):
9207
+ continue
9208
+ server_type = str(raw_server.get("type") or "").strip().lower()
9209
+ if server_type and server_type not in ("sse", "http", "streamable-http"):
9210
+ continue
9211
+ headers = raw_server.get("headers") if isinstance(raw_server.get("headers"), dict) else {}
9212
+ found.append(
9213
+ {
9214
+ "name": f"mcp-{name}",
9215
+ "url": url,
9216
+ "headers": {str(k): str(v) for k, v in headers.items() if str(k).strip()},
9217
+ "channel": name,
9218
+ "sender_id": name,
9219
+ "recipient": "all",
9220
+ "mcp": True,
9221
+ }
9222
+ )
9223
+ return found
9224
+
9225
+
9226
+ def _read_mcp_sse_servers_from_json(path: Path, cwd: Path) -> list[dict[str, Any]]:
9227
+ try:
9228
+ data = json.loads(path.read_text(encoding="utf-8"))
9229
+ except Exception:
9230
+ return []
9231
+ servers = _mcp_sse_servers_from_mapping(data)
9232
+ if path.name == ".claude.json" and isinstance(data, dict):
9233
+ projects = data.get("projects")
9234
+ if isinstance(projects, dict):
9235
+ for project_key, project_data in projects.items():
9236
+ if _project_key_matches_cwd(str(project_key), cwd):
9237
+ servers.extend(_mcp_sse_servers_from_mapping(project_data))
9238
+ out: list[dict[str, Any]] = []
9239
+ seen: set[str] = set()
9240
+ for server in servers:
9241
+ key = f"{server.get('name')}|{server.get('url')}"
9242
+ if key in seen:
9243
+ continue
9244
+ seen.add(key)
9245
+ out.append(server)
9246
+ return out
9247
+
9248
+
9249
+ def auto_start_sse_channels_from_mcp_configs(
9250
+ passthrough: list[str] | None = None,
9251
+ cwd: Path | None = None,
9252
+ home: Path | None = None,
9253
+ ) -> list[dict[str, Any]]:
9254
+ cwd = cwd or Path.cwd()
9255
+ started: list[dict[str, Any]] = []
9256
+ for path in claude_mcp_config_paths(passthrough, cwd, home):
9257
+ if not path.exists() or not path.is_file():
9258
+ continue
9259
+ for server in _read_mcp_sse_servers_from_json(path, cwd):
9260
+ try:
9261
+ status = start_channel_sse_connection(server)
9262
+ started.append(status)
9263
+ router_log("INFO", f"channel_sse_auto_started name={status.get('name')} url={status.get('url')}")
9264
+ except Exception as exc:
9265
+ router_log("WARN", f"channel_sse_auto_start_failed path={path} error={type(exc).__name__}: {exc}")
9266
+ return started
9267
+
9268
+
9269
+ def channel_specs_for_launch(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> list[str]:
9270
+ configured = [spec for spec in channel_specs(cfg) if is_channel_spec_tagged(spec)]
9271
+ specs = configured
9272
+ if extra_specs:
9273
+ specs = [*specs, *extra_specs]
9274
+ return _dedupe_strings(spec for spec in specs if is_channel_spec_tagged(spec))
9275
+
9276
+
9277
+ def is_channel_spec_tagged(spec: str) -> bool:
9278
+ return spec.startswith("plugin:") or spec.startswith("server:")
8769
9279
 
8770
9280
 
8771
9281
  def channel_status_text(cfg: dict[str, Any] | None = None) -> str:
@@ -8773,15 +9283,11 @@ def channel_status_text(cfg: dict[str, Any] | None = None) -> str:
8773
9283
  channels = channel_specs(cfg)
8774
9284
  if not channels:
8775
9285
  return "off"
8776
- suffix = "; dev" if channel_development_enabled(cfg) else ""
8777
- return f"{len(channels)} channel{'s' if len(channels) != 1 else ''}{suffix}"
9286
+ return f"{len(channels)} channel{'s' if len(channels) != 1 else ''}"
8778
9287
 
8779
9288
 
8780
9289
  def set_channel_development_enabled(enabled: bool) -> list[str]:
8781
- cfg = load_config()
8782
- cfg.setdefault("claude_code", {})["development_channels"] = bool(enabled)
8783
- save_config(cfg)
8784
- return [f"Development channels: {'on' if enabled else 'off'}."]
9290
+ return ["Channel wake delivery is always enabled by Claude Any."]
8785
9291
 
8786
9292
 
8787
9293
  def add_channel_spec(spec: str, *, development: bool = False) -> list[str]:
@@ -8796,13 +9302,8 @@ def add_channel_spec(spec: str, *, development: bool = False) -> list[str]:
8796
9302
  if spec not in channels:
8797
9303
  channels.append(spec)
8798
9304
  cc["channels"] = channels
8799
- if development:
8800
- cc["development_channels"] = True
8801
9305
  save_config(cfg)
8802
- lines = [f"Channel added: {spec}."]
8803
- if development:
8804
- lines.append("Development channels: on.")
8805
- return lines
9306
+ return [f"Channel added: {spec}."]
8806
9307
 
8807
9308
 
8808
9309
  def remove_channel_spec(spec: str) -> list[str]:
@@ -8833,7 +9334,6 @@ def cmd_channels(args: argparse.Namespace) -> None:
8833
9334
  for spec in channel_specs(cfg):
8834
9335
  if spec not in OFFICIAL_CHANNEL_PLUGINS.values():
8835
9336
  print(f" * custom {spec}")
8836
- print(f"development_channels: {'on' if channel_development_enabled(cfg) else 'off'}")
8837
9337
  return
8838
9338
  head = values[0].strip().lower()
8839
9339
  if head in ("on", "enable", "add"):
@@ -8844,13 +9344,12 @@ def cmd_channels(args: argparse.Namespace) -> None:
8844
9344
  return
8845
9345
  if head in ("dev", "development"):
8846
9346
  if len(values) >= 2 and values[1].lower() in ("on", "off", "true", "false", "1", "0"):
8847
- enabled = values[1].lower() in ("on", "true", "1")
8848
- for line in set_channel_development_enabled(enabled):
9347
+ for line in set_channel_development_enabled(True):
8849
9348
  print(line)
8850
9349
  return
8851
9350
  if len(values) < 2:
8852
- raise SystemExit("Usage: claude-any channels dev CHANNEL_SPEC | claude-any channels dev on|off")
8853
- for line in add_channel_spec(values[1], development=True):
9351
+ raise SystemExit("Usage: claude-any channels add CHANNEL_SPEC")
9352
+ for line in add_channel_spec(values[1]):
8854
9353
  print(line)
8855
9354
  return
8856
9355
  if head in ("off", "disable", "remove", "rm"):
@@ -12062,7 +12561,7 @@ def main_menu_rows(cfg: dict[str, Any], provider: str, pcfg: dict[str, Any], lan
12062
12561
  f"4. {ui_text('model', lang)} [{compact_text(pcfg.get('current_model', 'unset'), 62)}]",
12063
12562
  f"5. {ui_text('advisor_model', lang)} [{compact_text(pcfg.get('advisor_model') or 'off', 62)}]",
12064
12563
  f"6. {ui_text('options', lang)} [{compact_text(llm_options_status(provider, pcfg), 62)}]",
12065
- f"7. Channels [{channel_status_text(cfg)}]",
12564
+ f"7. {ui_text('log_level', lang)} [{log_level_status()}]",
12066
12565
  f"8. {ui_text('test', lang)}",
12067
12566
  f"9. {ui_text('launch', lang)}",
12068
12567
  ui_text("quit", lang),
@@ -12092,6 +12591,30 @@ def language_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
12092
12591
  return rows, values
12093
12592
 
12094
12593
 
12594
+ def log_level_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
12595
+ rows: list[str] = []
12596
+ values: list[str] = []
12597
+ current = log_level_name()
12598
+ descriptions = {
12599
+ "SILENT": "no router log writes",
12600
+ "ERROR": "errors only",
12601
+ "WARN": "warnings and errors",
12602
+ "INFO": "normal diagnostics",
12603
+ "DEBUG": "verbose diagnostics",
12604
+ "TRACE": "request/response trace detail",
12605
+ }
12606
+ for numeric in sorted(LOG_LEVEL_NAMES):
12607
+ name = LOG_LEVEL_NAMES[numeric]
12608
+ mark = "*" if name == current else " "
12609
+ rows.append(f"{mark} {name:<6} {numeric} {descriptions.get(name, '')}")
12610
+ values.append(name)
12611
+ rows.append(f"Reset to default/env [{log_level_status()}]")
12612
+ values.append("DEFAULT")
12613
+ rows.append(ui_text("back", cfg.get("language", "en")))
12614
+ values.append("back")
12615
+ return rows, values
12616
+
12617
+
12095
12618
  def model_panel_rows(provider: str, pcfg: dict[str, Any]) -> tuple[list[str], list[str]]:
12096
12619
  values = unique_model_ids(provider, upstream_model_ids(provider, pcfg))
12097
12620
  rows: list[str] = []
@@ -12138,16 +12661,13 @@ def advisor_model_panel_rows(provider: str, pcfg: dict[str, Any]) -> tuple[list[
12138
12661
 
12139
12662
  def channel_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
12140
12663
  channels = channel_specs(cfg)
12141
- dev_enabled = channel_development_enabled(cfg)
12142
12664
  rows: list[str] = []
12143
12665
  values: list[str] = []
12144
- rows.append(f"Development channel loading [{'on' if dev_enabled else 'off'}]")
12145
- values.append("__toggle_dev__")
12146
12666
  for name, spec in OFFICIAL_CHANNEL_PLUGINS.items():
12147
12667
  mark = "*" if spec in channels else " "
12148
12668
  rows.append(f"{mark} {name:<10} {spec}")
12149
12669
  values.append(spec)
12150
- rows.append("+ Add development/custom channel...")
12670
+ rows.append("+ Add custom channel...")
12151
12671
  values.append("__add_custom__")
12152
12672
  if channels:
12153
12673
  rows.append("- Remove channel...")
@@ -12501,6 +13021,8 @@ def portable_prelaunch_menu() -> int:
12501
13021
  panel_rows, panel_values = ["Run compatibility test", "Back"], ["run", "back"]
12502
13022
  elif name == "options":
12503
13023
  panel_rows, panel_values = llm_option_panel_rows(provider, pcfg, cfg.get("language", "en"))
13024
+ elif name == "log-level":
13025
+ panel_rows, panel_values = log_level_panel_rows(cfg)
12504
13026
  elif name == "channels":
12505
13027
  panel_rows, panel_values = channel_panel_rows(cfg)
12506
13028
  elif name == "context":
@@ -12680,18 +13202,22 @@ def portable_prelaunch_menu() -> int:
12680
13202
  panel_rows, panel_values = ["Run compatibility test again", "Back"], ["run", "back"]
12681
13203
  refresh_checks()
12682
13204
  main_idx = 9 if "Compatibility: OK" in out else 4
12683
- elif panel == "channels":
13205
+ elif panel == "log-level":
12684
13206
  if value == "back":
12685
13207
  close_panel()
12686
- elif value == "__toggle_dev__":
12687
- messages = set_channel_development_enabled(not channel_development_enabled(cfg))
13208
+ elif value:
13209
+ messages = set_log_level_config(value)
13210
+ refresh_checks()
12688
13211
  cfg = load_config()
12689
- panel_rows, panel_values = channel_panel_rows(cfg)
12690
- panel_idx = 0
13212
+ panel_rows, panel_values = log_level_panel_rows(cfg)
13213
+ panel_idx = max(0, min(panel_idx, len(panel_rows) - 1))
13214
+ elif panel == "channels":
13215
+ if value == "back":
13216
+ close_panel()
12691
13217
  elif value == "__add_custom__":
12692
13218
  spec = prompt_menu_value("Channel spec (for example plugin:ainet@local or server:ainet)", restore_tty=restore_line_mode, raw_tty=restore_raw_mode)
12693
13219
  if spec:
12694
- messages = add_channel_spec(spec, development=True)
13220
+ messages = add_channel_spec(spec)
12695
13221
  cfg = load_config()
12696
13222
  panel_rows, panel_values = channel_panel_rows(cfg)
12697
13223
  elif value == "__remove__":
@@ -12817,7 +13343,7 @@ def portable_prelaunch_menu() -> int:
12817
13343
  elif key in ("esc", "q"):
12818
13344
  return 10
12819
13345
  elif key == "enter":
12820
- actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "channels", "test", "launch", "quit"]
13346
+ actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "log-level", "test", "launch", "quit"]
12821
13347
  action = actions[main_idx]
12822
13348
  if action == "launch":
12823
13349
  blockers = launch_readiness_errors()
@@ -12931,17 +13457,16 @@ def normalize_channel_passthrough(passthrough: list[str]) -> list[str]:
12931
13457
  return normalized
12932
13458
 
12933
13459
 
12934
- def claude_channel_args(cfg: dict[str, Any], passthrough: list[str]) -> list[str]:
12935
- channels = [spec for spec in channel_specs(cfg) if is_channel_spec_tagged(spec)]
12936
- if not channels or has_passthrough_option(passthrough, "--channels", "--dangerously-load-development-channels"):
12937
- return []
12938
- return ["--dangerously-load-development-channels", *channels]
13460
+ def native_channel_passthrough_requested(passthrough: list[str]) -> bool:
13461
+ return has_passthrough_option(passthrough, "--channels", "--dangerously-load-development-channels")
12939
13462
 
12940
13463
 
12941
- def claude_channels_requested(cfg: dict[str, Any], passthrough: list[str]) -> bool:
12942
- if has_passthrough_option(passthrough, "--channels", "--dangerously-load-development-channels"):
12943
- return True
12944
- return any(is_channel_spec_tagged(spec) for spec in channel_specs(cfg))
13464
+ def claude_channel_args(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> list[str]:
13465
+ return []
13466
+
13467
+
13468
+ def claude_channels_requested(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> bool:
13469
+ return native_channel_passthrough_requested(passthrough)
12945
13470
 
12946
13471
 
12947
13472
  def write_web_tools_mcp_config(cfg: dict[str, Any]) -> Path:
@@ -12984,6 +13509,351 @@ def write_duckduckgo_mcp_config(cfg: dict[str, Any]) -> Path:
12984
13509
  return path
12985
13510
 
12986
13511
 
13512
+ def write_channel_mcp_config() -> Path:
13513
+ data = {
13514
+ "mcpServers": {
13515
+ "claude-any-router": {
13516
+ "type": "sse",
13517
+ "url": f"{ROUTER_BASE}/ca/mcp/sse",
13518
+ }
13519
+ }
13520
+ }
13521
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
13522
+ CHANNEL_MCP_CONFIG.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
13523
+ try:
13524
+ os.chmod(CHANNEL_MCP_CONFIG, 0o600)
13525
+ except Exception:
13526
+ pass
13527
+ return CHANNEL_MCP_CONFIG
13528
+
13529
+
13530
+ def write_mcp_proxy_config(
13531
+ passthrough: list[str],
13532
+ *,
13533
+ extra_config_paths: list[Path | str] | None = None,
13534
+ cwd: Path | None = None,
13535
+ home: Path | None = None,
13536
+ ) -> Path | None:
13537
+ cwd = cwd or Path.cwd()
13538
+ extra = [Path(item).expanduser() for item in (extra_config_paths or [])]
13539
+ paths = [*extra, *claude_mcp_config_paths(passthrough, cwd, home)]
13540
+ servers: dict[str, Any] = {}
13541
+ seen: set[str] = set()
13542
+ server_dir = CONFIG_DIR / "mcp-proxy-servers"
13543
+ for path in paths:
13544
+ if not path.exists() or not path.is_file():
13545
+ continue
13546
+ for name, server in _read_mcp_servers_from_json(path, cwd):
13547
+ if name in seen:
13548
+ continue
13549
+ seen.add(name)
13550
+ if _mcp_server_is_stdio(server):
13551
+ server_dir.mkdir(parents=True, exist_ok=True)
13552
+ server_path = server_dir / f"{_safe_mcp_proxy_name(name)}.json"
13553
+ server_path.write_text(json.dumps(server, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
13554
+ try:
13555
+ os.chmod(server_path, 0o600)
13556
+ except Exception:
13557
+ pass
13558
+ servers[name] = {
13559
+ "command": sys.executable,
13560
+ "args": [
13561
+ str(Path(__file__).resolve()),
13562
+ "mcp-proxy",
13563
+ "--server-name",
13564
+ name,
13565
+ "--server-config",
13566
+ str(server_path),
13567
+ ],
13568
+ }
13569
+ else:
13570
+ servers[name] = server
13571
+ if not servers:
13572
+ return None
13573
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
13574
+ MCP_PROXY_CONFIG.write_text(json.dumps({"mcpServers": servers}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
13575
+ try:
13576
+ os.chmod(MCP_PROXY_CONFIG, 0o600)
13577
+ except Exception:
13578
+ pass
13579
+ router_log("INFO", f"mcp_proxy_config_written servers={','.join(sorted(servers))}")
13580
+ return MCP_PROXY_CONFIG
13581
+
13582
+
13583
+ def should_use_channel_stdin_proxy(use_router_mode: bool, passthrough: list[str]) -> bool:
13584
+ return bool(use_router_mode and not native_channel_passthrough_requested(passthrough))
13585
+
13586
+
13587
+ def format_channel_wake_prompt(message: dict[str, Any]) -> str:
13588
+ channel = str(message.get("channel") or "default")
13589
+ sender = str(message.get("sender_id") or "channel")
13590
+ mid = str(message.get("id") or "")
13591
+ meta = message.get("meta") if isinstance(message.get("meta"), dict) else {}
13592
+ room = str(meta.get("room_id") or meta.get("room") or channel)
13593
+ thread = str(message.get("thread_id") or meta.get("thread_id") or "")
13594
+ body = re.sub(r"\s+", " ", str(message.get("message") or "")).strip()
13595
+ fields = [f"channel={channel}", f"room={room}", f"from={sender}"]
13596
+ if mid:
13597
+ fields.append(f"id={mid}")
13598
+ if thread:
13599
+ fields.append(f"thread={thread}")
13600
+ return (
13601
+ "[claude-any external channel message] "
13602
+ + " ".join(fields)
13603
+ + f" text={json.dumps(body, ensure_ascii=False)}. "
13604
+ + "If relevant to current work, respond or act now; otherwise keep working."
13605
+ )
13606
+
13607
+
13608
+ def _write_fd_all(fd: int, data: bytes) -> None:
13609
+ view = memoryview(data)
13610
+ while view:
13611
+ written = os.write(fd, view)
13612
+ view = view[written:]
13613
+
13614
+
13615
+ def _channel_wake_input_bytes(prompt: str) -> bytes:
13616
+ # Ctrl-U clears any stale line editor text before submitting the synthetic prompt.
13617
+ return b"\x15" + prompt.encode("utf-8", errors="replace") + b"\n"
13618
+
13619
+
13620
+ def _inject_pending_channel_messages(master_fd: int, last_id: int) -> int:
13621
+ for message in read_chat_messages(last_id, None, None, 100):
13622
+ try:
13623
+ last_id = max(last_id, int(message.get("id") or 0))
13624
+ except Exception:
13625
+ continue
13626
+ prompt = format_channel_wake_prompt(message)
13627
+ _write_fd_all(master_fd, _channel_wake_input_bytes(prompt))
13628
+ router_log("INFO", f"channel_stdin_proxy_injected message_id={message.get('id')} channel={message.get('channel')}")
13629
+ return last_id
13630
+
13631
+
13632
+ def _chat_messages_file_marker() -> tuple[float, int]:
13633
+ try:
13634
+ stat = CHAT_MESSAGES_PATH.stat()
13635
+ return (stat.st_mtime, stat.st_size)
13636
+ except Exception:
13637
+ return (0.0, 0)
13638
+
13639
+
13640
+ def subprocess_call_with_channel_wake_proxy(cmd: list[str], env: dict[str, str]) -> int:
13641
+ if os.name != "posix" or not sys.stdin.isatty() or not sys.stdout.isatty():
13642
+ router_log("INFO", "channel_stdin_proxy_unavailable; using direct subprocess call")
13643
+ return subprocess.call(cmd, env=env)
13644
+ import pty
13645
+ import select
13646
+ import termios
13647
+ import tty
13648
+
13649
+ master_fd, slave_fd = pty.openpty()
13650
+ proc = subprocess.Popen(cmd, stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, env=env, close_fds=True)
13651
+ os.close(slave_fd)
13652
+ stdin_fd = sys.stdin.fileno()
13653
+ stdout_fd = sys.stdout.fileno()
13654
+ old_attrs = termios.tcgetattr(stdin_fd)
13655
+ last_id = _chat_init_next_id() - 1
13656
+ last_channel_poll = 0.0
13657
+ last_channel_marker = _chat_messages_file_marker()
13658
+ try:
13659
+ tty.setraw(stdin_fd)
13660
+ while proc.poll() is None:
13661
+ try:
13662
+ readable, _, _ = select.select([stdin_fd, master_fd], [], [], 0.2)
13663
+ except OSError:
13664
+ break
13665
+ if stdin_fd in readable:
13666
+ data = os.read(stdin_fd, 4096)
13667
+ if data:
13668
+ _write_fd_all(master_fd, data)
13669
+ if master_fd in readable:
13670
+ try:
13671
+ data = os.read(master_fd, 4096)
13672
+ except OSError:
13673
+ break
13674
+ if data:
13675
+ _write_fd_all(stdout_fd, data)
13676
+ now = time.time()
13677
+ if now - last_channel_poll >= 0.5:
13678
+ last_channel_poll = now
13679
+ marker = _chat_messages_file_marker()
13680
+ if marker != last_channel_marker:
13681
+ last_channel_marker = marker
13682
+ last_id = _inject_pending_channel_messages(master_fd, last_id)
13683
+ while True:
13684
+ try:
13685
+ readable, _, _ = select.select([master_fd], [], [], 0)
13686
+ if master_fd not in readable:
13687
+ break
13688
+ data = os.read(master_fd, 4096)
13689
+ if not data:
13690
+ break
13691
+ _write_fd_all(stdout_fd, data)
13692
+ except OSError:
13693
+ break
13694
+ return proc.returncode if proc.returncode is not None else 0
13695
+ finally:
13696
+ try:
13697
+ termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_attrs)
13698
+ except Exception:
13699
+ pass
13700
+ try:
13701
+ os.close(master_fd)
13702
+ except Exception:
13703
+ pass
13704
+ if proc.poll() is None:
13705
+ try:
13706
+ proc.terminate()
13707
+ except Exception:
13708
+ pass
13709
+
13710
+
13711
+ def _mcp_proxy_notification_payload(server_name: str, message: dict[str, Any]) -> dict[str, Any] | None:
13712
+ method = str(message.get("method") or "").strip()
13713
+ if not method.startswith("notifications/"):
13714
+ return None
13715
+ params = message.get("params") if isinstance(message.get("params"), dict) else {}
13716
+ payload = params.get("payload") if isinstance(params.get("payload"), dict) else {}
13717
+ data = params.get("data") if isinstance(params.get("data"), dict) else {}
13718
+ event = params.get("event") if isinstance(params.get("event"), dict) else {}
13719
+ meta: dict[str, Any] = {
13720
+ "mcp_server": server_name,
13721
+ "mcp_method": method,
13722
+ }
13723
+ meta.update(_event_meta_from_sources(message, params, payload, data, event))
13724
+ content = (
13725
+ _event_payload_text(params)
13726
+ or _event_payload_text(payload)
13727
+ or _event_payload_text(data)
13728
+ or _event_payload_text(event)
13729
+ )
13730
+ if not content and params:
13731
+ content = json.dumps(params, ensure_ascii=False, separators=(",", ":"), default=str)
13732
+ if not content:
13733
+ return None
13734
+ channel = str(meta.get("channel") or meta.get("room_id") or meta.get("room") or server_name)
13735
+ return {
13736
+ "channel": channel,
13737
+ "sender_id": str(meta.get("sender_id") or meta.get("agent_id") or server_name),
13738
+ "recipients": meta.get("recipient_id") or "all",
13739
+ "thread_id": meta.get("thread_id"),
13740
+ "parent_id": meta.get("parent_id"),
13741
+ "kind": method.replace("notifications/claude/", "").replace("notifications/", "").replace("/", "."),
13742
+ "message": content,
13743
+ "meta": meta,
13744
+ }
13745
+
13746
+
13747
+ def _mcp_proxy_observe_stdout_line(server_name: str, line: bytes) -> None:
13748
+ try:
13749
+ text = line.decode("utf-8", errors="replace").strip()
13750
+ if not text or not text.startswith("{"):
13751
+ return
13752
+ payload = json.loads(text)
13753
+ except Exception:
13754
+ return
13755
+ if not isinstance(payload, dict):
13756
+ return
13757
+ chat_payload = _mcp_proxy_notification_payload(server_name, payload)
13758
+ if not chat_payload:
13759
+ return
13760
+ try:
13761
+ saved = append_chat_message(chat_payload)
13762
+ router_log(
13763
+ "INFO",
13764
+ f"mcp_proxy_notification server={server_name} method={payload.get('method')} message_id={saved.get('id')}",
13765
+ )
13766
+ except Exception as exc:
13767
+ router_log("WARN", f"mcp_proxy_notification_failed server={server_name} error={type(exc).__name__}: {exc}")
13768
+
13769
+
13770
+ def _mcp_proxy_forward_stdin(proc: subprocess.Popen[bytes]) -> None:
13771
+ try:
13772
+ while True:
13773
+ chunk = sys.stdin.buffer.read(65536)
13774
+ if not chunk:
13775
+ break
13776
+ if proc.stdin:
13777
+ proc.stdin.write(chunk)
13778
+ proc.stdin.flush()
13779
+ except Exception:
13780
+ pass
13781
+ finally:
13782
+ try:
13783
+ if proc.stdin:
13784
+ proc.stdin.close()
13785
+ except Exception:
13786
+ pass
13787
+
13788
+
13789
+ def _mcp_proxy_forward_stderr(proc: subprocess.Popen[bytes]) -> None:
13790
+ try:
13791
+ if not proc.stderr:
13792
+ return
13793
+ while True:
13794
+ chunk = proc.stderr.read(4096)
13795
+ if not chunk:
13796
+ break
13797
+ sys.stderr.buffer.write(chunk)
13798
+ sys.stderr.buffer.flush()
13799
+ except Exception:
13800
+ pass
13801
+
13802
+
13803
+ def run_mcp_stdio_proxy(server_name: str, server_config_path: Path) -> int:
13804
+ try:
13805
+ server = json.loads(server_config_path.read_text(encoding="utf-8"))
13806
+ except Exception as exc:
13807
+ print(f"claude-any mcp-proxy: cannot read server config: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
13808
+ return 2
13809
+ if not isinstance(server, dict) or not _mcp_server_is_stdio(server):
13810
+ print("claude-any mcp-proxy: server config is not a stdio MCP server", file=sys.stderr, flush=True)
13811
+ return 2
13812
+ command = str(server.get("command") or "").strip()
13813
+ args = [str(item) for item in server.get("args", [])] if isinstance(server.get("args"), list) else []
13814
+ env = os.environ.copy()
13815
+ raw_env = server.get("env")
13816
+ if isinstance(raw_env, dict):
13817
+ env.update({str(k): str(v) for k, v in raw_env.items() if str(k)})
13818
+ cwd_value = server.get("cwd") or server.get("workingDirectory")
13819
+ cwd = str(cwd_value) if cwd_value else None
13820
+ try:
13821
+ proc = subprocess.Popen(
13822
+ [command, *args],
13823
+ stdin=subprocess.PIPE,
13824
+ stdout=subprocess.PIPE,
13825
+ stderr=subprocess.PIPE,
13826
+ cwd=cwd,
13827
+ env=env,
13828
+ )
13829
+ except Exception as exc:
13830
+ print(f"claude-any mcp-proxy: failed to start {command}: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
13831
+ return 127
13832
+ threading.Thread(target=_mcp_proxy_forward_stdin, args=(proc,), daemon=True, name=f"mcp-proxy-stdin-{server_name}").start()
13833
+ threading.Thread(target=_mcp_proxy_forward_stderr, args=(proc,), daemon=True, name=f"mcp-proxy-stderr-{server_name}").start()
13834
+ try:
13835
+ if proc.stdout:
13836
+ for line in iter(proc.stdout.readline, b""):
13837
+ _mcp_proxy_observe_stdout_line(server_name, line)
13838
+ sys.stdout.buffer.write(line)
13839
+ sys.stdout.buffer.flush()
13840
+ return proc.wait()
13841
+ finally:
13842
+ if proc.poll() is None:
13843
+ try:
13844
+ proc.terminate()
13845
+ except Exception:
13846
+ pass
13847
+
13848
+
13849
+ def cmd_mcp_proxy(argv: list[str]) -> int:
13850
+ parser = argparse.ArgumentParser(prog="claude-any mcp-proxy")
13851
+ parser.add_argument("--server-name", required=True)
13852
+ parser.add_argument("--server-config", required=True)
13853
+ args = parser.parse_args(argv)
13854
+ return run_mcp_stdio_proxy(args.server_name, Path(args.server_config).expanduser())
13855
+
13856
+
12987
13857
  def run_claude_update_check(claude: str, enabled: bool = True) -> None:
12988
13858
  if not enabled:
12989
13859
  return
@@ -13076,6 +13946,29 @@ def npm_latest_package_version(npm: str, package_spec: str, timeout: float = 8.0
13076
13946
  return out.splitlines()[-1].strip() if out else ""
13077
13947
 
13078
13948
 
13949
+ def npm_global_package_root(npm: str, package_name: str = "@oneciel-ai/claude-any", timeout: float = 8.0) -> Path | None:
13950
+ try:
13951
+ p = subprocess.run(
13952
+ [npm, "root", "-g"],
13953
+ text=True,
13954
+ stdout=subprocess.PIPE,
13955
+ stderr=subprocess.DEVNULL,
13956
+ timeout=timeout,
13957
+ )
13958
+ except Exception:
13959
+ return None
13960
+ if p.returncode != 0:
13961
+ return None
13962
+ root = (p.stdout or "").strip()
13963
+ if not root:
13964
+ return None
13965
+ package_path = Path(root)
13966
+ for part in package_name.split("/"):
13967
+ if part:
13968
+ package_path /= part
13969
+ return package_path
13970
+
13971
+
13079
13972
  def claude_code_current_version(claude: str) -> str:
13080
13973
  try:
13081
13974
  p = subprocess.run(
@@ -13100,6 +13993,26 @@ def running_from_npm_package() -> bool:
13100
13993
  return "/node_modules/@oneciel-ai/claude-any/" in path
13101
13994
 
13102
13995
 
13996
+ def claude_any_restart_user_args() -> list[str]:
13997
+ args = list(sys.argv[1:])
13998
+ if args and args[0] == "cli":
13999
+ return args[1:]
14000
+ return args
14001
+
14002
+
14003
+ def restart_claude_any_after_update(npm: str) -> None:
14004
+ os.environ["CLAUDE_ANY_SKIP_SELF_UPDATE"] = "1"
14005
+ user_args = claude_any_restart_user_args()
14006
+ package_root = npm_global_package_root(npm)
14007
+ package_script = package_root / "claude_any.py" if package_root else None
14008
+ if package_script and package_script.exists():
14009
+ os.execv(sys.executable, [sys.executable, str(package_script), "cli", *user_args])
14010
+ launcher = find_executable("claude-any")
14011
+ if launcher:
14012
+ raise SystemExit(subprocess.call([launcher, *user_args], env=os.environ.copy()))
14013
+ os.execv(sys.executable, [sys.executable, *sys.argv])
14014
+
14015
+
13103
14016
  def run_claude_any_update_check(enabled: bool = True) -> bool:
13104
14017
  if not enabled:
13105
14018
  return False
@@ -13142,9 +14055,10 @@ def run_claude_any_update_check(enabled: bool = True) -> bool:
13142
14055
  print(f"Claude Any update exited with {update.returncode}; continuing with current version.", flush=True)
13143
14056
  return False
13144
14057
  print("Claude Any updated. Restarting with the new version...", flush=True)
13145
- os.environ["CLAUDE_ANY_SKIP_SELF_UPDATE"] = "1"
13146
14058
  try:
13147
- os.execv(sys.executable, [sys.executable, *sys.argv])
14059
+ restart_claude_any_after_update(npm)
14060
+ except SystemExit:
14061
+ raise
13148
14062
  except Exception as exc:
13149
14063
  print(f"Restart failed ({type(exc).__name__}); continuing with the current process.", flush=True)
13150
14064
  return True
@@ -13244,14 +14158,16 @@ def launch_claude(
13244
14158
  use_native_anthropic = native_anthropic_enabled(provider)
13245
14159
  use_ollama_native = ollama_native_compat_enabled(provider, pcfg)
13246
14160
  use_provider_native = provider_native_compat_enabled(provider, pcfg)
14161
+ use_router_mode = not (use_native_anthropic or use_ollama_native or use_provider_native)
13247
14162
  cleanup_managed_services_for_provider(provider, pcfg, cfg, quiet=True)
13248
- if not (use_native_anthropic or use_ollama_native or use_provider_native):
14163
+ if use_router_mode:
13249
14164
  start_router_if_needed()
13250
14165
  env = os.environ.copy()
13251
14166
  env["PATH"] = str(HOME / ".local" / "bin") + os.pathsep + env.get("PATH", "")
13252
14167
  launch_env = env_vars(cfg)
13253
14168
  launch_passthrough = normalize_channel_passthrough(passthrough)
13254
14169
  if claude_channels_requested(cfg, launch_passthrough):
14170
+ env.pop("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", None)
13255
14171
  launch_env.pop("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", None)
13256
14172
  if use_native_anthropic:
13257
14173
  for key in (
@@ -13282,8 +14198,21 @@ def launch_claude(
13282
14198
  run_claude_update_check(claude, enabled=update_check)
13283
14199
  claude = find_executable("claude") or claude
13284
14200
  extra_args: list[str] = []
14201
+ mcp_config_paths: list[str] = []
13285
14202
  if should_attach_web_search(provider, cfg, web_search_override):
13286
- extra_args.extend(["--mcp-config", str(write_duckduckgo_mcp_config(cfg))])
14203
+ mcp_config_paths.append(str(write_duckduckgo_mcp_config(cfg)))
14204
+ claude_passthrough = list(launch_passthrough)
14205
+ if should_use_channel_stdin_proxy(use_router_mode, launch_passthrough):
14206
+ auto_start_sse_channels_from_mcp_configs(launch_passthrough)
14207
+ proxy_config = write_mcp_proxy_config(
14208
+ launch_passthrough,
14209
+ extra_config_paths=[Path(path) for path in mcp_config_paths],
14210
+ )
14211
+ if proxy_config:
14212
+ mcp_config_paths = [str(proxy_config)]
14213
+ claude_passthrough = strip_mcp_config_passthrough(launch_passthrough)
14214
+ if mcp_config_paths:
14215
+ extra_args.extend(["--mcp-config", *mcp_config_paths])
13287
14216
  if should_append_compat_prompt(provider, cfg) and not has_passthrough_option(launch_passthrough, "--system-prompt"):
13288
14217
  extra_args.extend(["--append-system-prompt", NON_ANTHROPIC_COMPAT_PROMPT])
13289
14218
  extra_args.extend(claude_channel_args(cfg, launch_passthrough))
@@ -13295,7 +14224,9 @@ def launch_claude(
13295
14224
  if model:
13296
14225
  cmd.extend(["--model", model])
13297
14226
  cmd.extend(extra_args)
13298
- cmd.extend(launch_passthrough)
14227
+ cmd.extend(claude_passthrough)
14228
+ if should_use_channel_stdin_proxy(use_router_mode, launch_passthrough):
14229
+ return subprocess_call_with_channel_wake_proxy(cmd, env)
13299
14230
  return subprocess.call(cmd, env=env)
13300
14231
 
13301
14232
 
@@ -13317,7 +14248,8 @@ Control plane, runs before Claude Code and does not require LLM connectivity:
13317
14248
  claude-any set-api-key PROVIDER KEY
13318
14249
  claude-any web-search [on|off] Auto-attach DuckDuckGo MCP for non-native providers
13319
14250
  claude-any web-fetch [on|off] Auto-attach fetch MCP for web page content
13320
- claude-any channels [cmd] Configure Claude Code --channels auto-injection
14251
+ claude-any log-level [LEVEL] Show or set router log level
14252
+ claude-any channels [cmd] Configure external channel specs
13321
14253
  claude-any ollama-native [on|off] Use Ollama's official Claude Code env path
13322
14254
  claude-any ollama-options [provider] [key=value ...]
13323
14255
  Set Ollama num_ctx/options/keep_alive/think
@@ -13352,15 +14284,13 @@ Headless setup flags, namespaced to avoid Claude CLI collisions:
13352
14284
  claude-any --ca-rate-limit-status on|off
13353
14285
  claude-any --ca-stream on|off
13354
14286
  claude-any --ca-stream-word-chunking on|off
14287
+ claude-any --ca-log-level LEVEL Set router log level: SILENT, ERROR, WARN, INFO, DEBUG, TRACE
13355
14288
  claude-any --ca-web-search Force DuckDuckGo MCP for this launch
13356
14289
  claude-any --ca-no-web-search Disable DuckDuckGo MCP for this launch
13357
14290
  claude-any --ca-web-fetch Enable fetch MCP
13358
14291
  claude-any --ca-no-web-fetch Disable fetch MCP
13359
14292
  claude-any --ca-channel SPEC Add an official/approved Claude Code channel
13360
- claude-any --ca-dev-channel SPEC Add a development channel and enable dev loading
13361
- claude-any --ca-development-channels on|off
13362
- Use tagged specs with --dangerously-load-development-channels
13363
- claude-any --ca-clear-channels Clear saved channel auto-injection specs
14293
+ claude-any --ca-clear-channels Clear saved channel specs
13364
14294
  claude-any --ca-no-self-update-check
13365
14295
  Skip Claude Any npm self-update check
13366
14296
  claude-any --ca-no-update-check Skip Claude Code update check for this launch
@@ -13482,18 +14412,14 @@ def apply_headless_env_config() -> tuple[bool, bool | None, bool | None, bool |
13482
14412
  if item.strip()
13483
14413
  ]
13484
14414
  for channel_value in dev_channel_values:
13485
- add_channel_spec(channel_value, development=True)
13486
- skip_menu = True
13487
- dev_channels = os.environ.get("CLAUDE_ANY_DEVELOPMENT_CHANNELS", "").strip().lower()
13488
- if dev_channels:
13489
- if dev_channels not in ("on", "off", "true", "false", "1", "0"):
13490
- raise SystemExit("CLAUDE_ANY_DEVELOPMENT_CHANNELS must be on or off")
13491
- set_channel_development_enabled(dev_channels in ("on", "true", "1"))
14415
+ add_channel_spec(channel_value)
13492
14416
  skip_menu = True
13493
14417
  return skip_menu, web_search_override, update_check_override, self_update_check_override, force_menu
13494
14418
 
13495
14419
 
13496
14420
  def run_cli(argv: list[str]) -> int:
14421
+ if argv and argv[0] == "mcp-proxy":
14422
+ return cmd_mcp_proxy(argv[1:])
13497
14423
  if argv and argv[0] in ("help", "--help", "-h"):
13498
14424
  print(cli_usage())
13499
14425
  return 0
@@ -13549,6 +14475,9 @@ def run_cli(argv: list[str]) -> int:
13549
14475
  if head in ("web-fetch", "webfetch"):
13550
14476
  cmd_web_fetch(argparse.Namespace(value=rest[0] if rest else None))
13551
14477
  return 0
14478
+ if head in ("log-level", "loglevel", "logging"):
14479
+ cmd_log_level(argparse.Namespace(value=rest[0] if rest else None))
14480
+ return 0
13552
14481
  if head in ("channels", "channel"):
13553
14482
  cmd_channels(argparse.Namespace(values=rest))
13554
14483
  return 0
@@ -13870,6 +14799,18 @@ def run_cli(argv: list[str]) -> int:
13870
14799
  i += 1
13871
14800
  cmd_provider_options(argparse.Namespace(values=[f"stream_word_chunking={value}"]))
13872
14801
  skip_menu = True
14802
+ elif arg == "--ca-log-level" or arg.startswith("--ca-log-level="):
14803
+ value = arg.split("=", 1)[1] if "=" in arg else None
14804
+ if value is None:
14805
+ if i + 1 >= len(argv):
14806
+ raise SystemExit("Missing level for --ca-log-level")
14807
+ value = argv[i + 1]
14808
+ i += 2
14809
+ else:
14810
+ i += 1
14811
+ for line in set_log_level_config(value):
14812
+ print(line)
14813
+ skip_menu = True
13873
14814
  elif arg == "--ca-web-search":
13874
14815
  web_search_override = True
13875
14816
  skip_menu = True
@@ -13907,7 +14848,7 @@ def run_cli(argv: list[str]) -> int:
13907
14848
  i += 2
13908
14849
  else:
13909
14850
  i += 1
13910
- for line in add_channel_spec(value, development=True):
14851
+ for line in add_channel_spec(value):
13911
14852
  print(line)
13912
14853
  skip_menu = True
13913
14854
  elif arg == "--ca-development-channels" or arg.startswith("--ca-development-channels="):
@@ -13919,8 +14860,7 @@ def run_cli(argv: list[str]) -> int:
13919
14860
  i += 2
13920
14861
  else:
13921
14862
  i += 1
13922
- enabled = value.strip().lower() in ("on", "enable", "enabled", "true", "1")
13923
- for line in set_channel_development_enabled(enabled):
14863
+ for line in set_channel_development_enabled(True):
13924
14864
  print(line)
13925
14865
  skip_menu = True
13926
14866
  elif arg == "--ca-clear-channels":
@@ -13998,6 +14938,9 @@ def build_parser() -> argparse.ArgumentParser:
13998
14938
  wf = sub.add_parser("web-fetch")
13999
14939
  wf.add_argument("value", nargs="?")
14000
14940
  wf.set_defaults(func=cmd_web_fetch)
14941
+ ll = sub.add_parser("log-level")
14942
+ ll.add_argument("value", nargs="?")
14943
+ ll.set_defaults(func=cmd_log_level)
14001
14944
  ch = sub.add_parser("channels")
14002
14945
  ch.add_argument("values", nargs="*")
14003
14946
  ch.set_defaults(func=cmd_channels)