@oneciel-ai/claude-any 0.1.95 → 0.1.97

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 +300 -8
  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
@@ -105,7 +106,7 @@ OFFICIAL_CHANNEL_PLUGINS = {
105
106
  "fakechat": "plugin:fakechat@claude-plugins-official",
106
107
  }
107
108
  APP_NAME = "Claude Any"
108
- VERSION = "0.1.95"
109
+ VERSION = "0.1.97"
109
110
  CREDITS = "Credits: One Ciel LLC"
110
111
 
111
112
  LOG_LEVELS = {"SILENT": 0, "ERROR": 1, "WARN": 2, "INFO": 3, "DEBUG": 4, "TRACE": 5}
@@ -9498,6 +9499,233 @@ def _mcp_server_is_stdio(server: dict[str, Any]) -> bool:
9498
9499
  return "mcp-proxy" not in args
9499
9500
 
9500
9501
 
9502
+ def _channel_probe_initialize_payload() -> bytes:
9503
+ payload = {
9504
+ "jsonrpc": "2.0",
9505
+ "id": 1,
9506
+ "method": "initialize",
9507
+ "params": {
9508
+ "protocolVersion": "2024-11-05",
9509
+ "capabilities": {},
9510
+ "clientInfo": {"name": "claude-any-channel-probe", "version": VERSION},
9511
+ },
9512
+ }
9513
+ return json.dumps(payload, ensure_ascii=False).encode("utf-8")
9514
+
9515
+
9516
+ def _channel_probe_parse_framed_responses(buffer: bytes) -> list[dict[str, Any]]:
9517
+ out: list[dict[str, Any]] = []
9518
+ idx = 0
9519
+ while idx < len(buffer):
9520
+ header_end = buffer.find(b"\r\n\r\n", idx)
9521
+ if header_end < 0:
9522
+ return out
9523
+ header = buffer[idx:header_end].decode("ascii", errors="replace")
9524
+ length: int | None = None
9525
+ for line in header.split("\r\n"):
9526
+ if line.lower().startswith("content-length:"):
9527
+ try:
9528
+ length = int(line.split(":", 1)[1].strip())
9529
+ except Exception:
9530
+ return out
9531
+ break
9532
+ if length is None:
9533
+ return out
9534
+ body_start = header_end + 4
9535
+ body_end = body_start + length
9536
+ if len(buffer) < body_end:
9537
+ return out
9538
+ try:
9539
+ msg = json.loads(buffer[body_start:body_end].decode("utf-8", errors="replace"))
9540
+ except Exception:
9541
+ idx = body_end
9542
+ continue
9543
+ if isinstance(msg, dict):
9544
+ out.append(msg)
9545
+ idx = body_end
9546
+ return out
9547
+
9548
+
9549
+ def _channel_probe_parse_jsonl_responses(buffer: bytes) -> list[dict[str, Any]]:
9550
+ out: list[dict[str, Any]] = []
9551
+ for raw_line in buffer.split(b"\n"):
9552
+ line = raw_line.strip()
9553
+ if not line:
9554
+ continue
9555
+ try:
9556
+ msg = json.loads(line.decode("utf-8", errors="replace"))
9557
+ except Exception:
9558
+ continue
9559
+ if isinstance(msg, dict):
9560
+ out.append(msg)
9561
+ return out
9562
+
9563
+
9564
+ def _channel_probe_find_initialize_response(buffer: bytes, framed: bool) -> dict[str, Any] | None:
9565
+ msgs = _channel_probe_parse_framed_responses(buffer) if framed else _channel_probe_parse_jsonl_responses(buffer)
9566
+ for msg in msgs:
9567
+ if msg.get("id") == 1 and "result" in msg:
9568
+ return msg
9569
+ return None
9570
+
9571
+
9572
+ def _channel_probe_capability_present(initialize_response: dict[str, Any]) -> bool:
9573
+ result = initialize_response.get("result")
9574
+ if not isinstance(result, dict):
9575
+ return False
9576
+ capabilities = result.get("capabilities")
9577
+ if not isinstance(capabilities, dict):
9578
+ return False
9579
+ experimental = capabilities.get("experimental")
9580
+ if not isinstance(experimental, dict):
9581
+ return False
9582
+ value = experimental.get("claude/channel")
9583
+ return value is not None and value is not False
9584
+
9585
+
9586
+ def probe_stdio_mcp_for_channel_capability(server_name: str, server: dict[str, Any], timeout: float = 3.0) -> bool:
9587
+ if not _mcp_server_is_stdio(server):
9588
+ return False
9589
+ command = str(server.get("command") or "").strip()
9590
+ args_raw = server.get("args", [])
9591
+ args = [str(item) for item in args_raw] if isinstance(args_raw, list) else []
9592
+ if not command:
9593
+ return False
9594
+ command, args = resolve_mcp_server_process(command, args)
9595
+ env = os.environ.copy()
9596
+ raw_env = server.get("env")
9597
+ if isinstance(raw_env, dict):
9598
+ env.update({str(k): str(v) for k, v in raw_env.items() if str(k)})
9599
+ cwd_value = server.get("cwd") or server.get("workingDirectory")
9600
+ cwd = str(cwd_value) if cwd_value else None
9601
+ framed = _mcp_proxy_stdio_mode(server) != "jsonl"
9602
+
9603
+ proc: subprocess.Popen[bytes] | None = None
9604
+ try:
9605
+ proc = subprocess.Popen(
9606
+ [command, *args],
9607
+ stdin=subprocess.PIPE,
9608
+ stdout=subprocess.PIPE,
9609
+ stderr=subprocess.DEVNULL,
9610
+ cwd=cwd,
9611
+ env=env,
9612
+ bufsize=0,
9613
+ close_fds=True,
9614
+ )
9615
+ except Exception as exc:
9616
+ router_log("DEBUG", f"channel_probe_spawn_failed server={server_name} error={type(exc).__name__}: {exc}")
9617
+ return False
9618
+
9619
+ chunks_queue: queue.Queue[bytes | None] = queue.Queue()
9620
+
9621
+ def _reader() -> None:
9622
+ try:
9623
+ assert proc is not None
9624
+ stdout = proc.stdout
9625
+ if stdout is None:
9626
+ return
9627
+ while True:
9628
+ chunk = stdout.read(4096)
9629
+ if not chunk:
9630
+ break
9631
+ chunks_queue.put(chunk)
9632
+ except Exception:
9633
+ pass
9634
+ finally:
9635
+ chunks_queue.put(None)
9636
+
9637
+ threading.Thread(target=_reader, daemon=True, name=f"channel-probe-stdout-{server_name}").start()
9638
+
9639
+ body = _channel_probe_initialize_payload()
9640
+ if framed:
9641
+ frame = b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n\r\n" + body
9642
+ else:
9643
+ frame = body + b"\n"
9644
+ try:
9645
+ if proc.stdin:
9646
+ proc.stdin.write(frame)
9647
+ proc.stdin.flush()
9648
+ except Exception:
9649
+ pass
9650
+
9651
+ deadline = time.time() + timeout
9652
+ stdout_buf = bytearray()
9653
+ capable = False
9654
+ try:
9655
+ while time.time() < deadline:
9656
+ wait = min(0.2, max(0.001, deadline - time.time()))
9657
+ try:
9658
+ chunk = chunks_queue.get(timeout=wait)
9659
+ except queue.Empty:
9660
+ continue
9661
+ if chunk is None:
9662
+ break
9663
+ stdout_buf.extend(chunk)
9664
+ response = _channel_probe_find_initialize_response(bytes(stdout_buf), framed)
9665
+ if response is not None:
9666
+ capable = _channel_probe_capability_present(response)
9667
+ break
9668
+ finally:
9669
+ try:
9670
+ if proc.stdin:
9671
+ proc.stdin.close()
9672
+ except Exception:
9673
+ pass
9674
+ try:
9675
+ proc.terminate()
9676
+ proc.wait(timeout=1.0)
9677
+ except Exception:
9678
+ try:
9679
+ proc.kill()
9680
+ except Exception:
9681
+ pass
9682
+
9683
+ router_log(
9684
+ "INFO",
9685
+ f"channel_probe_result server={server_name} channel_capable={capable} bytes={len(stdout_buf)}",
9686
+ )
9687
+ return capable
9688
+
9689
+
9690
+ def detect_channel_capable_mcp_servers(
9691
+ mcp_config_paths: Iterable[str],
9692
+ cwd: Path,
9693
+ *,
9694
+ include_router_self: bool = True,
9695
+ timeout_per_server: float = 3.0,
9696
+ ) -> list[str]:
9697
+ """Probe MCP servers declared in given config files; return names that declare experimental['claude/channel']."""
9698
+ capable: list[str] = []
9699
+ seen: set[str] = set()
9700
+ if include_router_self:
9701
+ capable.append("claude-any-router")
9702
+ seen.add("claude-any-router")
9703
+ for path_str in mcp_config_paths:
9704
+ if not path_str:
9705
+ continue
9706
+ path = Path(path_str)
9707
+ if not path.exists():
9708
+ continue
9709
+ for name, server in _read_mcp_servers_from_json(path, cwd):
9710
+ if name in seen:
9711
+ continue
9712
+ seen.add(name)
9713
+ if name == "claude-any-router":
9714
+ continue
9715
+ if not _mcp_server_is_stdio(server):
9716
+ # Non-stdio (sse/http) probing not implemented; skip silently.
9717
+ continue
9718
+ try:
9719
+ if probe_stdio_mcp_for_channel_capability(name, server, timeout=timeout_per_server):
9720
+ capable.append(name)
9721
+ except Exception as exc:
9722
+ router_log(
9723
+ "WARN",
9724
+ f"channel_probe_exception server={name} error={type(exc).__name__}: {exc}",
9725
+ )
9726
+ return capable
9727
+
9728
+
9501
9729
  def _mcp_config_passthrough_values(passthrough: list[str]) -> list[str]:
