@oneciel-ai/claude-any 0.1.96 → 0.1.98

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 +740 -18
  2. package/package.json +1 -1
package/claude_any.py CHANGED
@@ -9,6 +9,7 @@ import importlib.util
9
9
  import json
10
10
  import math
11
11
  import os
12
+ import queue
12
13
  import re
13
14
  import signal
14
15
  import shlex
@@ -59,6 +60,7 @@ WEB_TOOLS_MCP_CONFIG = CONFIG_DIR / "web-tools-mcp.json"
59
60
  DUCKDUCKGO_MCP_CONFIG = CONFIG_DIR / "duckduckgo-mcp.json"
60
61
  CHANNEL_MCP_CONFIG = CONFIG_DIR / "channel-mcp.json"
61
62
  CHANNEL_MCP_CURSOR_PATH = CONFIG_DIR / "channel-mcp-cursor.json"
63
+ CHANNEL_PROBE_CACHE_PATH = CONFIG_DIR / "channel-probe-cache.json"
62
64
  MCP_PROXY_CONFIG = CONFIG_DIR / "mcp-proxy.json"
63
65
  ROUTER_HOST = os.environ.get("CLAUDE_ANY_ROUTER_CLIENT_HOST", "127.0.0.1").strip() or "127.0.0.1"
64
66
  ROUTER_PORT = 8799
@@ -105,7 +107,7 @@ OFFICIAL_CHANNEL_PLUGINS = {
105
107
  "fakechat": "plugin:fakechat@claude-plugins-official",
106
108
  }
107
109
  APP_NAME = "Claude Any"
108
- VERSION = "0.1.96"
110
+ VERSION = "0.1.98"
109
111
  CREDITS = "Credits: One Ciel LLC"
110
112
 
111
113
  LOG_LEVELS = {"SILENT": 0, "ERROR": 1, "WARN": 2, "INFO": 3, "DEBUG": 4, "TRACE": 5}
