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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

[中文](CHANGELOG_zh.md)

## v4.0.3

Fix duplicated and misplaced Claude messages when browsing overlapping history pages.
Wire protocol remains v72. See the bilingual [release notes](docs/releases/v4.0.3.md).

- Reconcile optimistic browser message IDs with their unique native user-message
aliases across loaded and cached history pages.
- Keep established row order during partial cache updates, including when
timestamps are missing or equal.
- Preserve expanded process details and native detail lookup IDs without changing
live completion state. Keep distinct native messages even when their text matches.
- Repair duplicate copies when reading cached pages. Cover pagination and reloads
with reliability tests and Chromium/iPhone WebKit regressions.

## v4.0.2

Fix stale Claude activity and coordinate device upgrades with their Relay.
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

[English](CHANGELOG.md)

## v4.0.3

修复 Claude 历史翻页时对话重复、后续问题被放到开头的问题。通信协议保持 v72。
详见[双语发布说明](docs/releases/v4.0.3.md)。

- 根据唯一的原生用户消息别名合并浏览器实时记录与历史分页。
- 局部更新缓存时保留消息原来的顺序,即使时间戳缺失或相同。
- 保留已展开的处理过程及原生详情查询标识,不改动实时完成状态;不同原生消息
即使文字相同也不会被误合并。
- 读取缓存页面时修复重复副本,补充翻页、重新加载及 Chromium/iPhone WebKit 回归覆盖。

## v4.0.2

修复 Claude 假运行状态,并让设备更新与 VPS 协调执行。通信协议保持 v72。
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.2** · Wire protocol v72
**产品版本:v4.0.3** · 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: v4.0.2** · Wire protocol v72
**Product version: v4.0.3** · 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__ = "4.0.2"
__version__ = "4.0.3"
2 changes: 1 addition & 1 deletion deploy/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Download, verify, and run a role-specific cc-remote release installer.
set -euo pipefail

VERSION="${CC_REMOTE_VERSION:-4.0.2}"
VERSION="${CC_REMOTE_VERSION:-4.0.3}"
REPOSITORY="${CC_REMOTE_GITHUB_REPOSITORY:-muggle-stack/cc-remote}"
BASE_URL="${CC_REMOTE_RELEASE_BASE_URL:-https://github.com/$REPOSITORY/releases/download/v$VERSION}"

Expand Down
4 changes: 2 additions & 2 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ Web,Wrapper 包只含本机控制端;两者都自带 `uv`,安装时创建
### 1)下载并校验引导脚本

在 GitHub Release 页面确认版本与 release attestation,再在待安装机器下载同一版本的
`install.sh` 和 `SHA256SUMS`。下例使用 `4.0.2`;请先确认对应版本已发布,或替换为已选定的已发布 tag
`install.sh` 和 `SHA256SUMS`。下例使用 `4.0.3`;请先确认对应版本已发布,或替换为已选定的已发布 tag
(变量中不带开头的 `v`)。该路径不会自动安装尚未发布的开发分支:

```bash
export CC_REMOTE_VERSION=4.0.2
export CC_REMOTE_VERSION=4.0.3
release_base="https://github.com/muggle-stack/cc-remote/releases/download/v${CC_REMOTE_VERSION}"
curl -fLO "$release_base/install.sh"
curl -fLO "$release_base/SHA256SUMS"
Expand Down
4 changes: 2 additions & 2 deletions docs/installation_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ repository, install Node, or paste tokens into service definitions.
### 1) Download and verify the bootstrap

Confirm the version and release attestation on GitHub, then download
`install.sh` and `SHA256SUMS` from that same release. The example uses `4.0.2`;
`install.sh` and `SHA256SUMS` from that same release. The example uses `4.0.3`;
first confirm that it is published, or replace it with the published tag you
selected (without the leading `v`). This
does not select an unpublished development-branch build:

```bash
export CC_REMOTE_VERSION=4.0.2
export CC_REMOTE_VERSION=4.0.3
release_base="https://github.com/muggle-stack/cc-remote/releases/download/v${CC_REMOTE_VERSION}"
curl -fLO "$release_base/install.sh"
curl -fLO "$release_base/SHA256SUMS"
Expand Down
48 changes: 48 additions & 0 deletions docs/releases/v4.0.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# cc-remote v4.0.3

## 中文

v4.0.3 修复 Claude 历史对话重复、后续问题被放到第一条问候之前的问题。
产品版本 **4.0.3**,通信协议保持 **v72**。

### 本次修复