9502
9730
  values: list[str] = []
9503
9731
  i = 0
@@ -13939,8 +14167,21 @@ def native_channel_passthrough_requested(passthrough: list[str]) -> bool:
13939
14167
  return has_passthrough_option(passthrough, "--channels", "--dangerously-load-development-channels")
13940
14168
 
13941
14169
 
13942
- def claude_channel_args(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> list[str]:
13943
- return []
14170
+ def claude_channel_args(
14171
+ cfg: dict[str, Any],
14172
+ passthrough: list[str],
14173
+ extra_specs: list[str] | None = None,
14174
+ *,
14175
+ native_channel_bridge: bool = False,
14176
+ ) -> list[str]:
14177
+ if not native_channel_bridge:
14178
+ return []
14179
+ if native_channel_passthrough_requested(passthrough):
14180
+ return []
14181
+ specs = list(channel_specs_for_launch(cfg, passthrough, extra_specs))
14182
+ if not specs:
14183
+ return []
14184
+ return ["--dangerously-load-development-channels", *specs]
13944
14185
 
13945
14186
 
13946
14187
  def claude_channels_requested(cfg: dict[str, Any], passthrough: list[str], extra_specs: list[str] | None = None) -> bool:
@@ -14160,20 +14401,41 @@ def _write_fd_all(fd: int, data: bytes) -> None:
14160
14401
  view = view[written:]
14161
14402
 
14162
14403
 
14404
+ def _channel_platform_default_enter_bytes(platform: str | None = None, os_name: str | None = None) -> bytes:
14405
+ sys_platform = str(platform if platform is not None else sys.platform).lower()
14406
+ os_family = str(os_name if os_name is not None else os.name).lower()
14407
+ if os_family == "nt" or sys_platform.startswith(("win", "cygwin", "msys")):
14408
+ return b"\r\n"
14409
+ if os_family == "posix":
14410
+ return b"\r\n"
14411
+ return b"\r\n"
14412
+
14413
+
14163
14414
  def _channel_wake_enter_bytes(value: str | bytes | None = None) -> bytes:
14164
14415
  raw: str | bytes | None = value
14165
14416
  if raw is None:
14166
14417
  raw = os.environ.get("CLAUDE_ANY_CHANNEL_WAKE_ENTER")
14418
+ if raw is None:
14419
+ return _channel_platform_default_enter_bytes()
14167
14420
  if isinstance(raw, bytes):
14168
- return raw if raw in (b"\n", b"\r", b"\r\n") else b"\n"
14421
+ return raw if raw in (b"\n", b"\r", b"\r\n") else _channel_platform_default_enter_bytes()
14169
14422
  normalized = str(raw or "").strip().lower()
14170
- if normalized in {"", "lf", "nl", "newline", "linefeed", "\\n"}:
14423
+ if normalized in {"", "auto", "default", "platform"}:
14424
+ return _channel_platform_default_enter_bytes()
14425
+ if normalized in {"lf", "nl", "newline", "linefeed", "\\n"}:
14171
14426
  return b"\n"
14172
14427
  if normalized in {"cr", "return", "carriage-return", "carriage_return", "\\r"}:
14173
14428
  return b"\r"
14174
14429
  if normalized in {"crlf", "cr-lf", "return-newline", "\\r\\n"}:
14175
14430
  return b"\r\n"
14176
- return b"\n"
14431
+ return _channel_platform_default_enter_bytes()
14432
+
14433
+
14434
+ def _channel_wake_enter_env_is_fixed() -> bool:
14435
+ raw = os.environ.get("CLAUDE_ANY_CHANNEL_WAKE_ENTER")
14436
+ if raw is None:
14437
+ return False
14438
+ return str(raw).strip().lower() not in {"", "auto", "default", "platform"}
14177
14439
 
14178
14440
 
14179
14441
  def _channel_enter_bytes_from_user_input(data: bytes) -> bytes | None:
@@ -14269,6 +14531,10 @@ def subprocess_call_with_channel_wake_proxy(cmd: list[str], env: dict[str, str])
14269
14531
  old_attrs = termios.tcgetattr(stdin_fd)
14270
14532
  last_channel_poll = 0.0
14271
14533
  channel_enter_bytes = _channel_wake_enter_bytes()
14534
+ router_log(
14535
+ "INFO",
14536
+ f"channel_stdin_proxy_enter_default enter={_channel_enter_label(channel_enter_bytes)} os={os.name} platform={sys.platform}",
14537
+ )
14272
14538
  try:
14273
14539
  tty.setraw(stdin_fd)
14274
14540
  while proc.poll() is None:
@@ -14280,7 +14546,12 @@ def subprocess_call_with_channel_wake_proxy(cmd: list[str], env: dict[str, str])
14280
14546
  data = os.read(stdin_fd, 4096)
14281
14547
  if data:
14282
14548
  observed_enter = _channel_synthetic_enter_bytes_from_user_input(data)
14283
- if observed_enter and not os.environ.get("CLAUDE_ANY_CHANNEL_WAKE_ENTER"):
14549
+ if observed_enter and not _channel_wake_enter_env_is_fixed():
14550
+ if observed_enter != channel_enter_bytes:
14551
+ router_log(
14552
+ "INFO",
14553
+ f"channel_stdin_proxy_enter_observed enter={_channel_enter_label(observed_enter)}",
14554
+ )
14284
14555
  channel_enter_bytes = observed_enter
14285
14556
  _write_fd_all(master_fd, data)
14286
14557
  if master_fd in readable:
@@ -15150,7 +15421,28 @@ def launch_claude(
15150
15421
  extra_args.extend(["--mcp-config", *mcp_config_paths])
15151
15422
  if should_append_compat_prompt(provider, cfg) and not has_passthrough_option(launch_passthrough, "--system-prompt"):
15152
15423
  extra_args.extend(["--append-system-prompt", NON_ANTHROPIC_COMPAT_PROMPT])
15153
- extra_args.extend(claude_channel_args(cfg, launch_passthrough))
15424
+ detected_channel_specs: list[str] = []
15425
+ if native_channel_bridge and mcp_config_paths:
15426
+ try:
15427
+ detected_servers = detect_channel_capable_mcp_servers(
15428
+ mcp_config_paths,
15429
+ Path(os.getcwd()),
15430
+ )
15431
+ detected_channel_specs = [f"server:{name}" for name in detected_servers]
15432
+ router_log(
15433
+ "INFO",
15434
+ f"channel_probe_detected count={len(detected_servers)} servers={','.join(detected_servers) or '-'}",
15435
+ )
15436
+ except Exception as exc:
15437
+ router_log("WARN", f"channel_probe_failed error={type(exc).__name__}: {exc}")
15438
+ extra_args.extend(
15439
+ claude_channel_args(
15440
+ cfg,
15441
+ launch_passthrough,
15442
+ extra_specs=detected_channel_specs,
15443
+ native_channel_bridge=native_channel_bridge,
15444
+ )
15445
+ )
15154
15446
  cmd = [
15155
15447
  claude,
15156
15448
  "--dangerously-skip-permissions",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneciel-ai/claude-any",
3
- "version": "0.1.95",
3
+ "version": "0.1.97",
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",