@@ -1060,6 +1062,7 @@ UI_TEXT = {
1060
1062
  "test": "Test compatibility",
1061
1063
  "options": "LLM options",
1062
1064
  "channel_delivery": "Channel delivery",
1065
+ "channels": "Channels",
1063
1066
  "log_level": "Log level",
1064
1067
  "presets": "LLM presets",
1065
1068
  "context_setup": "Context setup",
@@ -1082,6 +1085,7 @@ UI_TEXT = {
1082
1085
  "test": "호환성 테스트",
1083
1086
  "options": "LLM 옵션",
1084
1087
  "channel_delivery": "채널 전달 방식",
1088
+ "channels": "채널",
1085
1089
  "log_level": "로그 레벨",
1086
1090
  "presets": "LLM 프리셋",
1087
1091
  "context_setup": "컨텍스트 설정",
@@ -1104,6 +1108,7 @@ UI_TEXT = {
1104
1108
  "test": "互換性テスト",
1105
1109
  "options": "LLMオプション",
1106
1110
  "channel_delivery": "チャンネル配信方式",
1111
+ "channels": "チャンネル",
1107
1112
  "log_level": "ログレベル",
1108
1113
  "presets": "LLMプリセット",
1109
1114
  "context_setup": "コンテキスト設定",
@@ -1126,6 +1131,7 @@ UI_TEXT = {
1126
1131
  "test": "兼容性测试",
1127
1132
  "options": "LLM 选项",
1128
1133
  "channel_delivery": "频道投递方式",
1134
+ "channels": "频道",
1129
1135
  "log_level": "日志级别",
1130
1136
  "presets": "LLM 预设",
1131
1137
  "context_setup": "上下文设置",
@@ -9498,6 +9504,233 @@ def _mcp_server_is_stdio(server: dict[str, Any]) -> bool:
9498
9504
  return "mcp-proxy" not in args
9499
9505
 
9500
9506
 
9507
+ def _channel_probe_initialize_payload() -> bytes:
9508
+ payload = {
9509
+ "jsonrpc": "2.0",
9510
+ "id": 1,
9511
+ "method": "initialize",
9512
+ "params": {
9513
+ "protocolVersion": "2024-11-05",
9514
+ "capabilities": {},
9515
+ "clientInfo": {"name": "claude-any-channel-probe", "version": VERSION},
9516
+ },
9517
+ }
9518
+ return json.dumps(payload, ensure_ascii=False).encode("utf-8")
9519
+
9520
+
9521
+ def _channel_probe_parse_framed_responses(buffer: bytes) -> list[dict[str, Any]]:
9522
+ out: list[dict[str, Any]] = []
9523
+ idx = 0
9524
+ while idx < len(buffer):
9525
+ header_end = buffer.find(b"\r\n\r\n", idx)
9526
+ if header_end < 0:
9527
+ return out
9528
+ header = buffer[idx:header_end].decode("ascii", errors="replace")
9529
+ length: int | None = None
9530
+ for line in header.split("\r\n"):
9531
+ if line.lower().startswith("content-length:"):
9532
+ try:
9533
+ length = int(line.split(":", 1)[1].strip())
9534
+ except Exception:
9535
+ return out
9536
+ break
9537
+ if length is None:
9538
+ return out
9539
+ body_start = header_end + 4
9540
+ body_end = body_start + length
9541
+ if len(buffer) < body_end:
9542
+ return out
9543
+ try:
9544
+ msg = json.loads(buffer[body_start:body_end].decode("utf-8", errors="replace"))
9545
+ except Exception:
9546
+ idx = body_end
9547
+ continue
9548
+ if isinstance(msg, dict):
9549
+ out.append(msg)
9550
+ idx = body_end
9551
+ return out
9552
+
9553
+
9554
+ def _channel_probe_parse_jsonl_responses(buffer: bytes) -> list[dict[str, Any]]:
9555
+ out: list[dict[str, Any]] = []
9556
+ for raw_line in buffer.split(b"\n"):
9557
+ line = raw_line.strip()
9558
+ if not line:
9559
+ continue
9560
+ try:
9561
+ msg = json.loads(line.decode("utf-8", errors="replace"))
9562
+ except Exception:
9563
+ continue
9564
+ if isinstance(msg, dict):
9565
+ out.append(msg)
9566
+ return out
9567
+
9568
+
9569
+ def _channel_probe_find_initialize_response(buffer: bytes, framed: bool) -> dict[str, Any] | None:
9570
+ msgs = _channel_probe_parse_framed_responses(buffer) if framed else _channel_probe_parse_jsonl_responses(buffer)
9571
+ for msg in msgs:
9572
+ if msg.get("id") == 1 and "result" in msg:
9573
+ return msg
9574
+ return None
9575
+
9576
+
9577
+ def _channel_probe_capability_present(initialize_response: dict[str, Any]) -> bool:
9578
+ result = initialize_response.get("result")
9579
+ if not isinstance(result, dict):
9580
+ return False
9581
+ capabilities = result.get("capabilities")
9582
+ if not isinstance(capabilities, dict):
9583
+ return False
9584
+ experimental = capabilities.get("experimental")
9585
+ if not isinstance(experimental, dict):
9586
+ return False
9587
+ value = experimental.get("claude/channel")
9588
+ return value is not None and value is not False
9589
+
9590
+
9591
+ def probe_stdio_mcp_for_channel_capability(server_name: str, server: dict[str, Any], timeout: float = 3.0) -> bool:
9592
+ if not _mcp_server_is_stdio(server):
9593
+ return False
9594
+ command = str(server.get("command") or "").strip()
9595
+ args_raw = server.get("args", [])
9596
+ args = [str(item) for item in args_raw] if isinstance(args_raw, list) else []
9597
+ if not command:
9598
+ return False
9599
+ command, args = resolve_mcp_server_process(command, args)
9600
+ env = os.environ.copy()
9601
+ raw_env = server.get("env")
9602
+ if isinstance(raw_env, dict):
9603
+ env.update({str(k): str(v) for k, v in raw_env.items() if str(k)})
9604
+ cwd_value = server.get("cwd") or server.get("workingDirectory")
9605
+ cwd = str(cwd_value) if cwd_value else None
9606
+ framed = _mcp_proxy_stdio_mode(server) != "jsonl"
9607
+
9608
+ proc: subprocess.Popen[bytes] | None = None
9609
+ try:
9610
+ proc = subprocess.Popen(
9611
+ [command, *args],
9612
+ stdin=subprocess.PIPE,
9613
+ stdout=subprocess.PIPE,
9614
+ stderr=subprocess.DEVNULL,
9615
+ cwd=cwd,
9616
+ env=env,
9617
+ bufsize=0,
9618
+ close_fds=True,
9619
+ )
9620
+ except Exception as exc:
9621
+ router_log("DEBUG", f"channel_probe_spawn_failed server={server_name} error={type(exc).__name__}: {exc}")
9622
+ return False
9623
+
9624
+ chunks_queue: queue.Queue[bytes | None] = queue.Queue()
9625
+
9626
+ def _reader() -> None:
9627
+ try:
9628
+ assert proc is not None
9629
+ stdout = proc.stdout
9630
+ if stdout is None:
9631
+ return
9632
+ while True:
9633
+ chunk = stdout.read(4096)
9634
+ if not chunk:
9635
+ break
9636
+ chunks_queue.put(chunk)
9637
+ except Exception:
9638
+ pass
9639
+ finally:
9640
+ chunks_queue.put(None)
9641
+
9642
+ threading.Thread(target=_reader, daemon=True, name=f"channel-probe-stdout-{server_name}").start()
9643
+
9644
+ body = _channel_probe_initialize_payload()
9645
+ if framed:
9646
+ frame = b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n\r\n" + body
9647
+ else:
9648
+ frame = body + b"\n"
9649
+ try:
9650
+ if proc.stdin:
9651
+ proc.stdin.write(frame)
9652
+ proc.stdin.flush()
9653
+ except Exception:
9654
+ pass
9655
+
9656
+ deadline = time.time() + timeout
9657
+ stdout_buf = bytearray()
9658
+ capable = False
9659
+ try:
9660
+ while time.time() < deadline:
9661
+ wait = min(0.2, max(0.001, deadline - time.time()))
9662
+ try:
9663
+ chunk = chunks_queue.get(timeout=wait)
9664
+ except queue.Empty:
9665
+ continue
9666
+ if chunk is None:
9667
+ break
9668
+ stdout_buf.extend(chunk)
9669
+ response = _channel_probe_find_initialize_response(bytes(stdout_buf), framed)
9670
+ if response is not None:
9671
+ capable = _channel_probe_capability_present(response)
9672
+ break
9673
+ finally:
9674
+ try:
9675
+ if proc.stdin:
9676
+ proc.stdin.close()
9677
+ except Exception:
9678
+ pass
9679
+ try:
9680
+ proc.terminate()
9681
+ proc.wait(timeout=1.0)
9682
+ except Exception:
9683
+ try:
9684
+ proc.kill()
9685
+ except Exception:
9686
+ pass
9687
+
9688
+ router_log(
9689
+ "INFO",
9690
+ f"channel_probe_result server={server_name} channel_capable={capable} bytes={len(stdout_buf)}",
9691
+ )
9692
+ return capable
9693
+
9694
+
9695
+ def detect_channel_capable_mcp_servers(
9696
+ mcp_config_paths: Iterable[str],
9697
+ cwd: Path,
9698
+ *,
9699
+ include_router_self: bool = True,
9700
+ timeout_per_server: float = 3.0,
9701
+ ) -> list[str]:
9702
+ """Probe MCP servers declared in given config files; return names that declare experimental['claude/channel']."""
9703
+ capable: list[str] = []
9704
+ seen: set[str] = set()
9705
+ if include_router_self:
9706
+ capable.append("claude-any-router")
9707
+ seen.add("claude-any-router")
9708
+ for path_str in mcp_config_paths:
9709
+ if not path_str:
9710
+ continue
9711
+ path = Path(path_str)
9712
+ if not path.exists():
9713
+ continue
9714
+ for name, server in _read_mcp_servers_from_json(path, cwd):
9715
+ if name in seen:
9716
+ continue
9717
+ seen.add(name)
9718
+ if name == "claude-any-router":
9719
+ continue
9720
+ if not _mcp_server_is_stdio(server):
9721
+ # Non-stdio (sse/http) probing not implemented; skip silently.
9722
+ continue
9723
+ try:
9724
+ if probe_stdio_mcp_for_channel_capability(name, server, timeout=timeout_per_server):
9725
+ capable.append(name)
9726
+ except Exception as exc:
9727
+ router_log(
9728
+ "WARN",
9729
+ f"channel_probe_exception server={name} error={type(exc).__name__}: {exc}",
9730
+ )
9731
+ return capable
9732
+
9733
+
9501
9734
  def _mcp_config_passthrough_values(passthrough: list[str]) -> list[str]:
9502
9735
  values: list[str] = []
9503
9736
  i = 0
@@ -9593,6 +9826,215 @@ def auto_discovered_mcp_channel_specs(
9593
9826
  return _dedupe_strings(specs)
9594
9827
 
9595
9828
 
9829
+ CHANNEL_PROBE_CACHE_VERSION = 1
9830
+
9831
+
9832
+ def _builtin_router_probe_record() -> dict[str, Any]:
9833
+ return {
9834
+ "name": "claude-any-router",
9835
+ "capable": True,
9836
+ "transport": "sse",
9837
+ "source_path": "<built-in>",
9838
+ "url": f"{ROUTER_BASE}/ca/mcp/sse",
9839
+ "response_bytes": 0,
9840
+ "reason": "built-in",
9841
+ }
9842
+
9843
+
9844
+ def _server_transport_label(server: dict[str, Any]) -> str:
9845
+ if not isinstance(server, dict):
9846
+ return "unknown"
9847
+ declared = str(server.get("type") or "").strip().lower()
9848
+ if declared:
9849
+ return declared
9850
+ if server.get("url"):
9851
+ return "sse"
9852
+ if server.get("command"):
9853
+ return "stdio"
9854
+ return "unknown"
9855
+
9856
+
9857
+ def _probe_mcp_servers_to_records(
9858
+ paths: Iterable[str],
9859
+ cwd: Path,
9860
+ *,
9861
+ include_router_self: bool = True,
9862
+ timeout_per_server: float = 3.0,
9863
+ ) -> list[dict[str, Any]]:
9864
+ """Probe every MCP server referenced from the given config paths and
9865
+ return one record per server (capable and non-capable alike) for cache
9866
+ consumers and menu rendering."""
9867
+ records: list[dict[str, Any]] = []
9868
+ seen: set[str] = set()
9869
+ if include_router_self:
9870
+ records.append(_builtin_router_probe_record())
9871
+ seen.add("claude-any-router")
9872
+ for path_str in paths:
9873
+ if not path_str:
9874
+ continue
9875
+ path = Path(path_str)
9876
+ if not path.exists() or not path.is_file():
9877
+ continue
9878
+ for name, server in _read_mcp_servers_from_json(path, cwd):
9879
+ if name in seen:
9880
+ continue
9881
+ seen.add(name)
9882
+ if name == "claude-any-router":
9883
+ continue
9884
+ transport = _server_transport_label(server)
9885
+ record: dict[str, Any] = {
9886
+ "name": name,
9887
+ "capable": False,
9888
+ "transport": transport,
9889
+ "source_path": str(path),
9890
+ "response_bytes": 0,
9891
+ "reason": "",
9892
+ }
9893
+ if isinstance(server.get("url"), str):
9894
+ record["url"] = str(server.get("url"))
9895
+ if not _mcp_server_is_stdio(server):
9896
+ record["reason"] = "non_stdio_probe_not_implemented"
9897
+ records.append(record)
9898
+ continue
9899
+ try:
9900
+ capable = probe_stdio_mcp_for_channel_capability(name, server, timeout=timeout_per_server)
9901
+ record["capable"] = bool(capable)
9902
+ if not capable:
9903
+ record["reason"] = "no_experimental_claude_channel_or_timeout"
9904
+ except Exception as exc:
9905
+ record["reason"] = f"probe_exception:{type(exc).__name__}"
9906
+ router_log("WARN", f"channel_probe_exception server={name} error={type(exc).__name__}: {exc}")
9907
+ records.append(record)
9908
+ return records
9909
+
9910
+
9911
+ def read_channel_probe_cache() -> dict[str, Any]:
9912
+ if not CHANNEL_PROBE_CACHE_PATH.exists():
9913
+ return {"version": CHANNEL_PROBE_CACHE_VERSION, "probed_at": 0.0, "servers": []}
9914
+ try:
9915
+ data = json.loads(CHANNEL_PROBE_CACHE_PATH.read_text(encoding="utf-8"))
9916
+ except Exception as exc:
9917
+ router_log("WARN", f"channel_probe_cache_read_failed error={type(exc).__name__}: {exc}")
9918
+ return {"version": CHANNEL_PROBE_CACHE_VERSION, "probed_at": 0.0, "servers": []}
9919
+ if not isinstance(data, dict):
9920
+ return {"version": CHANNEL_PROBE_CACHE_VERSION, "probed_at": 0.0, "servers": []}
9921
+ data.setdefault("version", CHANNEL_PROBE_CACHE_VERSION)
9922
+ data.setdefault("probed_at", 0.0)
9923
+ servers = data.get("servers")
9924
+ data["servers"] = [item for item in servers if isinstance(item, dict)] if isinstance(servers, list) else []
9925
+ return data
9926
+
9927
+
9928
+ def _write_channel_probe_cache(cache: dict[str, Any]) -> None:
9929
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
9930
+ tmp = CHANNEL_PROBE_CACHE_PATH.with_suffix(".json.tmp")
9931
+ tmp.write_text(json.dumps(cache, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
9932
+ tmp.replace(CHANNEL_PROBE_CACHE_PATH)
9933
+ try:
9934
+ os.chmod(CHANNEL_PROBE_CACHE_PATH, 0o600)
9935
+ except Exception:
9936
+ pass
9937
+
9938
+
9939
+ def refresh_channel_probe_cache(
9940
+ passthrough: list[str] | None = None,
9941
+ cwd: Path | None = None,
9942
+ home: Path | None = None,
9943
+ timeout_per_server: float = 3.0,
9944
+ ) -> dict[str, Any]:
9945
+ """Re-scan known MCP config files, probe each stdio entry for the
9946
+ channel capability, write the result to disk and return it. Only called
9947
+ on explicit user action (menu refresh or CLI subcommand)."""
9948
+ cwd = cwd or Path.cwd()
9949
+ paths = [str(p) for p in claude_mcp_config_paths(passthrough or [], cwd, home)]
9950
+ records = _probe_mcp_servers_to_records(paths, cwd, timeout_per_server=timeout_per_server)
9951
+ cache = {
9952
+ "version": CHANNEL_PROBE_CACHE_VERSION,
9953
+ "probed_at": time.time(),
9954
+ "servers": records,
9955
+ }
9956
+ _write_channel_probe_cache(cache)
9957
+ capable = [r["name"] for r in records if r.get("capable")]
9958
+ router_log(
9959
+ "INFO",
9960
+ f"channel_probe_cache_refreshed total={len(records)} capable={len(capable)} servers={','.join(capable) or '-'}",
9961
+ )
9962
+ return cache
9963
+
9964
+
9965
+ def cached_channel_probe_servers() -> list[dict[str, Any]]:
9966
+ cache = read_channel_probe_cache()
9967
+ servers = cache.get("servers")
9968
+ if isinstance(servers, list):
9969
+ return [item for item in servers if isinstance(item, dict)]
9970
+ return []
9971
+
9972
+
9973
+ def cached_channel_capable_server_names() -> list[str]:
9974
+ """Capable server names from cache, with claude-any-router always
9975
+ present (because the router's own MCP bridge is built into claude-any)."""
9976
+ names = [str(r.get("name")) for r in cached_channel_probe_servers() if r.get("capable") and r.get("name")]
9977
+ if "claude-any-router" not in names:
9978
+ names.insert(0, "claude-any-router")
9979
+ return _dedupe_strings(names)
9980
+
9981
+
9982
+ def parse_passthrough_channel_specs(passthrough: list[str]) -> list[str]:
9983
+ """Extract channel specs from passthrough args of either
9984
+ --channels or --dangerously-load-development-channels (and the
9985
+ =VALUE inline form)."""
9986
+ specs: list[str] = []
9987
+ i = 0
9988
+ while i < len(passthrough):
9989
+ arg = passthrough[i]
9990
+ if arg in ("--channels", "--dangerously-load-development-channels"):
9991
+ i += 1
9992
+ while i < len(passthrough) and is_channel_spec_tagged(passthrough[i]):
9993
+ specs.append(passthrough[i])
9994
+ i += 1
9995
+ continue
9996
+ if arg.startswith("--channels=") or arg.startswith("--dangerously-load-development-channels="):
9997
+ value = arg.split("=", 1)[1].strip()
9998
+ if value and is_channel_spec_tagged(value):
9999
+ specs.append(value)
10000
+ i += 1
10001
+ continue
10002
+ i += 1
10003
+ return _dedupe_strings(specs)
10004
+
10005
+
10006
+ def auto_import_passthrough_channels(passthrough: list[str]) -> list[str]:
10007
+ """Add channel specs that arrived as CLI passthrough to the persisted
10008
+ cfg.channels list, so they show up alongside auto-detected entries in
10009
+ the menu and survive subsequent launches. Returns the newly added specs."""
10010
+ specs = parse_passthrough_channel_specs(passthrough)
10011
+ if not specs:
10012
+ return []
10013
+ cfg = load_config()
10014
+ existing = set(channel_specs(cfg))
10015
+ if all(spec in existing for spec in specs):
10016
+ return []
10017
+ cc = cfg.setdefault("claude_code", {})
10018
+ merged = list(channel_specs(cfg))
10019
+ added: list[str] = []
10020
+ for spec in specs:
10021
+ if spec in existing:
10022
+ continue
10023
+ merged.append(spec)
10024
+ existing.add(spec)
10025
+ added.append(spec)
10026
+ if not added:
10027
+ return []
10028
+ cc["channels"] = merged
10029
+ save_config(cfg)
10030
+ invalidate_config_cache()
10031
+ router_log(
10032
+ "INFO",
10033
+ f"channels_auto_imported_from_passthrough count={len(added)} specs={','.join(added)}",
10034
+ )
10035
+ return added
10036
+
10037
+
9596
10038
  def _mcp_sse_servers_from_mapping(mapping: Any) -> list[dict[str, Any]]:
9597
10039
  if not isinstance(mapping, dict):
9598
10040
  return []
@@ -9795,6 +10237,29 @@ def cmd_channels(args: argparse.Namespace) -> None:
9795
10237
  for line in clear_channel_specs():
9796
10238
  print(line)
9797
10239
  return
10240
+ if head in ("detect", "probe", "refresh"):
10241
+ try:
10242
+ result = refresh_channel_probe_cache()
10243
+ except Exception as exc:
10244
+ raise SystemExit(f"Channel probe failed: {type(exc).__name__}: {exc}")
10245
+ servers = result.get("servers") or []
10246
+ capable = [r for r in servers if r.get("capable")]
10247
+ non_capable = [r for r in servers if not r.get("capable")]
10248
+ probed_at = result.get("probed_at") or 0
10249
+ ts_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(probed_at)) if probed_at else "-"
10250
+ print(f"channel probe complete (cached at {ts_str})")
10251
+ print(f" capable : {len(capable)}")
10252
+ for r in capable:
10253
+ transport = r.get("transport") or "?"
10254
+ source = r.get("source_path") or ""
10255
+ suffix = " built-in" if source == "<built-in>" else f" {source}"
10256
+ print(f" * {r.get('name')} ({transport}){suffix}")
10257
+ print(f" non-capable : {len(non_capable)}")
10258
+ for r in non_capable:
10259
+ transport = r.get("transport") or "?"
10260
+ reason = r.get("reason") or "-"
10261
+ print(f" {r.get('name')} ({transport}) reason={reason}")
10262
+ return
9798
10263
  if head in ("delivery", "mode"):
9799
10264
  if len(values) < 2:
9800
10265
  print(f"channel_delivery: {channel_delivery_mode(cfg)}")
@@ -13011,9 +13476,10 @@ def main_menu_rows(cfg: dict[str, Any], provider: str, pcfg: dict[str, Any], lan
13011
13476
  f"5. {ui_text('advisor_model', lang)} [{compact_text(pcfg.get('advisor_model') or 'off', 62)}]",
13012
13477
  f"6. {ui_text('options', lang)} [{compact_text(llm_options_status(provider, pcfg), 62)}]",
13013
13478
  f"7. {ui_text('channel_delivery', lang)} [{channel_delivery_mode(cfg)}]",
13014
- f"8. {ui_text('log_level', lang)} [{log_level_status()}]",
13015
- f"9. {ui_text('test', lang)}",
13016
- f"10. {ui_text('launch', lang)}",
13479
+ f"8. {ui_text('channels', lang)} [{channel_status_text(cfg)}]",
13480
+ f"9. {ui_text('log_level', lang)} [{log_level_status()}]",
13481
+ f"10. {ui_text('test', lang)}",
13482
+ f"11. {ui_text('launch', lang)}",
13017
13483
  ui_text("quit", lang),
13018
13484
  ]
13019
13485
 
@@ -13110,13 +13576,71 @@ def advisor_model_panel_rows(provider: str, pcfg: dict[str, Any]) -> tuple[list[
13110
13576
 
13111
13577
 
13112
13578
  def channel_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
13113
- channels = channel_specs(cfg)
13579
+ channels = set(channel_specs(cfg))
13580
+ cache = read_channel_probe_cache()
13581
+ records = cache.get("servers") or []
13582
+ probed_at = cache.get("probed_at") or 0
13583
+ capable_records = [r for r in records if r.get("capable")]
13584
+ non_capable_records = [r for r in records if not r.get("capable")]
13585
+
13114
13586
  rows: list[str] = []
13115
13587
  values: list[str] = []
13116
- for name, spec in OFFICIAL_CHANNEL_PLUGINS.items():
13588
+
13589
+ rows.append("[Auto-detected channel-capable]")
13590
+ values.append("__heading__")
13591
+ if capable_records:
13592
+ for r in capable_records:
13593
+ name = str(r.get("name") or "")
13594
+ spec = f"server:{name}"
13595
+ mark = "*" if spec in channels else " "
13596
+ transport = str(r.get("transport") or "?")
13597
+ source = str(r.get("source_path") or "")
13598
+ if source == "<built-in>":
13599
+ rows.append(f"{mark} {name:<14} ({transport}, built-in)")
13600
+ else:
13601
+ rows.append(f"{mark} {name:<14} ({transport})")
13602
+ values.append(spec)
13603
+ else:
13604
+ hint = "press Re-probe now" if not probed_at else "none capable"
13605
+ rows.append(f" ({hint})")
13606
+ values.append("__noop__")
13607
+
13608
+ if non_capable_records:
13609
+ rows.append("[Detected but not channel-capable]")
13610
+ values.append("__heading__")
13611
+ for r in non_capable_records:
13612
+ name = str(r.get("name") or "")
13613
+ transport = str(r.get("transport") or "?")
13614
+ reason = str(r.get("reason") or "-")
13615
+ rows.append(f" {name:<14} ({transport}) {reason}")
13616
+ values.append("__noop__")
13617
+
13618
+ rows.append("[Official plugins]")
13619
+ values.append("__heading__")
13620
+ for plugin_name, spec in OFFICIAL_CHANNEL_PLUGINS.items():
13117
13621
  mark = "*" if spec in channels else " "
13118
- rows.append(f"{mark} {name:<10} {spec}")
13622
+ rows.append(f"{mark} {plugin_name:<10} {spec}")
13119
13623
  values.append(spec)
13624
+
13625
+ covered: set[str] = set(OFFICIAL_CHANNEL_PLUGINS.values())
13626
+ for r in capable_records:
13627
+ covered.add(f"server:{r.get('name')}")
13628
+ custom_specs = [spec for spec in channel_specs(cfg) if spec not in covered]
13629
+ if custom_specs:
13630
+ rows.append("[Configured custom / imported]")
13631
+ values.append("__heading__")
13632
+ for spec in custom_specs:
13633
+ rows.append(f"* {spec}")
13634
+ values.append(spec)
13635
+
13636
+ rows.append("[Actions]")
13637
+ values.append("__heading__")
13638
+ if probed_at:
13639
+ ts_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(probed_at))
13640
+ rows.append(f"Re-probe now (last: {ts_str})")
13641
+ else:
13642
+ rows.append("Re-probe now (no cache yet)")
13643
+ values.append("__reprobe__")
13120
13644
  rows.append("+ Add custom channel...")
13121
13645
  values.append("__add_custom__")
13122
13646
  if channels:
@@ -13129,6 +13653,25 @@ def channel_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
13129
13653
  return rows, values
13130
13654
 
13131
13655
 
13656
+ def _channel_panel_first_selectable(values: list[str]) -> int:
13657
+ for idx, value in enumerate(values):
13658
+ if value not in ("__heading__", "__noop__"):
13659
+ return idx
13660
+ return 0
13661
+
13662
+
13663
+ def _channel_panel_step(values: list[str], start: int, delta: int) -> int:
13664
+ if not values:
13665
+ return 0
13666
+ n = len(values)
13667
+ idx = start
13668
+ for _ in range(n):
13669
+ idx = (idx + delta) % n
13670
+ if values[idx] not in ("__heading__", "__noop__"):
13671
+ return idx
13672
+ return start
13673
+
13674
+
13132
13675
  def channel_delivery_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
13133
13676
  current = channel_delivery_mode(cfg)
13134
13677
  rows = [
@@ -13440,9 +13983,10 @@ def portable_language_menu() -> int:
13440
13983
  return 0
13441
13984
 
13442
13985
 
13443
- def portable_prelaunch_menu() -> int:
13986
+ def portable_prelaunch_menu(passthrough: list[str] | None = None) -> int:
13987
+ passthrough = list(passthrough or [])
13444
13988
  enable_ansi()
13445
- main_idx = 10 if settings_ready_except_api_key() else 0
13989
+ main_idx = 11 if settings_ready_except_api_key() else 0
13446
13990
  panel: str | None = None
13447
13991
  panel_idx = 0
13448
13992
  panel_rows: list[str] = []
@@ -13490,6 +14034,8 @@ def portable_prelaunch_menu() -> int:
13490
14034
  panel_rows, panel_values = log_level_panel_rows(cfg)
13491
14035
  elif name == "channels":
13492
14036
  panel_rows, panel_values = channel_panel_rows(cfg)
14037
+ if panel_values:
14038
+ panel_idx = _channel_panel_first_selectable(panel_values)
13493
14039
  elif name == "context":
13494
14040
  panel_rows, panel_values = context_setup_panel_rows(provider, pcfg, cfg.get("language", "en"))
13495
14041
  elif name == "preset":
@@ -13557,11 +14103,17 @@ def portable_prelaunch_menu() -> int:
13557
14103
  if panel:
13558
14104
  panel_name = panel
13559
14105
  if key in ("up", "k"):
13560
- panel_idx = (panel_idx - 1) % max(1, len(panel_rows))
14106
+ if panel == "channels":
14107
+ panel_idx = _channel_panel_step(panel_values, panel_idx, -1)
14108
+ else:
14109
+ panel_idx = (panel_idx - 1) % max(1, len(panel_rows))
13561
14110
  panel_last_idx[panel_name] = panel_idx
13562
14111
  continue
13563
14112
  if key in ("down", "j"):
13564
- panel_idx = (panel_idx + 1) % max(1, len(panel_rows))
14113
+ if panel == "channels":
14114
+ panel_idx = _channel_panel_step(panel_values, panel_idx, 1)
14115
+ else:
14116
+ panel_idx = (panel_idx + 1) % max(1, len(panel_rows))
13565
14117
  panel_last_idx[panel_name] = panel_idx
13566
14118
  continue
13567
14119
  if key in ("esc", "left", "q"):
@@ -13666,7 +14218,7 @@ def portable_prelaunch_menu() -> int:
13666
14218
  messages = lines[-8:] if lines else ["Test produced no output."]
13667
14219
  panel_rows, panel_values = ["Run compatibility test again", "Back"], ["run", "back"]
13668
14220
  refresh_checks()
13669
- main_idx = 10 if "Compatibility: OK" in out else 4
14221
+ main_idx = 11 if "Compatibility: OK" in out else 4
13670
14222
  elif panel == "log-level":
13671
14223
  if value == "back":
13672
14224
  close_panel()
@@ -13688,23 +14240,43 @@ def portable_prelaunch_menu() -> int:
13688
14240
  elif panel == "channels":
13689
14241
  if value == "back":
13690
14242
  close_panel()
14243
+ elif value in ("__heading__", "__noop__"):
14244
+ continue
14245
+ elif value == "__reprobe__":
14246
+ panel_rows, panel_values = ["Re-probing MCP channel capability..."], []
14247
+ first_render = render_prelaunch_screen(main_idx, panel, 0, panel_rows, checks, messages, first_render)
14248
+ try:
14249
+ result = refresh_channel_probe_cache(passthrough)
14250
+ capable = [r for r in result.get("servers") or [] if r.get("capable")]
14251
+ messages = [f"Probe complete: {len(capable)} channel-capable server(s)."]
14252
+ except Exception as exc:
14253
+ messages = [f"Re-probe failed: {type(exc).__name__}: {exc}"]
14254
+ cfg = load_config()
14255
+ panel_rows, panel_values = channel_panel_rows(cfg)
14256
+ if panel_values:
14257
+ panel_idx = _channel_panel_first_selectable(panel_values)
13691
14258
  elif value == "__add_custom__":
13692
14259
  spec = prompt_menu_value("Channel spec (for example plugin:ainet@local or server:ainet)", restore_tty=restore_line_mode, raw_tty=restore_raw_mode)
13693
14260
  if spec:
13694
14261
  messages = add_channel_spec(spec)
13695
14262
  cfg = load_config()
13696
14263
  panel_rows, panel_values = channel_panel_rows(cfg)
14264
+ if panel_values:
14265
+ panel_idx = _channel_panel_first_selectable(panel_values)
13697
14266
  elif value == "__remove__":
13698
14267
  spec = prompt_menu_value("Channel spec to remove", "", restore_tty=restore_line_mode, raw_tty=restore_raw_mode)
13699
14268
  if spec:
13700
14269
  messages = remove_channel_spec(spec)
13701
14270
  cfg = load_config()
13702
14271
  panel_rows, panel_values = channel_panel_rows(cfg)
14272
+ if panel_values:
14273
+ panel_idx = _channel_panel_first_selectable(panel_values)
13703
14274
  elif value == "__clear__":
13704
14275
  messages = clear_channel_specs()
13705
14276
  cfg = load_config()
13706
14277
  panel_rows, panel_values = channel_panel_rows(cfg)
13707
- panel_idx = 0
14278
+ if panel_values:
14279
+ panel_idx = _channel_panel_first_selectable(panel_values)
13708
14280
  elif value:
13709
14281
  if value in channel_specs(cfg):
13710
14282
  messages = remove_channel_spec(value)
@@ -13821,7 +14393,7 @@ def portable_prelaunch_menu() -> int:
13821
14393
  elif key in ("esc", "q"):
13822
14394
  return 10
13823
14395
  elif key == "enter":
13824
- actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "channel-delivery", "log-level", "test", "launch", "quit"]
14396
+ actions = ["language", "provider", "api-key", "base-url", "model", "advisor-model", "options", "channel-delivery", "channels", "log-level", "test", "launch", "quit"]
13825
14397
  action = actions[main_idx]
13826
14398
  if action == "launch":
13827
14399
  blockers = launch_readiness_errors()
@@ -13868,14 +14440,16 @@ def run_prelaunch_menu(passthrough: list[str], skip_menu: bool = False, force_me
13868
14440
  rc = run_external_menu("claude-any-menu")
13869
14441
  if rc is not None:
13870
14442
  return rc
13871
- return portable_prelaunch_menu()
14443
+ return portable_prelaunch_menu(passthrough)
13872
14444
 
13873
14445
 
13874
14446
  def start_router_if_needed() -> None:
13875
14447
  if router_up():
14448
+ router_log("INFO", f"router_check_state running=True spawn=False base={ROUTER_BASE}")
13876
14449
  return
13877
14450
  stop_router_processes(quiet=True)
13878
14451
  if router_up():
14452
+ router_log("INFO", f"router_check_state running=True spawn=False after_stop=True base={ROUTER_BASE}")
13879
14453
  return
13880
14454
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
13881
14455
  cmd = [sys.executable, str(Path(__file__).resolve()), "serve"]
@@ -13886,11 +14460,13 @@ def start_router_if_needed() -> None:
13886
14460
  kwargs["creationflags"] = flags
13887
14461
  else:
13888
14462
  kwargs["start_new_session"] = True
14463
+ router_log("INFO", f"router_check_state running=False spawn=True base={ROUTER_BASE}")
13889
14464
  with open(LOG_PATH, "ab", buffering=0) as log:
13890
14465
  subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=log, stderr=log, **kwargs)
13891
14466
  deadline = time.time() + 30
13892
14467
  while time.time() < deadline:
13893
14468
  if router_up():
14469
+ router_log("INFO", f"router_spawned running=True base={ROUTER_BASE} elapsed={time.time()-(deadline-30):.1f}s")
13894
14470
  return
13895
14471
  time.sleep(0.5)
13896
14472
  raise RuntimeError(f"claude-any router did not start. See {LOG_PATH}")
@@ -13939,8 +14515,21 @@ def native_channel_passthrough_requested(passthrough: list[str]) -> bool:
13939
14515
  return has_passthrough_option(passthrough, "--channels", "--dangerously-load-development-channels")
13940
14516
 
13941
14517
 
13942
- def claude_channel_args(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> list[str]:
13943
- return []
14518
+ def claude_channel_args(
14519
+ cfg: dict[str, Any],
14520
+ passthrough: list[str],
14521
+ extra_specs: list[str] | None = None,
14522
+ *,
14523
+ native_channel_bridge: bool = False,
14524
+ ) -> list[str]:
14525
+ if not native_channel_bridge:
14526
+ return []
14527
+ if native_channel_passthrough_requested(passthrough):
14528
+ return []
14529
+ specs = list(channel_specs_for_launch(cfg, passthrough, extra_specs))
14530
+ if not specs:
14531
+ return []
14532
+ return ["--dangerously-load-development-channels", *specs]
13944
14533
 
13945
14534
 
13946
14535
  def claude_channels_requested(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> bool:
@@ -15103,6 +15692,7 @@ def launch_claude(
15103
15692
  update_check = False
15104
15693
  self_update_check = False
15105
15694
  run_claude_any_update_check(enabled=self_update_check)
15695
+ auto_import_passthrough_channels(passthrough)
15106
15696
  rc = run_prelaunch_menu(passthrough, skip_menu=skip_menu, force_menu=force_menu)
15107
15697
  if rc == 10:
15108
15698
  return 0
@@ -15180,7 +15770,26 @@ def launch_claude(
15180
15770
  extra_args.extend(["--mcp-config", *mcp_config_paths])
15181
15771
  if should_append_compat_prompt(provider, cfg) and not has_passthrough_option(launch_passthrough, "--system-prompt"):
15182
15772
  extra_args.extend(["--append-system-prompt", NON_ANTHROPIC_COMPAT_PROMPT])
15183
- extra_args.extend(claude_channel_args(cfg, launch_passthrough))
15773
+ detected_channel_specs: list[str] = []
15774
+ if native_channel_bridge:
15775
+ try:
15776
+ capable_names = cached_channel_capable_server_names()
15777
+ detected_channel_specs = [f"server:{name}" for name in capable_names]
15778
+ cache_age = read_channel_probe_cache().get("probed_at") or 0
15779
+ router_log(
15780
+ "INFO",
15781
+ f"channel_probe_loaded source=cache cache_age_ts={int(cache_age)} count={len(capable_names)} servers={','.join(capable_names) or '-'}",
15782
+ )
15783
+ except Exception as exc:
15784
+ router_log("WARN", f"channel_probe_cache_load_failed error={type(exc).__name__}: {exc}")
15785
+ extra_args.extend(
15786
+ claude_channel_args(
15787
+ cfg,
15788
+ launch_passthrough,
15789
+ extra_specs=detected_channel_specs,
15790
+ native_channel_bridge=native_channel_bridge,
15791
+ )
15792
+ )
15184
15793
  cmd = [
15185
15794
  claude,
15186
15795
  "--dangerously-skip-permissions",
@@ -15190,11 +15799,124 @@ def launch_claude(
15190
15799
  cmd.extend(["--model", model])
15191
15800
  cmd.extend(extra_args)
15192
15801
  cmd.extend(claude_passthrough)
15802
+ _log_claude_command_for_diagnostics(cmd, env)
15803
+ capture_stderr = env_bool(os.environ.get("CLAUDE_ANY_CAPTURE_CC_STDERR"), False)
15193
15804
  if stdin_channel_proxy:
15194
15805
  return subprocess_call_with_channel_wake_proxy(cmd, env)
15806
+ if capture_stderr:
15807
+ return _subprocess_call_capturing_stderr(cmd, env)
15195
15808
  return subprocess.call(cmd, env=env)
15196
15809
 
15197
15810
 
15811
+ CLAUDE_CODE_STDERR_LOG = CONFIG_DIR / "claude-code-stderr.log"
15812
+
15813
+
15814
+ def _log_claude_command_for_diagnostics(cmd: list[str], env: dict[str, str]) -> None:
15815
+ try:
15816
+ mcp_idx = cmd.index("--mcp-config") if "--mcp-config" in cmd else -1
15817
+ mcp_value = cmd[mcp_idx + 1] if 0 <= mcp_idx < len(cmd) - 1 else "-"
15818
+ except Exception:
15819
+ mcp_value = "-"
15820
+ channel_specs_in_cmd: list[str] = []
15821
+ if "--dangerously-load-development-channels" in cmd:
15822
+ start = cmd.index("--dangerously-load-development-channels") + 1
15823
+ for arg in cmd[start:]:
15824
+ if arg.startswith("--"):
15825
+ break
15826
+ channel_specs_in_cmd.append(arg)
15827
+ router_log(
15828
+ "INFO",
15829
+ "claude_launch_cmd mcp_config=%s channels=%s argv_len=%d"
15830
+ % (
15831
+ mcp_value,
15832
+ ",".join(channel_specs_in_cmd) or "-",
15833
+ len(cmd),
15834
+ ),
15835
+ )
15836
+ relevant_env_keys = (
15837
+ "ANTHROPIC_BASE_URL",
15838
+ "ANTHROPIC_MODEL",
15839
+ "ANTHROPIC_API_KEY",
15840
+ "ANTHROPIC_AUTH_TOKEN",
15841
+ "CLAUDE_ANY_PROVIDER",
15842
+ "CLAUDE_ANY_MODEL_ALIAS",
15843
+ "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS",
15844
+ )
15845
+ env_summary = []
15846
+ for key in relevant_env_keys:
15847
+ if key in env:
15848
+ val = env[key]
15849
+ if "KEY" in key or "TOKEN" in key:
15850
+ val = mask_secret(val)
15851
+ env_summary.append(f"{key}={val}")
15852
+ if env_summary:
15853
+ router_log("INFO", "claude_launch_env " + " ".join(env_summary))
15854
+
15855
+
15856
+ def _subprocess_call_capturing_stderr(cmd: list[str], env: dict[str, str]) -> int:
15857
+ """Like subprocess.call but tees Claude Code's stderr into
15858
+ ~/.config/claude-any/claude-code-stderr.log so the user can collect
15859
+ the exact context around messages like
15860
+ `--dangerously-load-development-channels ignored (server:...)`.
15861
+
15862
+ Enabled via CLAUDE_ANY_CAPTURE_CC_STDERR=1."""
15863
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
15864
+ try:
15865
+ if CLAUDE_CODE_STDERR_LOG.exists() and CLAUDE_CODE_STDERR_LOG.stat().st_size > 2_000_000:
15866
+ CLAUDE_CODE_STDERR_LOG.replace(CLAUDE_CODE_STDERR_LOG.with_suffix(".log.1"))
15867
+ except Exception:
15868
+ pass
15869
+ try:
15870
+ log_handle = CLAUDE_CODE_STDERR_LOG.open("ab", buffering=0)
15871
+ except Exception as exc:
15872
+ router_log("WARN", f"claude_stderr_capture_open_failed error={type(exc).__name__}: {exc}")
15873
+ return subprocess.call(cmd, env=env)
15874
+ header = f"\n===== claude launch at {time.strftime('%Y-%m-%dT%H:%M:%S')} =====\n".encode("utf-8")
15875
+ try:
15876
+ log_handle.write(header)
15877
+ except Exception:
15878
+ pass
15879
+ try:
15880
+ proc = subprocess.Popen(cmd, env=env, stderr=subprocess.PIPE)
15881
+ except Exception as exc:
15882
+ router_log("WARN", f"claude_stderr_capture_spawn_failed error={type(exc).__name__}: {exc}")
15883
+ try:
15884
+ log_handle.close()
15885
+ except Exception:
15886
+ pass
15887
+ return subprocess.call(cmd, env=env)
15888
+
15889
+ def _tee_stderr() -> None:
15890
+ try:
15891
+ if proc.stderr is None:
15892
+ return
15893
+ while True:
15894
+ chunk = proc.stderr.read(4096)
15895
+ if not chunk:
15896
+ break
15897
+ try:
15898
+ sys.stderr.buffer.write(chunk)
15899
+ sys.stderr.buffer.flush()
15900
+ except Exception:
15901
+ pass
15902
+ try:
15903
+ log_handle.write(chunk)
15904
+ except Exception:
15905
+ pass
15906
+ finally:
15907
+ try:
15908
+ log_handle.close()
15909
+ except Exception:
15910
+ pass
15911
+
15912
+ tee_thread = threading.Thread(target=_tee_stderr, daemon=True, name="claude-stderr-tee")
15913
+ tee_thread.start()
15914
+ rc = proc.wait()
15915
+ tee_thread.join(timeout=2.0)
15916
+ router_log("INFO", f"claude_exit code={rc} stderr_log={CLAUDE_CODE_STDERR_LOG}")
15917
+ return rc
15918
+
15919
+
15198
15920
  def cli_usage() -> str:
15199
15921
  return """Usage:
15200
15922
  claude-any Launch Claude Code through claude-any router
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneciel-ai/claude-any",
3
- "version": "0.1.96",
3
+ "version": "0.1.98",
4
4
  "description": "Claude Code provider selector for Anthropic, Ollama, Ollama Cloud, vLLM, NVIDIA hosted, and self-hosted NIM.",
5
5
  "license": "MIT",
6
6
  "author": "One Ciel LLC",