Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ attachment and optional App-control MCP tools are separate user choices.
- **cwd must match resume**: a session's jsonl lives at
`~/.claude/projects/<cwd-with-/-as->/<uuid>.jsonl`. `ClaudeAgentOptions.cwd`
MUST equal the original session's cwd or `resume` can't find it.
- **SDK pinned to `claude-agent-sdk==0.2.151`**: message-type shapes and the
- **SDK pinned to `claude-agent-sdk==0.2.157`**: message-type shapes and the
interrupt/drain contract can shift between patch versions. Re-run the
interrupt+drain verification after any upgrade (`SdkHandle.preflight()` guards
the exact verified patch at startup).
Expand All @@ -78,7 +78,7 @@ attachment and optional App-control MCP tools are separate user choices.
- **`include_partial_messages`** is a `ClaudeAgentOptions` field (set at
construction, not on `query()`). Streaming events arrive as `StreamEvent`
(`.event` = raw Anthropic API stream-event dict) — NOT
`SDKPartialAssistantMessage` (doesn't exist in 0.2.151). Extract
`SDKPartialAssistantMessage` (doesn't exist in 0.2.157). Extract
`content_block_delta` → `delta.text` from `StreamEvent.event`.
- **tool_use is batched, not streamed**: emit one `tool_use` event from the
assembled `AssistantMessage` (full `input`), never as JSON-fragment deltas.
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@

[中文](CHANGELOG_zh.md)

## v4.0.4

Discover Claude models from the installed CLI and improve inline code emphasis.
Wire protocol remains v72. See the bilingual [release notes](docs/releases/v4.0.4.md).

- Populate Web, side-chat and TUI model pickers with the native model catalog and
supported effort levels, without sending prompts or resuming sessions.
- Refresh the catalog when opening model settings, scope its cache by account,
directory and CLI build, and preserve explicitly selected historical models.
- Keep Work discovery within its runtime policy and inherit native Claude model
and effort defaults instead of imposing a fixed Opus/max selection.
- Refresh Codex models on page reload, reconnect and picker open; share concurrent
reads per account and retain that account's last good list on discovery failure.
- Support Codex 0.156's protected socket aliases while preserving account ownership
checks and detecting replacement of the underlying app-server listener.
- Upgrade the verified Python Claude Agent SDK from 0.2.151 to 0.2.157. Wrapper
upgrades across this SDK boundary require the documented drain/installer path.
- Render inline code in softer harness colors without a background badge; actual
links retain their link styling and fenced code blocks retain their background.
- Expand Claude thinking text by default. Pressing `/` outside an editor focuses
the composer without inserting a character; pressing it again types normally.

## v4.0.3

Fix duplicated and misplaced Claude messages when browsing overlapping history pages.
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

[English](CHANGELOG.md)

## v4.0.4

从本机 Claude CLI 自动发现模型,并优化行内代码高亮。通信协议保持 v72。
详见[双语发布说明](docs/releases/v4.0.4.md)。

- Web、侧聊和 TUI 的模型列表读取原生目录及支持的思考强度,不发送提示词或恢复会话。
- 打开模型设置时刷新目录,按账号、目录和 CLI 构建缓存;保留用户明确选择的历史模型。
- Work 模型发现遵循其实际运行策略;Claude 新会话继承原生模型和思考强度默认值,
不再强制使用固定的 Opus/max 组合。
- 刷新页面、重连或打开模型菜单时重新读取 Codex 模型;同账号并发请求合并,读取
失败时保留该账号上次成功的列表。
- 兼容 Codex 0.156 的受保护套接字别名,继续校验账号归属并检测底层监听器替换。
- 已验证的 Python Claude Agent SDK 从 0.2.151 升级至 0.2.157;跨 SDK 版本升级
Wrapper 时,需按文档等待任务结束、迁移服务并使用安装器升级。
- 行内代码改为更柔和的引擎主题色文字,去掉背景块;真实链接保留链接样式,代码块
仍保留背景。
- Claude 思考文本默认展开;未聚焦编辑器时按 `/` 只聚焦输入框,再按一次才输入字符。

## v4.0.3

修复 Claude 历史翻页时对话重复、后续问题被放到开头的问题。通信协议保持 v72。
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ a separate choice; sharing alone does not authorize them.
- **cwd must match resume**: a session's jsonl lives at
`~/.claude/projects/<cwd-with-/-as->/<uuid>.jsonl`. `ClaudeAgentOptions.cwd`
MUST equal the original session's cwd or `resume` can't find it.
- **SDK pinned to `claude-agent-sdk==0.2.151`**: message-type shapes and the
- **SDK pinned to `claude-agent-sdk==0.2.157`**: message-type shapes and the
interrupt/drain contract can shift between patch versions. Re-run the
interrupt+drain verification after any upgrade (`SdkHandle.preflight()` guards
the exact verified patch at startup).
Expand All @@ -61,7 +61,7 @@ a separate choice; sharing alone does not authorize them.
- **`include_partial_messages`** is a `ClaudeAgentOptions` field (set at
construction, not on `query()`). Streaming events arrive as `StreamEvent`
(`.event` = raw Anthropic API stream-event dict) — NOT
`SDKPartialAssistantMessage` (doesn't exist in 0.2.151). Extract
`SDKPartialAssistantMessage` (doesn't exist in 0.2.157). Extract
`content_block_delta` → `delta.text` from `StreamEvent.event`.
- **tool_use is batched, not streamed**: emit one `tool_use` event from the
assembled `AssistantMessage` (full `input`), never as JSON-fragment deltas.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

自托管 · 多会话 · 多设备 · 实时工具过程 · Code / Work · Web / PWA / TUI

**产品版本:v4.0.3** · Wire protocol v72
**产品版本:v4.0.4** · Wire protocol v72

[English](README_en.md) · [功能对照](#引擎与功能) · [快速开始](#快速开始) ·
[终端工作台](#terminal-workspace) · [安装与升级](#安装与升级) · [文档](#文档) · [更新记录](CHANGELOG_zh.md)
Expand Down Expand Up @@ -152,7 +152,7 @@ Wrapper 主动出站连接 Relay,设备不需要开放公网入站端口。Rel
至少准备一个可用引擎:

- **Claude**:日常 Claude Code `>= 2.1.263`,默认路径 `~/.local/bin/claude`。
Wrapper 使用该 CLI;Python Agent SDK 固定为 `0.2.151`。
Wrapper 使用该 CLI;Python Agent SDK 固定为 `0.2.157`。
- **Codex**:已登录的官方 CLI。共享控制需要同时支持
`codex app-server daemon --help` 和 `codex app-server proxy --help`。

Expand Down
4 changes: 2 additions & 2 deletions README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Self-hosted · Multiple sessions and devices · Live tool activity · Code / Work · Web / PWA / TUI

**Product version: v4.0.3** · Wire protocol v72
**Product version: v4.0.4** · Wire protocol v72

[中文](README.md) · [Engine comparison](#engines-and-features) · [Quick start](#quick-start) ·
[Terminal workspace](#terminal-workspace) · [Install and upgrade](#install-and-upgrade) · [Documentation](#documentation) · [Changelog](CHANGELOG.md)
Expand Down Expand Up @@ -173,7 +173,7 @@ development/builds use **Python 3.13 and Node 24**, matching CI and [`.nvmrc`](.
Prepare at least one working engine:

- **Claude:** daily Claude Code `>= 2.1.263`, normally at `~/.local/bin/claude`.
Wrapper launches that CLI; the Python Agent SDK is pinned to `0.2.151`.
Wrapper launches that CLI; the Python Agent SDK is pinned to `0.2.157`.
- **Codex:** an authenticated official CLI. Shared control requires both
`codex app-server daemon --help` and `codex app-server proxy --help`.

Expand Down
2 changes: 1 addition & 1 deletion cc_remote/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
- control link: client <-> relay(WS) <-> wrapper <-> ClaudeSDKClient <-> cc
"""

__version__ = "4.0.3"
__version__ = "4.0.4"
2 changes: 1 addition & 1 deletion cc_remote/claude_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def claude_config_dir() -> Path:
"""Return the active native Claude Code config root."""
configured = os.environ.get("CLAUDE_CONFIG_DIR")
if configured:
# Match claude-agent-sdk 0.2.151 exactly: the environment value is a
# Match claude-agent-sdk 0.2.157 exactly: the environment value is a
# filesystem path, not a shell expression, so do not expand "~" or
# resolve symlinks behind the SDK's back.
return Path(unicodedata.normalize("NFC", configured))
Expand Down
5 changes: 2 additions & 3 deletions cc_remote/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -1797,9 +1797,8 @@ class DirList(_Base):
class GetModels(_Command):
"""client -> wrapper: what models does this engine actually offer?

`codex` answers with app-server's real catalog. Claude has no equivalent
catalog RPC, but can resolve explicit no-override settings for a cwd;
its model list therefore remains empty and the client keeps the static table.
Codex answers with app-server's model/list. Claude reads the native picker
from a prompt-free initialization and resolves no-override settings for cwd.
"""
type: Literal["get_models"] = "get_models"
engine: Optional[Literal["cc", "claude", "codex"]] = None
Expand Down
14 changes: 6 additions & 8 deletions cc_remote/tui_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ def args(self, kind):
values = {"cwd": self.values["cwd"], **self.profiles}
if kind == "models":
values["engine"] = self.engine
if self.space == "work":
values.pop("cwd", None)
else:
values.pop("claude_profile_id", None)
return values
Expand Down Expand Up @@ -237,8 +239,8 @@ def choices(self, field):
choices = [("Engine default (no override)", None)] if self.new else []
if field == "model":
models = self.catalog("models").get("models", [])
if self.engine == "claude":
# Claude has no model/list RPC; same curated choices as Web.
if self.engine == "claude" and not models:
# Offline fallback only; the native CLI catalog wins.
models = [{"id": value} for value in CLAUDE_MODELS]
for model in models:
identity = model.get("id")
Expand All @@ -258,7 +260,7 @@ def choices(self, field):
{},
)
efforts = model.get("efforts", [])
if self.engine == "claude":
if self.engine == "claude" and not model:
from cc_remote.wrapper.claude_controls import CLAUDE_EFFORTS

efforts = sorted(CLAUDE_EFFORTS)
Expand Down Expand Up @@ -467,9 +469,5 @@ def created(self, success):
}

CLAUDE_MODELS = (
"claude-opus-5[1m]",
"claude-mythos-5-1",
"claude-sonnet-5",
"claude-haiku-4-5",
"claude-fable-5-1",
"opus[1m]", "sonnet", "haiku",
)
181 changes: 181 additions & 0 deletions cc_remote/wrapper/claude_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""Discover models from the daily Claude CLI's initialization response.

This is the same native catalog exposed by the SDK's get_server_info(). No
prompt, resume, or model request is sent. A short-lived, customization-disabled
child preserves native account/model settings without running hooks or MCPs.
"""
from __future__ import annotations

import asyncio
from collections import OrderedDict
from contextlib import ExitStack
import json
import os
from pathlib import Path
import tempfile
import time

from cc_remote.log import logger
from cc_remote.wrapper.child_env import claude_profile_process_env
from cc_remote.wrapper.claude_controls import valid_claude_model
from cc_remote.wrapper.claude_runtime import resolve_claude_cli
from cc_remote.workspaces import _claude_runtime_settings

log = logger("cc_remote.wrapper.claude_models")
_TTL = 60.0
_TIMEOUT = 20.0
_MAX_SCOPES = 64
_cache: OrderedDict[tuple, tuple[float, list[dict]]] = OrderedDict()
_lock = asyncio.Lock()


def _normalize(raw: object) -> list[dict]:
if not isinstance(raw, list):
return []
models: dict[str, dict] = {}
for item in raw[:256]:
if not isinstance(item, dict):
continue
# Keep Default as a native selection: availableModels can permit the
# tier default while forbidding an explicit selection of its resolved
# ID. Named rows use resolved IDs to expose newly shipped versions.
value = valid_claude_model(item.get("value"))
identity = ("default" if value == "default" else
valid_claude_model(item.get("resolvedModel")) or value)
if not identity:
continue
levels = item.get("supportedEffortLevels")
levels = levels if isinstance(levels, list) else []
efforts = list(dict.fromkeys(
level for level in levels[:16]
if isinstance(level, str) and 0 < len(level) <= 64
)) if item.get("supportsEffort") is not False else []
is_default = item.get("value") == "default"
previous = models.get(identity)
# Repeated rows must not create duplicate choices.
if previous and is_default:
previous["is_default"] = True
continue
models[identity] = {
"id": identity,
"display_name": str(item.get("displayName") or identity)[:4096],
"description": str(item.get("description") or "")[:4096],
"efforts": efforts,
"default_effort": None,
"is_default": is_default or bool(previous and previous["is_default"]),
}
return list(models.values())


async def _read_catalog(
binary: str, cwd: str, config_dir: str | None, isolate_account_env: bool,
work_only: bool,
) -> list[dict]:
env = claude_profile_process_env(
config_dir, isolate_account_env=isolate_account_env)
env.pop("CLAUDECODE", None)
args = [
binary, "--print", "--input-format", "stream-json",
"--output-format", "stream-json", "--verbose",
"--no-session-persistence", "--safe-mode", "--strict-mcp-config",
"--tools", "",
]
with ExitStack() as scope:
if work_only:
# Match Work's sole policy source and provider allowlist. Never load
# user/project customizations or put provider credentials in argv.
settings = scope.enter_context(tempfile.NamedTemporaryFile(
mode="w+", prefix="cc-remote-models-", suffix=".json"))
json.dump(_claude_runtime_settings(config_dir), settings)
settings.flush()
args.extend(["--setting-sources", "", "--settings", settings.name])
elif isolate_account_env:
args.extend(["--setting-sources", "user"])
return await _initialize_catalog(args, cwd, env)


async def _initialize_catalog(args: list[str], cwd: str, env: dict[str, str]) -> list[dict]:
proc = await asyncio.create_subprocess_exec(
*args, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL, cwd=cwd, env=env,
limit=4 * 1024 * 1024,
)
try:
async with asyncio.timeout(_TIMEOUT):
proc.stdin.write((json.dumps({
"type": "control_request", "request_id": "model-catalog",
"request": {"subtype": "initialize"},
}) + "\n").encode())
await proc.stdin.drain()
while line := await proc.stdout.readline():
message = json.loads(line)
if not isinstance(message, dict):
continue
response = message.get("response")
if (message.get("type") != "control_response"
or not isinstance(response, dict)
or response.get("request_id") != "model-catalog"):
continue
if response.get("subtype") != "success":
raise RuntimeError("Claude initialization rejected")
result = response.get("response")
return _normalize(result.get("models") if isinstance(result, dict) else None)
raise RuntimeError("Claude closed before model discovery")
finally:
proc.stdin.close()
if proc.returncode is None:
try:
proc.terminate()
except ProcessLookupError:
pass
try:
await asyncio.wait_for(proc.wait(), 3.0)
except asyncio.TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
await proc.wait()


async def claude_model_catalog(
*, claude_bin: str, cwd: str | None = None,
config_dir: str | None = None, isolate_account_env: bool = False,
) -> list[dict]:
"""Bounded account/cwd cache; CLI replacement bypasses the TTL immediately.

Missing cwd means Work discovery using its filtered runtime settings, never
borrowing customizations from the Wrapper's Code project or user settings.
"""
async with _lock:
try:
binary, _ = resolve_claude_cli(
claude_bin or str(Path.home() / ".local/bin/claude"))
binary = os.path.realpath(binary)
stat = os.stat(binary)
target = os.path.realpath(os.path.expanduser(cwd or str(Path.home())))
key = (
binary, stat.st_mtime_ns, stat.st_size, target,
os.path.realpath(config_dir) if config_dir else None,
isolate_account_env, cwd is None,
)
except (OSError, RuntimeError):
return []
cached_at, cached = _cache.get(key, (0.0, []))
if key in _cache and time.monotonic() - cached_at < _TTL:
_cache.move_to_end(key)
return cached
try:
models = await _read_catalog(
binary, target, config_dir, isolate_account_env, cwd is None)
except Exception as exc:
# Native errors can contain provider/account information; report
# only their class, never dump raw initialization or stderr.
log.warning("Claude model discovery failed", reason=type(exc).__name__)
models = []
result = models or cached
_cache[key] = (time.monotonic(), result)
_cache.move_to_end(key)
while len(_cache) > _MAX_SCOPES:
_cache.popitem(last=False)
return result
2 changes: 1 addition & 1 deletion cc_remote/wrapper/claude_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import claude_agent_sdk


VERIFIED_SDK_VERSION = "0.2.151"
VERIFIED_SDK_VERSION = "0.2.157"
MINIMUM_CLAUDE_CLI_VERSION = "2.1.263"
_CLI_VERSION_TIMEOUT = 3.0
_VERSION_RE = re.compile(
Expand Down
2 changes: 1 addition & 1 deletion cc_remote/wrapper/claude_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class AccountIsolatedSubprocessCLITransport(
):
"""Spawn one Claude child without inherited account selectors.

Agent SDK 0.2.151 treats ``ClaudeAgentOptions.env`` as an overlay on the
Agent SDK 0.2.157 treats ``ClaudeAgentOptions.env`` as an overlay on the
Wrapper process environment. Empty strings are not equivalent to unsetting
modern Claude authentication selectors, while mutating ``os.environ`` would
race unrelated Codex and tool spawns. This pinned adapter mirrors the SDK's
Expand Down
Loading
Loading