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
21 changes: 21 additions & 0 deletions .agents/skills/cc-remote-deploy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@ migration, remaining in-process `/btw` turns, and deferred queues separately;
wait for them to drain instead of interrupting work. Do not restart or replace
an active SDK service to satisfy a version/readiness check.

## Retain rollback and all live dependencies

Apply [deployment backup retention](../../../deploy/README.md#deployment-backup-retention)
on every in-scope host/install. Use the repository's `deploy/cleanup.py` with a
private, reviewed inventory; do not write an ad hoc `rm`/`rmtree` retention script.
Keep the active installation, one complete previous rollback generation, and
every live dependency. Dependency protection overrides the generation count.
Include all unresolved transactions and service dependencies in the inventory;
unknown provenance or incomplete process visibility means deferred cleanup.

Follow the documented preview, quarantine, fresh acceptance and final removal
steps. Process argv alone is not evidence that a release is unused: cwd,
executables, open files and retained symlink targets count too. A cleanup that
fails or loses control remains an unresolved journal, not a reason to retry.
After cleanup, repeat live config checks as each Codex service user and verify
the Wrapper session route and public health. A pre-cleanup readiness receipt is
not final acceptance. Report retained/deferred paths and removed allocated bytes;
do not equate those bytes with actual free-space gain. No daemon or active task
may be stopped solely to make an artifact deletable. Installers do not prune
automatically.

## Codex CLI sharing is an acceptance check

For every enabled Codex **Code** account, follow
Expand Down
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ transactions. A dropped SSH/control connection is an unknown result: inspect
the original transaction and live state before deciding whether a retry is
safe. Deployment is complete only after protocol/build identity, service
stability, public health, and expected Wrapper connectivity are verified.
Use `deploy/cleanup.py` for reviewed retention inventories, never an ad hoc
deletion script. Preserve process cwd/open-file and retained runtime dependencies
even beyond the rollback generation limit. Repeat acceptance after cleanup,
including live Codex config reads; incomplete visibility means retain the files.
For Codex Code, also follow `deploy/README.md`'s shared-control-plane acceptance:
verify each account's daily CLI and Wrapper connect to the same official
app-server, not a private stdio fallback. Do not force takeover or kill a live
Expand Down Expand Up @@ -106,7 +110,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 v73 is declared by
- **Protocol version gate**: current wire protocol v74 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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

[中文](CHANGELOG_zh.md)

## Unreleased

- Display native Codex cross-session messages and open their source conversations
in a scoped read-only history view. Wire protocol v74 requires a coordinated
Relay, Web, and Wrapper upgrade.
- Keep at most one complete previous deployment rollback generation, preserving
active dependencies and unresolved transaction recovery files.

## v4.0.7

Add model-specific Codex speed selection and repair Claude recovery and private
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

[English](CHANGELOG.md)

## 未发布

- 展示 Codex 原生跨会话消息,可在只读历史面板中查看对应来源会话,并保留设备与账号边界。
通信协议升级到 v74,需协调更新 Relay、Web 和 Wrapper。
- 每次部署至多保留一套完整的上一版回滚备份;仍被运行服务引用的文件和未完成事务的恢复文件除外。

## v4.0.7

新增按模型选择 Codex 速度,修复 Claude 会话恢复和临时侧聊清理。
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ For deployment, upgrade, verification or recovery, read the repository skill at
even if this client does not discover `.agents/skills` automatically. It routes
to the maintained [`deploy/README.md`](deploy/README.md) automation contract,
installation paths and shared-control acceptance; do not invent another flow.
Use `deploy/cleanup.py` for retention; preserve live cwd/open-file and runtime
dependencies beyond the rollback count. Incomplete visibility means retain the
files. Final acceptance, including live Codex config reads, runs after cleanup.

Codex Code acceptance requires the daily CLI and Wrapper to use the same
official daemon for each account. An online Web UI alone is insufficient. Never
Expand Down Expand Up @@ -97,7 +100,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 v73 is declared by
- **Protocol version gate**: current wire protocol v74 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

**产品版本:v4.0.7** · Wire protocol v73
**产品版本:v4.0.7** · 当前开发版 Wire protocol v74

[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: v4.0.7** · Wire protocol v73
**Product version: v4.0.7** · Current development wire protocol v74

[中文](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
13 changes: 12 additions & 1 deletion cc_remote/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
MAX_SINGLE_ATTACHMENT_BYTES,
)

PROTOCOL_VERSION = 73
PROTOCOL_VERSION = 74

# Codex Desktop renders a 53-week daily token-activity calendar. Keep the wire
# payload to that same bounded window so an account response can never turn a
Expand Down Expand Up @@ -889,6 +889,7 @@ class UserMsg(_Base):
# the later live echo.
client_msg_id: Optional[WireId] = None
timed_task: Optional[TimedMessage] = None
source_thread_id: Optional[WireId] = None
prompt: str
images: Optional[list[QueryImage]] = Field(default=None, max_length=MAX_ATTACHMENT_COUNT)
# Metadata only: file bodies stay out of replay/cache, while names remain
Expand All @@ -901,6 +902,7 @@ class TurnSteered(_Base):
type: Literal["turn_steered"] = "turn_steered"
msg_id: WireId
turn_id: WireId
source_thread_id: Optional[WireId] = None
prompt: str
images: Optional[list[QueryImage]] = Field(
default=None, max_length=MAX_ATTACHMENT_COUNT)
Expand Down Expand Up @@ -2474,12 +2476,21 @@ class GetHistory(_Command):
detail: Literal["summary", "full"] = "full"


class SessionMessageReceipt(BaseModel):
model_config = ConfigDict(extra="forbid")
itemId: WireId
threadId: WireId
status: Literal["sending", "sent", "failed"]


class ConversationTurn(BaseModel):
"""Canonical lightweight turn rendered without replaying raw events."""
model_config = ConfigDict(extra="forbid")
id: WireId
clientMsgId: Optional[WireId] = None
timedTask: Optional[TimedMessage] = None
sourceThreadId: Optional[WireId] = None
sessionMessages: Optional[list[SessionMessageReceipt]] = Field(default=None, max_length=16)
prompt: str = Field(default="", max_length=128 * 1024)
blocks: list[dict[str, Any]] = Field(default_factory=list, max_length=32)
done: bool = False
Expand Down
9 changes: 8 additions & 1 deletion cc_remote/wrapper/codex_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ class _CommandResult:
def _run_command(
argv: tuple[str, ...], env: Mapping[str, str], timeout: float,
) -> _CommandResult:
"""Blocking subprocess boundary, kept separate for deterministic tests."""
"""Run native lifecycle commands outside the disposable Wrapper release."""
# Native start/restart inherits this cwd into the durable app-server. A
# release can be retired while that server survives several Wrapper upgrades.
# Never fall back to the caller's cwd, including for read-only lifecycle probes.
home = env.get("HOME") or str(Path.home())
if not os.path.isabs(home) or not os.path.isdir(home):
return _CommandResult(127, b"", b"InvalidDaemonWorkingDirectory")
command_argv = argv
if os.name == "posix" and any(
argv[index:index + 2] == ("app-server", "daemon")
Expand All @@ -126,6 +132,7 @@ def _run_command(
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=dict(env),
cwd=home,
timeout=timeout,
check=False,
)
Expand Down
68 changes: 68 additions & 0 deletions cc_remote/wrapper/codex_delegation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Codex App cross-thread input envelopes (not sub-agent collaboration).

Only decode the native envelope. A source id is display/navigation metadata,
never authority to change accounts, machines or execute an action.
"""
from __future__ import annotations

import re
from dataclasses import dataclass

_NATIVE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
_ENVELOPE = re.compile(
r"<codex_delegation>\s*<source_thread_id>([^<>]+)</source_thread_id>"
r"\s*<input>([^<>]*)</input>\s*</codex_delegation>",
re.DOTALL,
)
_TOOLS = frozenset({"create_thread", "send_message_to_thread", "handoff_thread"})


@dataclass(frozen=True)
class CodexDelegation:
source_thread_id: str
prompt: str


def parse_codex_delegation(text: object) -> CodexDelegation | None:
if not isinstance(text, str) or len(text) > 1024 * 1024:
return None
match = _ENVELOPE.fullmatch(text.strip())
if match is None or not _NATIVE_ID.fullmatch(match[1].strip()):
return None
# Match the official App's escaping exactly; do not parse XML/entities.
prompt = match[2].strip().replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
if not prompt:
return None
return CodexDelegation(match[1].strip(), prompt)


def is_codex_delegation_output(item: object) -> bool:
return (isinstance(item, dict)
and str(item.get("type") or "").lower() == "functioncalloutput"
and item.get("namespace") == "codex_app"
and isinstance(item.get("name"), str)
and item.get("name") in _TOOLS)


def normalize_codex_delegation_item(item: dict) -> dict:
"""Project native turnToolOutput as the same user item used by the App.

Other function outputs must stay tools. Keep the exact item id for live /
persisted-history reconciliation; never infer identity from equal text.
"""
if (not is_codex_delegation_output(item)
or parse_codex_delegation(item.get("output")) is None):
return item
return {"type": "userMessage", "id": item.get("id"), "clientId": None,
"content": [{"type": "text", "text": item["output"]}]}


def codex_message_target(tool: object, arguments: object, server: object = None) -> str | None:
if not isinstance(tool, str) or not isinstance(arguments, dict):
return None
native_tool = tool in {"send_message_to_thread", "codex_app.send_message_to_thread"}
native_server = server == "codex_app" or arguments.get("namespace") == "codex_app"
if not ((native_tool and native_server) or tool == "mcp__codex_app__send_message_to_thread"):
return None
target = arguments.get("threadId")
return target if isinstance(target, str) and _NATIVE_ID.fullmatch(target) else None
12 changes: 12 additions & 0 deletions cc_remote/wrapper/codex_external.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
from pathlib import Path
from typing import Callable, Iterable, Mapping

from cc_remote.wrapper.codex_delegation import (
normalize_codex_delegation_item, parse_codex_delegation,
)

from cc_remote.protocol import (
MAX_SAFE_WIRE_INTEGER,
MAX_SAFE_WIRE_TIMESTAMP_SECONDS,
Expand Down Expand Up @@ -98,6 +102,7 @@ class CodexRolloutUserMessage:

raw_text: str
prompt: str | None
source_thread_id: str | None = None
message_id: str | None = None
client_id: str | None = None
turn_id: str | None = None
Expand Down Expand Up @@ -1043,6 +1048,9 @@ def visible_codex_user_message(message: object) -> str | None:
text = message.strip()
if not text:
return None
delegation = parse_codex_delegation(text)
if delegation is not None:
return delegation.prompt
marker = text.rfind(_CODEX_REQUEST_MARKER)
if marker >= 0:
request = text[marker + len(_CODEX_REQUEST_MARKER):].strip()
Expand Down Expand Up @@ -1089,6 +1097,8 @@ def codex_rollout_user_message(
raw_text = payload.get("message")
elif payload_type == "item_completed":
item = payload.get("item")
if isinstance(item, dict):
item = normalize_codex_delegation_item(item)
if (
not isinstance(item, dict)
or str(item.get("type") or "").lower() != "usermessage"
Expand All @@ -1101,7 +1111,9 @@ def codex_rollout_user_message(
return None
if not isinstance(raw_text, str):
return None
delegation = parse_codex_delegation(raw_text)
return CodexRolloutUserMessage(
source_thread_id=delegation.source_thread_id if delegation else None,
raw_text=raw_text,
prompt=visible_codex_user_message(raw_text),
message_id=message_id if isinstance(message_id, str) else None,
Expand Down
11 changes: 10 additions & 1 deletion cc_remote/wrapper/codex_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
from dataclasses import dataclass, field, replace
from typing import Any, Awaitable, Callable

from cc_remote.wrapper.codex_delegation import (
normalize_codex_delegation_item, parse_codex_delegation,
)

from cc_remote.protocol import TurnEnd, TurnResult, UserMsg
from cc_remote.wrapper.claude_compaction import compact_completion_events
from cc_remote.wrapper.codex_history_prefetch import CodexHistoryPrefetch
Expand Down Expand Up @@ -224,6 +228,7 @@ def _validated_turn(
"invalid Codex user content")

normalized = dict(value)
normalized["items"] = [normalize_codex_delegation_item(item) for item in items]
normalized["startedAt"] = _optional_nonnegative_int(
value.get("startedAt"), "startedAt")
normalized["completedAt"] = _optional_nonnegative_int(
Expand Down Expand Up @@ -265,11 +270,14 @@ def _user_message(item: dict[str, Any], *, ts: float | None) -> UserMsg:
"invalid Codex image data")
images.append({"media_type": media_type, "data": data})

prompt = "".join(prompt_parts)
delegation = parse_codex_delegation(prompt)
kwargs: dict[str, Any] = {
"source_thread_id": delegation.source_thread_id if delegation else item.get("_ccRemoteSourceThreadId"),
"msg_id": _wire_id(item.get("id"), "user"),
"client_msg_id": _optional_wire_id(
item.get("clientId"), "client-message"),
"prompt": "".join(prompt_parts),
"prompt": delegation.prompt if delegation else prompt,
"images": images or None,
}
if ts is not None:
Expand Down Expand Up @@ -1017,6 +1025,7 @@ async def summary_page(
"type": "text",
"text": recovered.prompt,
}],
"_ccRemoteSourceThreadId": recovered.source_thread_id,
"_ccRemoteImages": [
dict(image) for image in recovered.images or []
],
Expand Down
38 changes: 38 additions & 0 deletions cc_remote/wrapper/codex_readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from typing import Any

from websockets.client import ClientProtocol
from websockets.asyncio.client import unix_connect
from websockets.frames import Frame, Opcode
from websockets.http11 import Response
from websockets.uri import parse_uri
Expand All @@ -31,6 +32,43 @@
_TIMEOUT = 8.0


async def probe_config(socket_path: str) -> None:
"""Read live config without starting/resuming a thread or an account CLI.

A listening daemon can still have a deleted working directory. Initialization
alone doesn't exercise the config loader used to start the next turn. Never
expose the config or raw native errors in this acceptance check.
"""
before = socket_identity(socket_path, owner_uid=os.geteuid())
async with asyncio.timeout(_TIMEOUT):
async with unix_connect(
socket_path, uri="ws://localhost/", proxy=None,
open_timeout=_TIMEOUT, close_timeout=1, max_size=2 * 1024 * 1024,
) as connection:
async def request(request_id: int, method: str, params: dict) -> dict:
await connection.send(json.dumps({
"id": request_id, "method": method, "params": params,
}))
while True:
response = json.loads(await connection.recv())
if not isinstance(response, dict) or response.get("id") != request_id:
continue
if "error" in response or not isinstance(response.get("result"), dict):
raise RuntimeError("Codex live configuration check failed")
return response["result"]

await request(1, "initialize", {
"clientInfo": {"name": "cc-remote-readiness", "version": __version__},
})
await connection.send('{"method":"initialized"}')
# No cwd override: validate the daemon's own configuration base.
result = await request(2, "config/read", {"includeLayers": False})
if not isinstance(result.get("config"), dict):
raise RuntimeError("Codex live configuration response is invalid")
if socket_identity(socket_path, owner_uid=os.geteuid()) != before:
raise RuntimeError("Codex daemon changed during configuration check")


async def probe_proxy(binary: str, env: dict[str, str], socket_path: str) -> None:
"""Initialize through the official raw WebSocket proxy, without a thread."""
process = await asyncio.create_subprocess_exec(
Expand Down
Loading
Loading