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
7 changes: 6 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,11 @@ jobs:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
notes_file="docs/releases/$RELEASE_TAG.md"
notes_args=(--generate-notes)
if [ -f "$notes_file" ]; then
notes_args=(--notes-file "$notes_file")
fi
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
is_draft="$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft)"
test "$is_draft" = true || {
Expand All @@ -222,7 +227,7 @@ jobs:
--verify-tag \
--draft \
--title "cc-remote $RELEASE_TAG" \
--generate-notes
"${notes_args[@]}"
fi
gh release upload "$RELEASE_TAG" dist/* --clobber
gh release edit "$RELEASE_TAG" --draft=false --latest
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ attachment and optional App-control MCP tools are separate user choices.
transport, never the caller's Origin. Uvicorn trusts forwarded transport
metadata only from loopback Caddy. Never put tokens in URLs or protocol
message bodies; logging redacts token/password fields.
- **Protocol version gate**: current wire protocol v71 is declared by
- **Protocol version gate**: current wire protocol v72 is declared by
`PROTOCOL_VERSION` in both `protocol.py` and `web/src/protocol.ts`.
`deserialize` hard-rejects a version mismatch, and
`_Base` is `extra="forbid"`, so ANY protocol change must be deployed to all
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@

## Unreleased

## v4.0.0

cc-remote v4 brings persistent Claude sessions, native steering, durable history
and per-turn diffs, richer side chats and previews, and a terminal workspace
preview to the Claude/Codex control plane. See the bilingual
[release notes](docs/releases/v4.0.0.md) for highlights and upgrade requirements.

### Final stability fixes

- Drain injected Claude background responses without blocking the human Result
or the next prompt. Track actual native continuations independently of child
task status, including when the CLI omits an internal user echo.
- Keep task results absorbed during an active response on that response's
original timeline. Restore consumed queued human prompts from native JSONL
and remove empty continuation process disclosures.
- Recover the original working directory from bounded native transcript reads
when a large leading image hides it from the SDK catalog.
- Deduplicate manual compaction using the exact native boundary receipt.
- Yield live-output scroll corrections to touch/scrollbar gestures and keyboard
viewport changes; suppress duplicate iOS keyboard paste deliveries while
preserving deliberate repeated pastes.

### Changes since v3.0.0

- Track native Claude continuations and their live processing state under protocol v72.
- Combine terminal queue controls and session recovery under protocol v71.
Upgrade Relay, Wrapper, Web and TUI together. TUI recovery replaces replayed
text without duplicating existing output.
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@

## 未发布

## v4.0.0

cc-remote v4 为 Claude/Codex 控制面带来独立 Claude 会话服务、原生引导、可恢复
历史与每轮差异归档、更完整的侧聊和文件预览,以及终端工作台预览版。
功能重点与升级要求见[双语发布说明](docs/releases/v4.0.0.md)。

### 稳定性收敛

- 正确消费 Claude 后台注入响应,不再阻塞主轮结束或下一条消息;按真实原生续跑
更新运行状态,区分子任务状态,并支持 CLI 省略内部用户回显的情况。
- 子任务在主轮执行中返回时沿用原时间线;从原生 JSONL 恢复已消费的排队提示词,
移除没有实际过程内容的续跑折叠项。
- 首条大图片遮住 SDK 目录信息时,通过有界原生记录读取恢复原始工作目录。
- 按原生压缩边界回执去重手动压缩记录。
- 流式输出时尊重触摸、滚动条和键盘视口变化,避免抢回底部或反复抖动;拦截 iOS
键盘重复投递的粘贴事件,同时保留用户主动连续粘贴。
- 最终协同协议为 v72,Relay、Wrapper、Web 和 TUI 必须使用一致版本。

### 自 v3.0.0 以来的改动

- 终端队列控制与会话恢复合并使用协议 v71,Relay、Wrapper、Web 和 TUI 需要
一起升级。TUI 恢复时替换已有文本,不再重复追加已显示的内容。

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ a separate choice; sharing alone does not authorize them.
`useLayoutEffect` is deliberately dependency-free — late virtualizer/image
measurements settle without a React render, and constraining it to its read
set reintroduces a full-viewport jump on touch release.
- **Protocol version gate**: current wire protocol v71 is declared by
- **Protocol version gate**: current wire protocol v72 is declared by
`PROTOCOL_VERSION` in both `protocol.py` and `web/src/protocol.ts`.
`deserialize` hard-rejects a version mismatch, and
`_Base` is `extra="forbid"`, so ANY protocol change must be deployed to all
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

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

**产品版本:v3.0.0** · Wire protocol v71
**产品版本:v4.0.0** · Wire protocol v72

[English](README_en.md) · [功能对照](#引擎与功能) · [快速开始](#快速开始) ·
[终端工作台](#terminal-workspace) · [安装与升级](#安装与升级) · [文档](#文档) · [更新记录](CHANGELOG_zh.md)
Expand Down
2 changes: 1 addition & 1 deletion 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: v3.0.0** · Wire protocol v71
**Product version: v4.0.0** · 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
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__ = "3.0.0"
__version__ = "4.0.0"
73 changes: 49 additions & 24 deletions cc_remote/claude_service/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from pathlib import Path
from uuid import uuid4

from cc_remote.claude_steering import PendingSteers, steer_message, _origin_key
from cc_remote.claude_steering import PendingSteers, background_end_ids, is_managed_input, steer_message

from .wire import (
ControllerLeaseConflict, decode_sdk, encode_sdk, private_directory,
Expand Down Expand Up @@ -99,6 +99,8 @@ def __init__(self, directory: Path, metadata: dict, factory=None):
self.callback_answers: dict[str, object] = {}
self.initializers: dict[str, dict] = {}
self.turn: dict | None = None
self.managed_input_seen = False
self.pending_compact = False
self.steers = PendingSteers()
# Controller replacement may happen before an attachment's native echo.
# Keep ownership here until exact terminal commit or explicit close.
Expand Down Expand Up @@ -198,8 +200,17 @@ async def read_messages(self) -> None:
async for value in self.client._query.receive_messages():
if self.closed:
return
value = self.steers.annotate(value)
managed_pending = bool(self.turn and self.terminal_seq is None
and not self.turn.get("awaiting_steer"))
if managed_pending and is_managed_input(
value, pending_compact=self.pending_compact and not self.steers.background_id,
):
self.managed_input_seen = True
self.origin_id = self.turn["id"]
value = self.steers.annotate(
value, managed_active=managed_pending and self.managed_input_seen)
if "__cc_steer" in value:
self.managed_input_seen = True
self.origin_id = value["__cc_steer"]["id"]
if self.turn is not None:
if self.turn.get("awaiting_steer"):
Expand All @@ -219,17 +230,15 @@ async def read_messages(self) -> None:
# stop the sole native reader at a per-turn byte cap: an offline
# long turn could then never deliver the Result that frees it.
seq = self.journal.append(value)
origin = value.get("origin")
kind = origin.get("kind") if isinstance(origin, dict) else None
if value.get("type") == "user" and kind not in (None, "human"):
background = value.get("__cc_background_start")
if background and not any(t["identity"] == background["id"] for t in self.background_turns):
self.background_turns.append({"start_seq": seq - 1, "terminal_seq": None,
"origin": _origin_key(origin),
"identity": self.steers.background_id})
if value.get("type") == "result" and kind not in (None, "human"):
for turn in reversed(self.background_turns):
if turn["terminal_seq"] is None and turn.get("origin") == _origin_key(origin):
"identity": background["id"]})
ended = background_end_ids(value)
if ended:
for turn in self.background_turns:
if turn["terminal_seq"] is None and turn["identity"] in ended:
turn["terminal_seq"] = seq
break
if value.get("type") == "system":
subtype = value.get("subtype")
task_id = value.get("task_id")
Expand All @@ -242,8 +251,14 @@ async def read_messages(self) -> None:
self.metadata["session_id"] = sid
if (self.turn is not None and _human_result(value)
and not self.turn.get("awaiting_steer")
and not value.get("__cc_steer_intermediate")):
and not value.get("__cc_steer_intermediate")
and (self.managed_input_seen or not ended)):
self.terminal_seq = seq
if not self.managed_input_seen:
# Commands/errors can terminate without a user replay.
self.origin_id = self.turn["id"]
if _human_result(value):
self.managed_input_seen = False
await self.notify()
except asyncio.CancelledError:
raise
Expand All @@ -266,6 +281,7 @@ def description(self) -> dict:
"task_seeds": list(self.task_seeds.values()),
"native_steering": True,
"background_steering": True,
"background_activity_steering": True,
"pending_steers": {uid: {"id": data["id"]} for uid, data in self.steers.pending.items()},
}

Expand Down Expand Up @@ -362,6 +378,8 @@ async def _mutate(self, method: str, params: dict):
"previous_origin_id": self.origin_id,
"start_seq": self.journal.seq, "started_at": time.time()}
self.terminal_seq = None
self.managed_input_seen = False
self.pending_compact = False
directory = params["metadata"].get("attachment_dir")
if directory:
self.steer_attachment_dirs.add(directory)
Expand All @@ -386,8 +404,11 @@ async def stream():
prompt = _prompt_messages(prompt)
elif not isinstance(prompt, str):
raise ValueError("Claude prompt must be text or message objects")
self.turn = {**params["turn"], "start_seq": self.journal.seq, "started_at": time.time()}
self.origin_id = self.turn["id"]
self.turn = {**params["turn"], "start_seq": self.journal.seq, "started_at": time.time(),
"previous_origin_id": self.origin_id}
self.managed_input_seen = False
self.pending_compact = (isinstance(prompt, str)
and prompt.split(maxsplit=1)[:1] == ["/compact"])
self.terminal_seq = None
self.submitted_turns.add(self.turn["id"])
self.callback_answers.clear()
Expand All @@ -404,6 +425,7 @@ async def stream():
if self.terminal_seq is None or params["seq"] != self.terminal_seq:
raise ValueError("Claude terminal was not acknowledged exactly")
self.ack = max(self.ack, self.terminal_seq)
self._retire_backgrounds(self.terminal_seq)
self.turn = None
self._cleanup_steer_attachments()
self.journal.prune(
Expand All @@ -416,16 +438,7 @@ async def stream():
if not 0 <= seq <= self.journal.seq:
raise ValueError("invalid Claude journal acknowledgement")
self.ack = max(self.ack, seq)
retired = [turn["terminal_seq"] for turn in self.background_turns
if turn["terminal_seq"] is not None and seq >= turn["terminal_seq"]]
if retired:
self.background_turns = [turn for turn in self.background_turns
if turn["terminal_seq"] not in retired]
self.task_seeds = {
key: seed for key, seed in self.task_seeds.items()
if seed["data"].get("subtype") != "task_notification"
or seed["seq"] > max(retired)
}
self._retire_backgrounds(seq)
boundary = min(self.ack, self.turn["start_seq"]) if self.turn else self.ack
if self.background_start is not None:
boundary = min(boundary, self.background_start)
Expand All @@ -437,6 +450,18 @@ async def stream():
return None
raise ValueError("unknown Claude service operation")

def _retire_backgrounds(self, seq: int) -> None:
retired = [turn["terminal_seq"] for turn in self.background_turns
if turn["terminal_seq"] is not None and seq >= turn["terminal_seq"]]
if retired:
self.background_turns = [turn for turn in self.background_turns
if turn["terminal_seq"] not in retired]
self.task_seeds = {
key: seed for key, seed in self.task_seeds.items()
if seed["data"].get("subtype") != "task_notification"
or seed["seq"] > max(retired)
}

def _cleanup_steer_attachments(self) -> None:
for directory in self.steer_attachment_dirs:
shutil.rmtree(directory, ignore_errors=True)
Expand Down
Loading
Loading