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
54 changes: 43 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# cc-remote

**在手机和浏览器里,继续使用你机器上的 Claude Code 和 Codex。**
**在手机、浏览器和终端里,继续使用你机器上的 Claude Code 和 Codex。**

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

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

[English](README_en.md) · [功能对照](#引擎与功能) · [快速开始](#快速开始) ·
[安装与升级](#安装与升级) · [文档](#文档) · [更新记录](CHANGELOG_zh.md)
[终端工作台](#terminal-workspace) · [安装与升级](#安装与升级) · [文档](#文档) · [更新记录](CHANGELOG_zh.md)

cc-remote 把本机 agent 的会话、工具过程、文件和运行控制带到远端。你可以在电脑上
开始任务,用手机查看进度、回答询问、补充指令,再回到原来的会话继续工作。
Expand All @@ -32,6 +32,9 @@ cc-remote 把本机 agent 的会话、工具过程、文件和运行控制带到
可配置独立账号,分别使用原生登录、会话和扩展目录。
- **适合手机使用**:紧凑的引擎菜单、明暗主题、图片缩放、PWA 和可选后台通知。
通知默认只显示通用状态,显示会话名称需要主动开启。
- **也能在终端继续工作**:全屏 TUI 支持 Claude/Codex 的 Code / Work,共享网页
会话、运行状态和队列;提供 Vim 风格操作、会话树、标签与工具详情。
[安装与启动](#terminal-workspace)。

### Code 与 Work

Expand All @@ -51,6 +54,7 @@ cc-remote 把本机 agent 的会话、工具过程、文件和运行控制带到
|---|---|---|
| 接入方式 | 日常 Claude CLI + Agent SDK | 官方 app-server,共享 daemon |
| Code / Work | 两者支持 | 两者支持 |
| 全屏终端 TUI | 支持 | 支持 |
| 模型与思考 | 原生模型与支持的档位 | 原生模型、思考强度、服务档位 |
| Plan | 原生 Plan 模式 | 原生 Plan 协作模式 |
| Goal | 完成条件、检查轮次、最近检查结果、Token 用量和耗时 | 目标、可选 Token 预算、暂停/继续、完成和清除 |
Expand Down Expand Up @@ -127,6 +131,7 @@ Relay 不落盘原文件或预览结果。Viewer 默认复用主站地址,不
```mermaid
flowchart LR
browser["手机 / 浏览器"] <-->|HTTPS / WSS| relay["Relay + Web 静态文件"]
terminal["终端 TUI"] <-->|WSS| relay
subgraph device["你的机器"]
wrapper["Wrapper"] <--> engine["Claude SDK / Codex app-server"]
end
Expand Down Expand Up @@ -266,18 +271,45 @@ Code 默认权限较宽;Work 的私有目录策略不能替代独立系统用
| Codex App 接入:[macOS](docs/codex-desktop-launcher.md)/[Linux](docs/codex-desktop-linux.md) | 可选桌面 App、日常 CLI 与 Wrapper 共用 daemon |
| [Codex App 工具](docs/codex-app-tools.md) | 可选 App-control MCP |
| [定时消息 UI](docs/timed-messages.md) | 定时发送入口、消息标签、光圈与下次时间 |
| [终端工作台](docs/tui_zh.md) | TUI 安装、连接、Vim 操作、会话树、文件预览与终端限制 |
| [更新记录](CHANGELOG_zh.md) | 版本变化与迁移记录 |

<a id="terminal-workspace"></a>

## 终端工作台(预览)

不习惯网页时,可以使用内置的 Python/Textual 全屏 TUI。它与网页共享
relay/wrapper 会话,提供会话标签、Space e 目录树及搜索、Vim 风格阅读与输入
模式、消息跳转,以及不改变阅读位置的复制和引用。
支持思考/工具/进程详情、运行计时、Goal/Plan、
用量、队列编辑及共享会话控制;支持 Markdown 预览和兼容终端的图片显示,
其他图形内容保留明确的网页入口。
安装、快捷键及终端边界见
[终端工作台中文指南](docs/tui_zh.md)。原逐行客户端可用 `--line-mode` 启动。
内置的 Python/Textual 全屏 TUI 支持 **Claude/Codex 的 Code / Work**,连接与网页
相同的 Relay 和 Wrapper。会话、运行状态和队列来自同一控制链路;关闭 TUI
不会停止已运行的任务或已接收的排队消息。原生客户端的会话写入权限仍然生效。

完成上面的源码依赖安装后,再安装可选终端依赖:

```bash
.venv/bin/python -m pip install -r requirements-tui.txt
# 离线体验,不连接服务器,也不调用模型。
./scripts/cc-remote-tui --demo
# 连接自己的 Relay;按提示输入登录口令,替换示例域名。
./scripts/cc-remote-tui --engine codex --url wss://cc.example.com/ws
```

`--engine claude` 切换引擎,`--space work` 进入 Work;可附加会话 ID,
或用 `--machine <id>` 选择已授权设备。TUI 与 Relay、Wrapper 必须使用相同的
源码/协议版本。登录配置、本机自动认证及固定启动命令见
[终端工作台中文指南](docs/tui_zh.md)。

| 操作 | 默认按键 |
|---|---|
| 会话目录树与搜索 | Normal 模式下 `Space e`,树内 `/` 搜索 |
| 切换已打开会话 | Normal 模式下 `H` / `L`;`Space ,` 搜索标签 |
| 切换阅读区/输入框 | `Ctrl+k` / `Ctrl+j` |
| 输入与发送 | `i` 进入 Insert,`Esc` 回 Normal,Normal 下 `Enter` 发送 |
| 排队/停止当前轮 | `Ctrl+e` / `Ctrl+x` |
| 查看当前快捷键 | `Space h` |

支持 Vim 选择、复制和引用,按需展开思考/工具/进程详情,以及运行计时、
Goal / Plan、用量与队列编辑。运行状态文字从左向右扫光,完成或中断后停止;
`TEXTUAL_ANIMATIONS=none` 可关闭动画。Markdown 可在终端预览,图片取决于终端
图形协议;其他内容提供网页入口。原逐行客户端仍可通过 `--line-mode` 启动。

## 开发

Expand Down
59 changes: 47 additions & 12 deletions README_en.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# cc-remote

**Use Claude Code and Codex on your machine from your phone or browser.**
**Use Claude Code and Codex on your machine from your phone, browser or terminal.**

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

**Product version: v3.0.0** · Wire protocol v71

[中文](README.md) · [Engine comparison](#engines-and-features) · [Quick start](#quick-start) ·
[Install and upgrade](#install-and-upgrade) · [Documentation](#documentation) · [Changelog](CHANGELOG.md)
[Terminal workspace](#terminal-workspace) · [Install and upgrade](#install-and-upgrade) · [Documentation](#documentation) · [Changelog](CHANGELOG.md)

cc-remote brings your local agent's sessions, tool activity, files and controls to
remote clients. Start a task on your computer, check progress from your phone,
Expand Down Expand Up @@ -41,6 +41,9 @@ features or wire protocol across different commits.
- **Work from a phone.** Compact engine menus, light/dark themes, image zoom, PWA
installation and optional background notifications. Notifications default to
generic status; showing a session name requires an explicit opt-in.
- **Continue in a terminal.** The full-screen TUI supports Claude/Codex Code and
Work, sharing Web sessions, running state and queues. Use Vim-style controls,
a searchable session tree, tabs and tool details. [Install and run](#terminal-workspace).

### Code and Work

Expand All @@ -62,6 +65,7 @@ and extensions still depend on the native installation on the selected device.
|---|---|---|
| Connection | Daily Claude CLI + Agent SDK | Official app-server, shared daemon |
| Code / Work | Both | Both |
| Full-screen terminal TUI | Supported | Supported |
| Models and reasoning | Native models and supported levels | Native models, reasoning effort, service tier |
| Plan | Native Plan mode | Native Plan collaboration mode |
| Goal | Completion condition, check count, latest feedback, token usage and elapsed time | Objective, optional token budget, pause/resume, complete and clear |
Expand Down Expand Up @@ -147,6 +151,7 @@ preview domain; it does not proxy arbitrary private-network services.
```mermaid
flowchart LR
browser["Phone / browser"] <-->|HTTPS / WSS| relay["Relay + static Web client"]
terminal["Terminal TUI"] <-->|WSS| relay
subgraph device["Your machine"]
wrapper["Wrapper"] <--> engine["Claude SDK / Codex app-server"]
end
Expand Down Expand Up @@ -305,19 +310,49 @@ policy is not a replacement for separate OS users, containers or virtual machine
| Codex App: [macOS](docs/codex-desktop-launcher.md) / [Linux](docs/codex-desktop-linux.md) | Optional App, daily CLI and Wrapper on one daemon |
| [Codex App tools](docs/codex-app-tools.md) | Optional App-control MCP |
| [Timed messages](docs/timed-messages.md) | Scheduled queue receipts, message tags and countdown UI |
| [Terminal workspace](docs/tui.md) | TUI setup, connections, Vim controls, session tree, previews and terminal limits |
| [Changelog](CHANGELOG.md) | Version changes and migrations |

<a id="terminal-workspace"></a>

## Terminal workspace (preview)

The built-in Python/Textual TUI shares relay/wrapper sessions with Web. It
provides session tabs, a Space e directory tree with search, Vim-style reading
and input modes, message jumps, and copy/quote actions that retain the reading
position. Thinking/tool/process
details, elapsed time, Goal/Plan, usage, queue editing and shared session
controls are available. Markdown and compatible-terminal image previews are
built in; other graphical content has an explicit Web handoff.
See the [terminal workspace guide](docs/tui.md) for installation, keys and
terminal boundaries. Use `--line-mode` for the original line-oriented client.
The built-in Python/Textual full-screen TUI supports **Claude/Codex Code and
Work** through the same Relay and Wrapper as Web. Sessions, running state and
queues share the control link. Closing the TUI leaves running tasks and accepted
queued messages intact; native clients' session ownership rules still apply.

After installing the source dependencies above, add the optional terminal packages:

```bash
.venv/bin/python -m pip install -r requirements-tui.txt
# Offline preview; no server connection or model calls.
./scripts/cc-remote-tui --demo
# Connect to your Relay; replace the example domain and enter the password when prompted.
./scripts/cc-remote-tui --engine codex --url wss://cc.example.com/ws
```

Use `--engine claude` for Claude or `--space work` for Work. Append a session ID
to open it directly, or use `--machine <id>` to select an authorized device.
The TUI, Relay and Wrapper must use the same source/protocol version. See the
[terminal workspace guide](docs/tui.md) for authentication, local automatic
login and a launcher that works from any directory.

| Action | Default keys |
|---|---|
| Session directory tree and search | `Space e` in Normal mode, then `/` to search |
| Switch open sessions | `H` / `L` in Normal mode; `Space ,` searches tabs |
| Focus transcript / draft | `Ctrl+k` / `Ctrl+j` |
| Type and send | `i` enters Insert; `Esc` returns to Normal; Normal `Enter` sends |
| Queue / stop the current turn | `Ctrl+e` / `Ctrl+x` |
| Current shortcut help | `Space h` |

Vim selection, copy and quote, on-demand thinking/tool/process details, elapsed
time, Goal / Plan, usage and queue editing are available. Active status text
sweeps from left to right and stops on completion or interruption; set
`TEXTUAL_ANIMATIONS=none` to disable animation. Markdown previews work in the
terminal; images depend on its graphics protocol, and other content offers a
Web handoff. Use `--line-mode` for the original line-oriented client.

## Development

Expand Down
5 changes: 4 additions & 1 deletion cc_remote/claude_service/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,15 @@ async def receive_messages(self):
if result["failure"] and not result["events"]:
raise RuntimeError("Claude SDK stream ended: " + result["failure"])

async def steer(self, prompt, *, native_id, metadata, turn_id):
async def steer(self, prompt, *, native_id, metadata, turn_id, background_id=None):
if not self.description.get("native_steering"):
raise ClaudeSteerRejected("Claude service requires a steering upgrade")
if background_id is not None and not self.description.get("background_steering"):
raise ClaudeSteerRejected("Claude service requires a background steering upgrade")
accepted = await self.call("steer", {
"prompt": prompt, "native_id": native_id,
"metadata": metadata, "turn_id": turn_id,
**({"background_id": background_id} if background_id is not None else {}),
}, request_id="steer-" + native_id)
if not accepted:
raise ClaudeSteerRejected("Claude response has already ended")
Expand Down
43 changes: 37 additions & 6 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
from cc_remote.claude_steering import PendingSteers, steer_message, _origin_key

from .wire import (
ControllerLeaseConflict, decode_sdk, encode_sdk, private_directory,
Expand Down Expand Up @@ -201,17 +201,33 @@ async def read_messages(self) -> None:
value = self.steers.annotate(value)
if "__cc_steer" in value:
self.origin_id = value["__cc_steer"]["id"]
if self.turn is not None:
if self.turn.get("awaiting_steer"):
identity = self.turn.get("background_id")
self.steers.handoff_background(identity)
# The exact human echo transfers this continuation
# to the managed turn. Some native versions emit no
# separate autonomous Result at this safe boundary.
self.background_turns = [t for t in self.background_turns
if t.get("identity") != identity]
self.turn.pop("awaiting_steer", None)
if (value.get("__cc_steer_cancelled") and not self.steers.pending
and self.turn and self.turn.get("awaiting_steer")):
self.turn = None
self._cleanup_steer_attachments()
# The journal is on disk, like Claude's own transcript. Do not
# 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"):
self.background_turns.append({"start_seq": seq - 1, "terminal_seq": None})
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:
if turn["terminal_seq"] is None and turn.get("origin") == _origin_key(origin):
turn["terminal_seq"] = seq
break
if value.get("type") == "system":
Expand All @@ -225,6 +241,7 @@ async def read_messages(self) -> None:
if isinstance(sid, str) and sid:
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")):
self.terminal_seq = seq
await self.notify()
Expand All @@ -248,6 +265,8 @@ def description(self) -> dict:
"head": self.journal.seq, "controls": self.controls, "pid": os.getpid(),
"task_seeds": list(self.task_seeds.values()),
"native_steering": True,
"background_steering": True,
"pending_steers": {uid: {"id": data["id"]} for uid, data in self.steers.pending.items()},
}

async def events(self, after: int) -> dict:
Expand Down Expand Up @@ -278,7 +297,9 @@ async def mutate(self, request_id: str, method: str, params: dict):
# A controller replacement may stage identical attachment bytes at
# another private path. Compare the original browser payload digest,
# not those incidental paths; the first mutation keeps its payload.
identity = [method, params["turn_id"], params["native_id"],
# The first accepted operation owns its target. Recovery can see
# that background continuation already adopted as a human turn.
identity = [method, params["native_id"],
params["metadata"]["fingerprint"]]
fingerprint = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
previous = self.mutation_fingerprints.get(request_id)
Expand Down Expand Up @@ -327,10 +348,20 @@ async def _mutate(self, method: str, params: dict):
return result
async with self.lock:
if method == "steer":
if (self.turn is None or self.terminal_seq is not None
or self.failure or params["turn_id"] != self.turn["id"]):
background = self.turn is None and params.get("background_id") is not None
if self.failure or (background and params["background_id"] != self.steers.background_id):
return False
if not background and (self.turn is None or self.terminal_seq is not None
or params["turn_id"] != self.turn["id"]):
return False
self.steers.add(params["native_id"], params["metadata"])
if background:
self.turn = {**params["metadata"], "background_steer": True, "awaiting_steer": True,
"background_id": params["background_id"],
"background_origin": self.steers.background_origin_data,
"previous_origin_id": self.origin_id,
"start_seq": self.journal.seq, "started_at": time.time()}
self.terminal_seq = None
directory = params["metadata"].get("attachment_dir")
if directory:
self.steer_attachment_dirs.add(directory)
Expand Down
Loading
Loading