- 浏览历史和读取缓存时,按原生用户消息及其唯一浏览器别名合并同一条消息,
避免翻页、重连或重新加载后出现两份对话和顺序错乱。
- 局部更新缓存时保留消息原来的顺序,即使时间戳缺失或相同。
- 保留已展开的思考/工具过程和正确的详情查询标识;历史副本不会改写较新记录
的运行、完成或错误状态。
- 同文但身份不同的真实消息仍分别保留,不使用文字、时间戳或共享任务 ID 去重。

### 升级

在独立终端执行 `cc-remote update`;Linux 的系统级安装使用
`sudo cc-remote update`。更新会先检查配对的 VPS;其他设备各自更新时,
已是目标版本的 VPS 会跳过。更新后重新加载网页或 PWA,让浏览器使用新的历史合并逻辑。
本次修复不修改原生 Claude JSONL,也不需要删除会话或手工清空历史。

v4.0.0 用户按[安装与升级](https://github.com/muggle-stack/cc-remote/blob/v4.0.3/docs/installation.md)
完成一次安装器升级,再使用更新命令。旧 Release 保留不变。

## English

v4.0.3 fixes duplicated Claude conversations and later prompts appearing before
the initial greeting while browsing history. Product version **4.0.3**, wire protocol **v72**.

- Reconcile native user-message identities and their unique browser aliases across
loaded and cached history pages, preventing duplicate copies and misplaced rows.
- Preserve established row order during partial cache updates even when timestamps
are missing or equal.
- Preserve expanded process details and native detail lookup IDs. Older history
copies cannot override the newer copy's running, completed or error state.
- Keep genuinely distinct messages even when their text matches; matching prose,
timestamps and shared task IDs are not used as deduplication keys.

Run `cc-remote update` in an independent terminal (`sudo cc-remote update` for
system-wide Linux installs). The updater checks the paired Relay first and skips
it if already at the target version. Update other devices individually, then
reload Web/PWA to load the new history merge logic. Native Claude JSONL files are
unchanged; deleting sessions or manually clearing history is unnecessary.

v4.0.0 requires a one-time [installer upgrade](https://github.com/muggle-stack/cc-remote/blob/v4.0.3/docs/installation_en.md)
before the update command is available. Existing releases remain available.
2 changes: 1 addition & 1 deletion tests/test_product_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@


def test_product_version_is_consistent_across_runtime_and_web_metadata():
assert __version__ == "4.0.2"
assert __version__ == "4.0.3"
assert re.fullmatch(r"[1-9]\d*\.\d+\.\d+", __version__)

package = json.loads((ROOT / "web/package.json").read_text())
Expand Down
4 changes: 2 additions & 2 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "web",
"private": true,
"version": "4.0.2",
"version": "4.0.3",
"author": "muggle",
"license": "MIT",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion web/public/cc-remote-build.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"version": "4.0.2",
"version": "4.0.3",
"protocol": 72
}
27 changes: 9 additions & 18 deletions web/src/history-browse.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
installAuthoritativeTurnDetailPage,
mergeAuthoritativeTurnDetail,
mergeHistoryPageCopies,
reconcileProvenCompactionOrphans,
} from "./history-merge.ts";
import {
Expand Down Expand Up @@ -186,24 +187,14 @@ function segmentToPage(segment: HistoryBrowseSegment): HistoryBrowsePage {
/** Newer segments win canonical overlap so an optimistic live id stays mounted
* after its native history row materializes. */
function dedupeSegments(segments: readonly HistoryBrowseSegment[]): HistoryBrowseSegment[] {
const next = segments.map((segment) => ({
...segment,
turns: [...segment.turns],
}));
const seen = new Set<string>();
for (let segmentIndex = next.length - 1; segmentIndex >= 0; segmentIndex -= 1) {
const segment = next[segmentIndex];
const retained: Turn[] = [];
for (let turnIndex = segment.turns.length - 1; turnIndex >= 0; turnIndex -= 1) {
const turn = segment.turns[turnIndex];
const key = canonicalTurnId(turn);
if (seen.has(key)) continue;
seen.add(key);
retained.unshift(turn);
}
segment.turns = retained;
}
return next;
const copies = mergeHistoryPageCopies(flattenSegments(segments));
let offset = 0;
return segments.map((segment) => {
const turns = copies.slice(offset, offset + segment.turns.length)
.filter((turn): turn is Turn => turn != null);
offset += segment.turns.length;
return { ...segment, turns };
});
}

function flattenSegments(segments: readonly HistoryBrowseSegment[]): Turn[] {
Expand Down
63 changes: 63 additions & 0 deletions web/src/history-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,69 @@ function mergeTurn(
};
}

/** Reconcile overlapping browse/cache pages by native user identity. Keep the
* last copy's display id and lifecycle; older copies can supply the native
* lookup id and already-expanded detail. Browse keeps the last copy's position
* and page ownership; partial cache writes keep the established first slot.
* Null slots preserve page boundaries without changing live runtime state.
*
* A client alias is usable only when it names one native row across the whole
* window. Task/fork ids, matching prose and timestamps are never row identity:
* multiple steered prompts may legitimately share all three. */
export function mergeHistoryPageCopies(
turns: readonly Turn[],
position: "first" | "last" = "last",
): (Turn | null)[] {
const canonicalId = (turn: Turn) => turn.historyTurnId || turn.id;
const aliases = new Map<string, Set<string>>();
const nativeIds = new Set<string>();
for (const turn of turns) {
const nativeId = canonicalId(turn);
if (turn.historyTurnId || (turn.clientMsgId && turn.clientMsgId !== turn.id)) {
nativeIds.add(nativeId);
}
for (const alias of [turn.clientMsgId, turn.historyTurnId ? turn.id : undefined]) {
if (!alias || alias === nativeId) continue;
const targets = aliases.get(alias) ?? new Set<string>();
targets.add(nativeId);
aliases.set(alias, targets);
}
}

const result: (Turn | null)[] = [...turns];
const indexes = new Map<string, number>();
turns.forEach((turn, index) => {
let key = canonicalId(turn);
if (!turn.historyTurnId && !nativeIds.has(key)) {
const targets = aliases.get(key);
if (targets?.size === 1) key = targets.values().next().value!;
}
const previousIndex = indexes.get(key);
const retainedIndex = position === "first" ? previousIndex ?? index : index;
indexes.set(key, retainedIndex);
if (previousIndex == null) return;
const previous = result[previousIndex]!;
const merged = mergeTurn(previous, turn, !turn.done, "second");
result[previousIndex] = null;
result[index] = null;
result[retainedIndex] = {
...merged,
historyTurnId: key !== turn.id ? key : turn.historyTurnId,
detailEventCount: Math.max(
previous.detailEventCount ?? 0, turn.detailEventCount ?? 0),
// These are display-only copies. An older completed page cannot settle
// a newer open row, nor reopen a newer terminal or restore its old error.
done: turn.done,
interrupted: turn.interrupted,
error: turn.error,
progress: turn.progress,
terminalSource: turn.terminalSource,
doneTs: turn.done ? merged.doneTs : turn.doneTs,
};
});
return result;
}

function restoreAuthoritativeLifecycle(merged: Turn, history: Turn, live: Turn): Turn {
const restored = {
...merged,
Expand Down
29 changes: 9 additions & 20 deletions web/src/history-page-cache.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import {
canonicalTurnId,
type HistoryBrowsePage,
} from "./history-browse.ts";
import type { HistoryBrowsePage } from "./history-browse.ts";
import type { Block, Turn } from "./domain/conversation.ts";
import { mergeHistoryPageCopies } from "./history-merge.ts";

/** Deliberately independent from cache.ts. Deep-history browsing is best-effort
* page storage and must never make an upgrade/failure of the replay/session
Expand Down Expand Up @@ -186,7 +184,8 @@ export function sanitizeHistoryPageForCache(
): HistoryBrowsePage {
return {
pageKey: page.pageKey,
turns: page.turns.map(sanitizeTurn),
turns: mergeHistoryPageCopies(page.turns.map(sanitizeTurn))
.filter((turn): turn is Turn => turn != null),
hasOlder: !!page.hasOlder,
olderCursor: page.olderCursor ?? null,
hasNewer: page.isLatest ? false : !!page.hasNewer || !!page.newerPageKey,
Expand Down Expand Up @@ -248,25 +247,15 @@ function validRecord(
&& validPage(record.page);
}

/** Merge repeated partial evictions of one logical page. New input wins a
* canonical overlap, while existing-only rows remain recoverable. */
/** Merge repeated partial evictions of one logical page. New input wins an
* overlap's content without moving its established slot; existing-only rows
* remain recoverable even when timestamps are missing or equal. */
function mergePageTurns(
existing: readonly Turn[],
incoming: readonly Turn[],
): Turn[] {
const turns = [...existing];
const indexes = new Map<string, number>();
turns.forEach((turn, index) => indexes.set(canonicalTurnId(turn), index));
for (const turn of incoming) {
const key = canonicalTurnId(turn);
const index = indexes.get(key);
if (index == null) {
indexes.set(key, turns.length);
turns.push(turn);
} else {
turns[index] = turn;
}
}
const turns = mergeHistoryPageCopies([...existing, ...incoming], "first")
.filter((turn): turn is Turn => turn != null);
turns.sort((left, right) => {
if (left.ts != null && right.ts != null && left.ts !== right.ts) {
return left.ts - right.ts;
Expand Down
Loading
Loading