diff --git a/.agents/skills/cc-remote-deploy/SKILL.md b/.agents/skills/cc-remote-deploy/SKILL.md
index 8d720a91..08d3f801 100644
--- a/.agents/skills/cc-remote-deploy/SKILL.md
+++ b/.agents/skills/cc-remote-deploy/SKILL.md
@@ -30,6 +30,13 @@ and use the repository's immutable activation transactions. Lost connectivity
means an unknown result: inspect the original operation before retrying. Do not
overwrite live directories or restart the controlling Wrapper from itself.
+For Claude deployments, also read
+[persistent session installation and acceptance](../../../docs/claude-session-service.md).
+An independent SDK service must survive ordinary Wrapper upgrades. Check first
+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.
+
## Codex CLI sharing is an acceptance check
For every enabled Codex **Code** account, follow
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 54a97012..2bbbfd7a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,7 @@
name: CI
on:
+ workflow_dispatch:
workflow_call:
pull_request:
push:
@@ -40,56 +41,25 @@ jobs:
with:
name: ci-web-dist
path: web/dist
- - name: Test and lint
- run: |
- .venv/bin/python -m pytest
- uvx --from ruff==0.15.13 ruff check cc_remote tests deploy
+ - name: Run pytest
+ run: .venv/bin/python -m pytest
web:
- name: Web
+ name: Web build
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- - uses: actions/setup-python@v6
- with:
- python-version: "3.13"
- - name: Install uv for the model-free Viewer fixture
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- with:
- version: "0.11.16"
- - name: Install Viewer fixture runtime
- run: |
- uv venv --python 3.13 .venv
- uv pip sync \
- --python .venv/bin/python \
- --require-hashes --only-binary=:all: --no-binary=http-ece \
- requirements.lock
- uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install dependencies
- run: |
- npm --prefix web ci
- npm --prefix web exec -- playwright install --with-deps chromium webkit
- - name: Build, test, and lint
- run: |
- npm --prefix web run build
- npm --prefix web run test:reliability
- npm --prefix web run test:history-browser
- npm --prefix web run test:viewer
- npm --prefix web run lint
- - name: Preserve Playwright failure evidence
- if: failure()
- uses: actions/upload-artifact@v4
- with:
- name: playwright-failure-${{ github.run_attempt }}
- path: web/test-results
- if-no-files-found: warn
- retention-days: 3
+ run: npm --prefix web ci
+ - name: Build Web client
+ run: npm --prefix web run build
- name: Preserve built Web client
uses: actions/upload-artifact@v4
with:
@@ -97,25 +67,3 @@ jobs:
path: web/dist
if-no-files-found: error
retention-days: 1
-
- deploy:
- name: Deploy scripts
- runs-on: ubuntu-24.04
- permissions:
- contents: read
- steps:
- - uses: actions/checkout@v6
- - name: Validate scripts
- run: |
- bash -n \
- deploy/install.sh \
- deploy/install-relay.sh \
- deploy/install-wrapper.sh \
- deploy/setup-vps.sh
- shellcheck -x \
- deploy/install.sh \
- deploy/install-relay.sh \
- deploy/install-wrapper.sh \
- deploy/setup-vps.sh \
- deploy/setup_transaction.sh
- git diff --check
diff --git a/AGENTS.md b/AGENTS.md
index 2c01638e..88faf8ce 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -41,6 +41,13 @@ and keep a decline or pending answer separate from deployment success. App
attachment and optional App-control MCP tools are separate user choices.
## Critical constraints / traps
+- **Claude service lifetime**: when `CC_REMOTE_CLAUDE_SERVICE_SOCKET` is set,
+ regular Claude Code/Work SDK processes belong to the separate local service.
+ Wrapper shutdown detaches; explicit stop/reconnect/eviction still has its
+ deliberate native lifecycle. Never resubmit an accepted query during recovery,
+ treat a background Result as the human terminal, or restart the service during
+ an ordinary Wrapper deploy. See `docs/claude-session-service.md` for first
+ migration, remaining in-process tasks, queue drain and version boundaries.
- **Drain footgun**: after `ClaudeSDKClient.interrupt()`, the SDK does NOT kill
the session — the current turn's stream still emits a terminal
`ResultMessage(subtype="error_during_execution")`. You MUST keep consuming
@@ -50,6 +57,12 @@ attachment and optional App-control MCP tools are separate user choices.
to the terminal ResultMessage; state only returns to `idle` (and the next
query is only accepted) after that break. Reject-while-busy prevents a second
query racing the drain.
+- **Claude steering**: send `priority="next"` through streaming input, keeping
+ the one session reader. Rebind the visible turn only on the exact native user
+ UUID echo. A Result before an accepted input is consumed is intermediate;
+ the persistent service journals this distinction and commits the original
+ root turn identity. Explicit Stop uses `interrupt(cancel_queued=true)` when
+ advertised and pending inputs exist, then drains the real Result as above.
- **cwd must match resume**: a session's jsonl lives at
`~/.claude/projects//.jsonl`. `ClaudeAgentOptions.cwd`
MUST equal the original session's cwd or `resume` can't find it.
@@ -58,7 +71,7 @@ attachment and optional App-control MCP tools are separate user choices.
interrupt+drain verification after any upgrade (`SdkHandle.preflight()` guards
the exact verified patch at startup).
- **Claude Code is the user's daily CLI, not the SDK bundle**: Claude Code
- `>=2.1.258` is required and checked before a Claude session starts. The wrapper
+ `>=2.1.263` is required and checked before a Claude session starts. The wrapper
defaults `CLAUDE_BIN` to `~/.local/bin/claude` and passes that path explicitly
to the SDK. An empty value keeps this default; only another absolute path may
override it. Keep that CLI updated and signed in before starting the wrapper.
@@ -93,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 v67 is declared by
+- **Protocol version gate**: current wire protocol v71 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
@@ -219,11 +232,15 @@ attachment and optional App-control MCP tools are separate user choices.
committing, verify the stored message and scope with
`git log -1 --format=raw --stat`, then recheck
`git status --short --branch`.
-- Before opening or updating **every** PR, run the complete local gate below;
- a docs-only or apparently narrow change does not skip it unless the user
- explicitly accepts that exception. Every command must exit zero. Expected
- platform-defined test skips are allowed, but failures or missing tools must
- be reported rather than silently bypassed.
+- Before opening or updating a maintainer-authored PR (including work prepared
+ by an agent for the maintainer), run the complete local gate below. A docs-only
+ or apparently narrow change does not skip it unless the user explicitly
+ accepts that exception. Every command must exit zero. Expected platform-defined
+ test skips are allowed; report failures or missing tools rather than bypassing
+ them. During development, use checks appropriate to the change.
+- Other contributors may open or update a PR without running the complete local
+ gate. Include the checks performed and any known validation gaps in the PR
+ description. Automatic CI still builds Web and runs pytest for every PR.
- Run the Web gate with Node 24 (see `.nvmrc`), matching CI. Newer Node
browser-like globals must not mask missing browser-environment guards.
@@ -232,8 +249,6 @@ attachment and optional App-control MCP tools are separate user choices.
uvx --from ruff==0.15.13 ruff check cc_remote tests deploy
npm --prefix web run build
npm --prefix web run test:reliability
-npm --prefix web run test:history-browser
-npm --prefix web run test:viewer
npm --prefix web run lint
bash -n \
deploy/install.sh \
@@ -249,9 +264,15 @@ shellcheck -x \
git diff --check
```
-- `.github/workflows/ci.yml` repeats this gate for pushes and PRs. A local pass
- is required before PR publication and does not replace green remote CI before
- merge. These checks are zero-token; do not substitute a live model probe.
+- `.github/workflows/ci.yml` automatically runs only the Web build and pytest
+ on PRs and pushes to `master`. Release tags reuse the same CI before packaging
+ and publishing. Pytest waits only for the Web build artifact. Lint, front-end
+ reliability tests and shell checks remain part of the local gate above.
+- Playwright is not part of CI or the required local PR gate. Existing browser
+ tests remain available for explicitly requested diagnostics. When the
+ maintainer asks for PR acceptance, check out the requested revision, run the
+ application and verify the changed behavior; report the actual checks and
+ any remaining gaps. The automated checks above do not call a live model.
## Run / test
```bash
@@ -260,7 +281,6 @@ python -m cc_remote.relay # terminal 1 (set WEB_STATIC_DIR=web/dist to se
python -m cc_remote.wrapper # terminal 2 (on each machine running Claude/Codex)
pytest # zero-token unit tests
npm --prefix web run test:reliability
-npm --prefix web run test:viewer
npm --prefix web run lint
npm --prefix web run build
```
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c87142e..5d043cd1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,41 @@
## Unreleased
+- 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.
+
+- Add an optional independent Claude session service: Wrapper reconnects recover
+ accepted turns and pending questions without resubmitting prompts. Include a
+ separate service installer and preserve account isolation.
+- Support native Claude steering during active work, retaining message, attachment
+ and token ownership. Reserve private BTW identities before launch, refresh live
+ context without overwriting newer readings, and retry busy session-list reads.
+- Replace replayed text prefixes and bound history summaries. Protocol v70
+ requires Wrapper, Relay and Web to be upgraded together.
+
+- Preview `.mmd` and `.mermaid` Work artifacts as diagrams, with a source view.
+
+- Keep Claude's internal recovery prompts hidden after compaction and rebuild
+ stale projections. Show autonomous replies after agent completion in separate
+ live process sections, with their own thinking, tools and working indicator.
+- Stream subagent text without waiting for whole messages. Recover agent-detail
+ read errors and timeouts, refresh active source-backed agents, and show each
+ agent's own running state.
+- Use a subdued text shimmer for active process and tool summaries. Present
+ readable command/search inputs and structured tool output, with raw data
+ available on demand.
+
+- Show native input/output token counts beside the working spark, with exact
+ counts and cache usage on click or tap. Use subdued counters that show
+ the latest readings directly. Keep per-turn ownership and
+ replace-only recovery snapshots across reconnects (protocol v68).
+
+
+- Request readable Claude thinking summaries in SDK sessions and show returned
+ text in the existing thinking timeline. Preserve native thinking mode,
+ token budget and effort across new sessions, resume and private forks.
+
- Accept provider-native Claude model ids (e.g. `glm-5.2` behind a custom
`ANTHROPIC_BASE_URL` gateway) as explicit model selections: they are handed
to Claude Code, persisted in the private session store, and restored across
@@ -17,6 +52,23 @@
TUI editing, cancellation and ordering controls. Reject stale queue snapshots
and changes while a message is starting. Deploy Relay, Web, Wrapper and TUI
together; protocol v66 clients cannot connect to protocol v67 services.
+- Replace Claude's persistent background-task panel with a compact composer
+ indicator. Open details above the trigger on desktop or in a centered mobile
+ dialog; native task completion removes finished items and closes the indicator
+ when no tasks remain, independently of the parent reply's completion.
+- Track Claude compaction through native status and boundary events, preserve
+ valid context readings during refresh failures, and show the applied automatic
+ compaction threshold separately from model capacity. Hide internal command
+ messages and keep completed-turn timestamps stable when rebuilding history.
+ Require Claude Code 2.1.263 or newer for the verified control APIs.
+- Keep account-specific Claude controls attached to the correct session and
+ omit transient Codex thread notifications that have no readable history from
+ the session list.
+- Show explicit timed-message tags, a rounded moving session outline and the
+ next scheduled send time. Tasks use account-scoped native queue receipts
+ independently of Goals (protocol v67). Session menus and task popovers
+ avoid the sidebar footer and the mobile visual viewport.
+
- Backport shared improvements from the DSH branch without adding a third
engine (protocol v66): rounded Claude/Codex Goal dialogs with native save
confirmation and mobile keyboard recovery; directory links open `/open`, and
diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md
index e14492c4..82f5a778 100644
--- a/CHANGELOG_zh.md
+++ b/CHANGELOG_zh.md
@@ -4,6 +4,32 @@
## 未发布
+- 终端队列控制与会话恢复合并使用协议 v71,Relay、Wrapper、Web 和 TUI 需要
+ 一起升级。TUI 恢复时替换已有文本,不再重复追加已显示的内容。
+
+- 新增可选的独立 Claude 会话服务:Wrapper 重连可恢复已接受的轮次和待回答问题,
+ 不会重发提示词;提供独立服务安装器,并保持账号隔离。
+- 支持 Claude 运行中原生引导,保留消息、附件和 token 的轮次归属;启动前预留私有
+ BTW 会话标识,运行中刷新上下文时保留较新读数,会话列表忙碌时自动重试读取。
+- 重放时替换已有文本前缀,并限制历史摘要块数。协议升至 v70,Wrapper、Relay 和
+ Web 需要同步升级。
+
+- Work Artifacts 支持 `.mmd` / `.mermaid` 图表预览,并可切换查看源码。
+
+- 修复 Claude 压缩历史中内部续写提示泄漏为用户消息的问题,并重建旧缓存。
+ 子代理返回后的主代理续写独立显示实时过程、思考、工具和运行提示。
+- 子代理流式文字及时显示,无需等待整段消息完成;详情读取支持错误恢复与超时
+ 重试,运行中的原生记录自动刷新,并显示子代理自己的运行状态和小火花。
+- 处理过程和工具摘要使用低调的文字扫光;命令、搜索条件和结构化工具结果按
+ 可读形式展示,原始数据可按需展开。
+
+- 在处理中的小火花旁以低调灰色直接显示原生输入/输出 token 计数,点击查看完整数字
+ 和缓存用量;绑定当前轮次,重连恢复不重复累计(protocol v68)。
+
+
+- Claude SDK 会话明确请求思考摘要,返回内容显示在现有“思考”区域;新建、恢复
+ 和私有分叉均保留原生思考模式、token 预算和强度。
+
- 支持把提供商原生模型 ID(例如自定义 `ANTHROPIC_BASE_URL` 网关下的
`glm-5.2`)作为显式模型选择:它会传给 Claude Code、写入私有会话记录,并在
重连和冷恢复后保留。仅在转录或 `/context` 元数据中“观测到”的模型 ID 依旧
@@ -13,6 +39,18 @@
- 新增服务端队列原子排序(protocol v67),TUI 编辑、取消及排序操作支持配置
快捷键。拒绝基于过期队列的排序和消息启动期间的调整。Relay、Web、Wrapper
与 TUI 必须一起升级;protocol v66 客户端不能连接 protocol v67 服务。
+- Claude 后台任务改为输入区工具栏中的紧凑胶囊;桌面向上展开详情,手机居中显示。
+ 按原生任务状态移除已结束的任务,全部结束时自动关闭小窗并隐藏入口;助手回复
+ 完成不会提前隐藏仍在运行的后台任务。
+- 按 Claude 原生状态与压缩边界更新压缩动画;上下文刷新失败时保留有效读数,
+ 分别显示生效的自动压缩阈值和模型容量。历史中隐藏内部命令消息,避免后续压缩
+ 改变已完成回答的时间戳。已验证的控制接口要求 Claude Code 2.1.263 或更高版本。
+- 修复多账号 Claude 会话控制的持久化归属;Codex 临时线程通知尚无可读历史时,
+ 不再生成点开后为空的会话列表项。
+- 定时消息增加来源标签;待发送任务的会话卡片显示圆角流动光圈及下次发送时间。
+ 按账号和原生队列回执关联,与 Goal 分开(protocol v67);会话菜单和任务小窗
+ 自动避让侧栏底栏及手机可视区域。
+
- 整合 DSH 分支中的通用改进,保留 Claude/Codex 双引擎(protocol v66):圆角
Goal 小窗等待原生保存确认,恢复手机键盘收起后的布局;目录链接接入 `/open`,
XLSX 可预览已保存的单元格、切换工作表并下载原文件。
diff --git a/CLAUDE.md b/CLAUDE.md
index b53034c1..5225266f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -28,6 +28,15 @@ this optional step does not block core deployment. App-control MCP tools require
a separate choice; sharing alone does not authorize them.
## Critical constraints / traps
+- **Claude service lifetime**: when `CC_REMOTE_CLAUDE_SERVICE_SOCKET` is set,
+ regular Claude Code/Work SDK processes belong to the separate local service.
+ Wrapper shutdown detaches; never resubmit an accepted query during recovery
+ or restart the service during an ordinary Wrapper deploy. See
+ `docs/claude-session-service.md` for first migration and drain boundaries.
+- **Claude steering**: send `priority="next"` through the sole streaming-input
+ reader and rebind only on the exact native user UUID echo. A Result before
+ an accepted input is consumed is intermediate. Explicit Stop cancels queued
+ inputs when supported, then drains the real terminal Result.
- **Drain footgun**: after `ClaudeSDKClient.interrupt()`, the SDK does NOT kill
the session — the current turn's stream still emits a terminal
`ResultMessage(subtype="error_during_execution")`. You MUST keep consuming
@@ -88,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 v67 is declared by
+- **Protocol version gate**: current wire protocol v71 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
diff --git a/README.md b/README.md
index d6ba458c..717363d3 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
自托管 · 多会话 · 多设备 · 实时工具过程 · Code / Work · PWA
-**产品版本:v3.0.0** · Wire protocol v67
+**产品版本:v3.0.0** · Wire protocol v71
[English](README_en.md) · [功能对照](#引擎与功能) · [快速开始](#快速开始) ·
[安装与升级](#安装与升级) · [文档](#文档) · [更新记录](CHANGELOG_zh.md)
@@ -23,7 +23,7 @@ cc-remote 把本机 agent 的会话、工具过程、文件和运行控制带到
- **同时处理多个任务**:会话按目录分组,支持搜索、置顶、重命名和后台运行。
切换页面不会停止任务;历史按需加载,工具细节展开后再读取。
- **跟进完整过程**:查看流式回复、引擎公开的思考摘要、计划、工具调用、命令输出、
- 文件改动和审批。Codex 运行中支持引导或排队;Claude 支持打断并发送或排队。
+ 文件改动和审批。Claude 与 Codex 运行中均支持引导或排队;引导会补充当前任务,不会打断正在执行的工具。
- **管理长任务**:通过 `/goal` 设置目标,查看进展并使用各引擎原生的预算与控制。
Claude、Codex 还支持 `/btw` 临时侧聊,主任务继续运行。
- **直接查看文件**:聊天里的文件和目录链接连接到 `/open` 与预览面板,支持源码、
@@ -146,7 +146,7 @@ Wrapper 主动出站连接 Relay,设备不需要开放公网入站端口。Rel
至少准备一个可用引擎:
-- **Claude**:日常 Claude Code `>= 2.1.258`,默认路径 `~/.local/bin/claude`。
+- **Claude**:日常 Claude Code `>= 2.1.263`,默认路径 `~/.local/bin/claude`。
Wrapper 使用该 CLI;Python Agent SDK 固定为 `0.2.151`。
- **Codex**:已登录的官方 CLI。共享控制需要同时支持
`codex app-server daemon --help` 和 `codex app-server proxy --help`。
@@ -265,6 +265,7 @@ Code 默认权限较宽;Work 的私有目录策略不能替代独立系统用
| [远程 Viewer](docs/remote-viewer.md) | 交互式静态页面、Bridge/Isolated 模式 |
| 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) | 定时发送入口、消息标签、光圈与下次时间 |
| [更新记录](CHANGELOG_zh.md) | 版本变化与迁移记录 |
## 终端工作台(预览)
@@ -286,13 +287,15 @@ relay/wrapper 会话,提供会话标签、Space e 目录树及搜索、Vim 风
.venv/bin/python -m pip install -r requirements-dev.txt
.venv/bin/python -m pytest
npm --prefix web run test:reliability
-npm --prefix web run test:history-browser
-npm --prefix web run test:viewer
npm --prefix web run lint
npm --prefix web run build
```
-完整提交/PR 门禁见 [AGENTS.md](AGENTS.md#commit-and-pr-gate)。
+维护者提交 PR 前执行完整本地检查,见 [AGENTS.md](AGENTS.md#commit-and-pr-gate)。
+其他贡献者可直接提交 PR,并说明已做的验证和未验证部分。PR 和推送到 `master` 会
+自动运行 **Web 编译与 pytest**,发布版本也复用这两项检查。
+Playwright 不进入 CI 或必跑的本地 PR 检查,现有用例仅保留供按需诊断。
+需要验收 PR 时,维护者拉取指定版本、实际运行应用并验证改动行为。
[真实链路脚本](scripts/live/) 单独运行;真实模型探针可能消耗额度,不属于默认单元测试。
网页开发服务器使用 `npm --prefix web run dev`,同源联调使用构建后的 Relay 网页。
diff --git a/README_en.md b/README_en.md
index 60e66b77..6be3435a 100644
--- a/README_en.md
+++ b/README_en.md
@@ -4,7 +4,7 @@
Self-hosted · Multiple sessions and devices · Live tool activity · Code / Work · PWA
-**Product version: v3.0.0** · Wire protocol v67
+**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)
@@ -28,8 +28,8 @@ features or wire protocol across different commits.
a task. History is paged, with tool details loaded when expanded.
- **Follow the work.** Read streaming replies, engine-provided reasoning
summaries, plans, tool calls, command output, file changes and approvals.
- Codex supports steering or queueing while busy; Claude supports
- interrupt-and-send or queueing.
+ Claude and Codex support steering or queueing while busy. Steering adds
+ instructions to the current task without interrupting running tools.
- **Manage longer tasks.** `/goal` exposes each engine's native progress, budget
and controls. Claude and Codex also offer temporary `/btw` side conversations
while the main task continues.
@@ -167,7 +167,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.258`, normally at `~/.local/bin/claude`.
+- **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`.
- **Codex:** an authenticated official CLI. Shared control requires both
`codex app-server daemon --help` and `codex app-server proxy --help`.
@@ -304,6 +304,7 @@ policy is not a replacement for separate OS users, containers or virtual machine
| [Remote Viewer](docs/remote-viewer.md) | Interactive static pages, Bridge/Isolated modes |
| 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 |
| [Changelog](CHANGELOG.md) | Version changes and migrations |
## Terminal workspace (preview)
@@ -327,13 +328,18 @@ Common tests that do not call a live model:
.venv/bin/python -m pip install -r requirements-dev.txt
.venv/bin/python -m pytest
npm --prefix web run test:reliability
-npm --prefix web run test:history-browser
-npm --prefix web run test:viewer
npm --prefix web run lint
npm --prefix web run build
```
-The complete commit/PR gate is in [AGENTS.md](AGENTS.md#commit-and-pr-gate).
+Maintainers run the complete local gate before submitting a PR; see
+[AGENTS.md](AGENTS.md#commit-and-pr-gate). Other contributors may submit a PR
+with a description of the checks performed and any validation gaps. PRs and
+pushes to `master` automatically run the **Web build and pytest**; releases
+reuse these checks. Playwright is not part of CI or the required local PR gate.
+Its tests remain available for diagnostics when requested. For PR acceptance,
+maintainers check out the requested revision and verify the changed behavior
+in the running application.
[Live probes](scripts/live/) run separately; live model probes may spend tokens
and are not default unit tests.
Use `npm --prefix web run dev` for the UI dev server, or the built Relay-served
diff --git a/cc_remote/claude_service/__init__.py b/cc_remote/claude_service/__init__.py
new file mode 100644
index 00000000..9000a6be
--- /dev/null
+++ b/cc_remote/claude_service/__init__.py
@@ -0,0 +1 @@
+"""Private, same-user Claude SDK service, independent of the relay Wrapper."""
diff --git a/cc_remote/claude_service/__main__.py b/cc_remote/claude_service/__main__.py
new file mode 100644
index 00000000..e8e534e2
--- /dev/null
+++ b/cc_remote/claude_service/__main__.py
@@ -0,0 +1,68 @@
+"""Run the SDK service under its own LaunchAgent or systemd user unit."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import fcntl
+import json
+import os
+import signal
+import stat
+from pathlib import Path
+
+from cc_remote.wrapper.process_scan import process_identity
+
+from .server import Service
+from .wire import private_directory
+
+
+async def serve(directory: Path) -> None:
+ private_directory(directory)
+ lock_fd = os.open(directory / "service.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
+ try:
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ path = directory / "service.sock"
+ if path.exists() or path.is_symlink():
+ info = path.lstat()
+ if info.st_uid != os.getuid() or not stat.S_ISSOCK(info.st_mode):
+ raise PermissionError("unsafe Claude service socket")
+ path.unlink()
+ service = Service(directory)
+ server = await asyncio.start_unix_server(service.connection, path)
+ os.chmod(path, 0o600)
+ identity = process_identity(os.getpid())
+ state = {
+ "pid": os.getpid(), "start_ticks": identity.start_ticks if identity else None,
+ "source": str(Path(__file__).resolve().parents[2]),
+ "socket": str(path), "protocol": 1,
+ }
+ descriptor = directory / "service.json"
+ fd = os.open(descriptor, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
+ with os.fdopen(fd, "w") as out:
+ json.dump(state, out)
+ stopped = asyncio.Event()
+ loop = asyncio.get_running_loop()
+ for signum in (signal.SIGTERM, signal.SIGINT):
+ loop.add_signal_handler(signum, stopped.set)
+ try:
+ await stopped.wait()
+ finally:
+ server.close()
+ await server.wait_closed()
+ await asyncio.gather(*(session.close() for session in service.sessions.values()))
+ path.unlink(missing_ok=True)
+ descriptor.unlink(missing_ok=True)
+ finally:
+ os.close(lock_fd)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--state-dir", type=Path, required=True)
+ arguments = parser.parse_args()
+ asyncio.run(serve(arguments.state_dir.expanduser().absolute()))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/cc_remote/claude_service/client.py b/cc_remote/claude_service/client.py
new file mode 100644
index 00000000..8ecea662
--- /dev/null
+++ b/cc_remote/claude_service/client.py
@@ -0,0 +1,324 @@
+"""SDK-shaped controller for the private persistent service."""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import contextvars
+import dataclasses
+import os
+from uuid import uuid4
+
+from cc_remote.claude_steering import ClaudeSteerRejected
+from cc_remote.log import logger
+
+from .wire import ControllerLeaseConflict, decode_sdk, encode_sdk, read_frame, write_frame
+
+log = logger("cc_remote.claude_service.client")
+
+CONTROLLER_LEASE_WAIT_SECONDS = 5.0
+CONTROLLER_LEASE_RETRY_DELAY = 0.1
+
+callback_identity: contextvars.ContextVar[str | None] = contextvars.ContextVar(
+ "claude_service_callback", default=None)
+
+
+class Connection:
+ def __init__(self, socket_path: str):
+ self.socket_path = os.path.expanduser(socket_path)
+ self.reader = None
+ self.writer = None
+ self.task = None
+ self.lock = asyncio.Lock()
+ self.pending: dict[str, asyncio.Future] = {}
+
+ async def connect(self) -> None:
+ self.reader, self.writer = await asyncio.open_unix_connection(self.socket_path)
+ self.task = asyncio.create_task(self._read())
+
+ async def _read(self) -> None:
+ try:
+ while True:
+ reply = await read_frame(self.reader)
+ future = self.pending.get(reply.get("id"))
+ if future is not None and not future.done():
+ if "error" in reply:
+ error_type = TimeoutError if reply.get("timeout") else RuntimeError
+ if reply["error"] == "ControllerLeaseConflict":
+ error_type = ControllerLeaseConflict
+ future.set_exception(error_type("Claude service: " + reply["error"]))
+ else:
+ future.set_result(reply.get("result"))
+ except (asyncio.IncompleteReadError, ConnectionError, ValueError):
+ pass
+ finally:
+ for future in self.pending.values():
+ if not future.done():
+ future.set_exception(ConnectionError("Claude service connection closed"))
+
+ async def call(self, method: str, params: dict | None = None, *, request_id=None, timeout=90):
+ if self.task is None or self.task.done():
+ raise ConnectionError("Claude service is not connected")
+ key = request_id or uuid4().hex
+ future = asyncio.get_running_loop().create_future()
+ self.pending[key] = future
+ try:
+ async with self.lock:
+ await write_frame(self.writer, {"id": key, "method": method, "params": params or {}})
+ return await asyncio.wait_for(future, timeout)
+ finally:
+ self.pending.pop(key, None)
+
+ async def disconnect(self) -> None:
+ if self.writer is not None:
+ self.writer.close()
+ with contextlib.suppress(ConnectionError):
+ await self.writer.wait_closed()
+ if self.task is not None:
+ self.task.cancel()
+ await asyncio.gather(self.task, return_exceptions=True)
+
+
+def options_payload(options) -> dict:
+ # Callables and live MCP objects stay in their owning processes. Provider
+ # environment goes over the same-user socket only, never to disk/relay/log.
+ omitted = {"can_use_tool", "stderr", "debug_stderr", "mcp_servers"}
+ if options.hooks or options.session_store:
+ raise ValueError("custom SDK hooks/session stores require a service adapter")
+ result = {
+ field.name: encode_sdk(getattr(options, field.name))
+ for field in dataclasses.fields(options) if field.name not in omitted
+ }
+ servers = options.mcp_servers
+ if isinstance(servers, dict):
+ result["mcp_servers"] = {
+ name: encode_sdk({key: value for key, value in config.items() if key != "instance"})
+ for name, config in servers.items()
+ }
+ else:
+ result["mcp_servers"] = encode_sdk(servers)
+ return result
+
+
+class RemoteClient:
+ def __init__(self, socket_path, *, options, metadata, isolated=False):
+ self.connection = Connection(socket_path)
+ self.options = options
+ self.metadata = metadata
+ self.isolated = isolated
+ self.description: dict = {}
+ self.id = None
+ self._query = self
+ self.callback_task = None
+ self.callback_tasks: dict[str, asyncio.Task] = {}
+ self.bridges = {}
+ self.bridge_ready: set[str] = set()
+ self.bridge_locks: dict[str, asyncio.Lock] = {}
+ self.next_turn: dict | None = None
+ self.recovery: dict | None = None
+ self.last_seq = 0
+ self.ready = asyncio.Event()
+ self.owner_identity = None
+
+ async def connect(self) -> None:
+ await self.connection.connect()
+ try:
+ from importlib.metadata import version
+
+ hello = await self.connection.call("hello")
+ if hello["sdk_version"] != version("claude-agent-sdk"):
+ raise RuntimeError("Claude service SDK differs from this Wrapper; drain before upgrading it")
+ from cc_remote.wrapper.child_env import sanitized_child_env, claude_sdk_process_env
+
+ payload = options_payload(self.options)
+ environment = sanitized_child_env()
+ payload["env"] = (
+ claude_sdk_process_env(self.options.env, environment)
+ if self.isolated else {**environment, **self.options.env}
+ )
+ self.description = await self._open({
+ "options": payload,
+ "metadata": self.metadata,
+ "isolated": self.isolated,
+ "fork": self.options.fork_session,
+ "session": self.metadata.get("service_id"),
+ "strict_session": bool(hello.get("strict_controller_leases")),
+ }, legacy=not hello.get("strict_controller_leases"))
+ except BaseException:
+ await self.connection.disconnect()
+ raise
+ self.id = self.description["id"]
+ from cc_remote.wrapper.process_scan import process_identity
+
+ self.owner_identity = process_identity(self.description["pid"])
+ self.recovery = self.description["turn"]
+ self.last_seq = self.description["after"]
+ self.callback_task = asyncio.create_task(self._callbacks())
+
+ async def _open(self, params, *, legacy):
+ if params["session"] is None:
+ return await self.connection.call("open", params)
+ # Only recovery of a listed worker may wait for the previous socket's
+ # finally block. Never retry an unknown open response or resubmit Query.
+ async with asyncio.timeout(None) as wait:
+ while True:
+ try:
+ return await self.connection.call("open", params)
+ except RuntimeError as exc:
+ conflict = isinstance(exc, ControllerLeaseConflict)
+ if not conflict and (not legacy or str(exc) != "Claude service: RuntimeError"):
+ raise
+ if wait.when() is None:
+ wait.reschedule(asyncio.get_running_loop().time() + CONTROLLER_LEASE_WAIT_SECONDS)
+ # Older immutable services report only exception names.
+ # Their existing-worker open has exactly one RuntimeError:
+ # an occupied lease. Recheck its full identity before retry.
+ if not conflict:
+ sessions = await self.connection.call("list")
+ if not any(item["id"] == params["session"] and all(
+ item["metadata"].get(key) == self.metadata.get(key)
+ for key in ("profile_root", "session_id", "space", "work_id", "btw", "cwd")
+ ) for item in sessions):
+ raise
+ await asyncio.sleep(CONTROLLER_LEASE_RETRY_DELAY)
+
+ async def call(self, method, params=None, **kwargs):
+ return await self.connection.call(method, {"session": self.id, **(params or {})}, **kwargs)
+
+ async def query(self, prompt) -> None:
+ if not isinstance(prompt, str):
+ prompt = [item async for item in prompt]
+ turn = self.next_turn
+ if not turn or not turn.get("id"):
+ raise ValueError("persistent Claude query requires its original turn identity")
+ self.next_turn = None
+ await self.call("query", {"prompt": prompt, "turn": turn}, request_id="query-" + turn["id"])
+
+ async def receive_messages(self):
+ await self.ready.wait()
+ for seed in self.description.get("task_seeds", []):
+ if seed["seq"] <= self.last_seq:
+ yield {**seed["data"], "__cc_service_origin": seed["origin_id"]}
+ while True:
+ result = await self.call("events", {"after": self.last_seq})
+ for event in result["events"]:
+ self.last_seq = event["seq"]
+ yield {**event["data"], "__cc_service_seq": event["seq"],
+ "__cc_service_ts": event["ts"]}
+ if event["seq"] == self.description["head"]:
+ # Even an ignored rate-limit/extension frame can be the
+ # last backlog row. Flush reconstruction without waiting
+ # for the model to produce another visible event.
+ yield {"type": "system", "subtype": "cc_remote_service_replay_end",
+ "__cc_service_seq": event["seq"]}
+ 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):
+ if not self.description.get("native_steering"):
+ raise ClaudeSteerRejected("Claude service requires a steering upgrade")
+ accepted = await self.call("steer", {
+ "prompt": prompt, "native_id": native_id,
+ "metadata": metadata, "turn_id": turn_id,
+ }, request_id="steer-" + native_id)
+ if not accepted:
+ raise ClaudeSteerRejected("Claude response has already ended")
+
+ async def _callbacks(self) -> None:
+ await self.ready.wait()
+ while True:
+ result = await self.call("callbacks", {"known": list(self.callback_tasks)})
+ for key in result["closed"]:
+ task = self.callback_tasks.pop(key, None)
+ if task is not None:
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+ for call in result["pending"]:
+ key = call["id"]
+ if key not in self.callback_tasks:
+ self.callback_tasks[key] = asyncio.create_task(self._callback(call))
+
+ async def _callback(self, call) -> None:
+ token = callback_identity.set(call["id"])
+ handled = False
+ value = None
+ delay = 0.25
+ try:
+ while True:
+ try:
+ if not handled:
+ if call["kind"] == "permission":
+ value = encode_sdk(await self.options.can_use_tool(
+ call["name"], call["input"], decode_sdk(call["context"])))
+ elif call["kind"] == "mcp":
+ value = await self._mcp(call["name"], call["message"])
+ else:
+ raise ValueError("unknown Claude callback")
+ handled = True
+ # An unknown answer delivery must reuse both the value and
+ # request identity, never execute the tool a second time.
+ await self.call("answer", {"callback_id": call["id"], "value": value},
+ request_id="answer-" + call["id"])
+ return
+ except Exception as exc:
+ if self.connection.task is None or self.connection.task.done():
+ raise
+ # Keep this known callback alive until the service closes
+ # it or the controller detaches. Cancellation must escape.
+ log.warning("Claude service callback will retry", callback_id=call["id"],
+ kind=call["kind"], stage="answer" if handled else "handler",
+ error=type(exc).__name__)
+ await asyncio.sleep(delay)
+ delay = min(delay * 2, 5)
+ finally:
+ callback_identity.reset(token)
+
+ async def _mcp(self, name: str, message: dict):
+ from claude_agent_sdk._internal.sdk_mcp_bridge import SdkMcpBridge
+
+ if name not in self.bridges:
+ self.bridges[name] = SdkMcpBridge(name, self.options.mcp_servers[name]["instance"])
+ self.bridge_locks[name] = asyncio.Lock()
+ bridge = self.bridges[name]
+ async with self.bridge_locks[name]:
+ if name not in self.bridge_ready:
+ initialize = self.description.get("initializers", {}).get(name)
+ if message.get("method") != "initialize" and initialize:
+ await bridge.handle(initialize)
+ await bridge.handle({"jsonrpc": "2.0", "method": "notifications/initialized"})
+ self.bridge_ready.add(name)
+ return await bridge.handle(message)
+
+ async def _send_control_request(self, request, timeout=60):
+ return await self.call("control", {"request": request, "timeout": timeout}, timeout=timeout + 10)
+
+ async def interrupt(self):
+ return await self.call("interrupt")
+
+ async def set_model(self, model):
+ return await self._send_control_request({"subtype": "set_model", "model": model})
+
+ async def set_permission_mode(self, mode):
+ return await self._send_control_request({"subtype": "set_permission_mode", "mode": mode})
+
+ async def rewind_files(self, user_message_id):
+ return await self._send_control_request({"subtype": "rewind_files", "user_message_id": user_message_id})
+
+ async def detach(self) -> None:
+ # Close the socket first: cancelled Wrapper callbacks must not become
+ # negative native answers. The service retains their original Futures.
+ await self.connection.disconnect()
+ tasks = [*self.callback_tasks.values()]
+ if self.callback_task is not None:
+ tasks.append(self.callback_task)
+ for task in tasks:
+ task.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
+ for bridge in self.bridges.values():
+ await bridge.aclose()
+
+ async def disconnect(self) -> None:
+ try:
+ await self.call("close")
+ finally:
+ await self.detach()
diff --git a/cc_remote/claude_service/server.py b/cc_remote/claude_service/server.py
new file mode 100644
index 00000000..e878ca80
--- /dev/null
+++ b/cc_remote/claude_service/server.py
@@ -0,0 +1,538 @@
+"""Own SDK/CLI lifetimes and pending callbacks outside the Wrapper process.
+
+Disconnecting a controller never interrupts a query. Only explicit ``close`` or
+``interrupt`` operations do that. The private journal retains an unacknowledged
+human turn from its beginning, including its terminal result, for reconstruction.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import dataclasses
+import hashlib
+import json
+import os
+import shutil
+import sqlite3
+import time
+from pathlib import Path
+from uuid import uuid4
+
+from cc_remote.claude_steering import PendingSteers, steer_message
+
+from .wire import (
+ ControllerLeaseConflict, decode_sdk, encode_sdk, private_directory,
+ read_frame, same_user, write_frame,
+)
+
+MAX_SESSIONS = 64
+MAX_CALLS = 64
+
+
+async def _prompt_messages(messages: list[dict]):
+ # Bind the materialized list as an argument. Closing over a variable that
+ # is then replaced by this iterator makes the iterator try to iterate itself.
+ for message in messages:
+ yield message
+
+
+class Journal:
+ def __init__(self, path: Path):
+ self.db = sqlite3.connect(path)
+ os.chmod(path, 0o600)
+ self.db.execute("CREATE TABLE events (seq INTEGER PRIMARY KEY, ts REAL NOT NULL, payload TEXT NOT NULL)")
+ self.seq = 0
+ self.size = 0
+
+ def append(self, value: dict) -> int:
+ payload = json.dumps(value, ensure_ascii=False)
+ self.seq += 1
+ self.db.execute("INSERT INTO events VALUES (?, ?, ?)", (self.seq, time.time(), payload))
+ self.db.commit()
+ self.size += len(payload.encode())
+ return self.seq
+
+ def after(self, seq: int) -> list[dict]:
+ # Bound the batch by bytes as well as count; a single SDK frame can be
+ # much larger than a normal text delta.
+ result = []
+ size = 0
+ for key, ts, payload in self.db.execute(
+ "SELECT seq, ts, payload FROM events WHERE seq > ? ORDER BY seq LIMIT 32", (seq,)
+ ):
+ if result and size + len(payload.encode()) > 16 * 1024 * 1024:
+ break
+ size += len(payload.encode())
+ result.append({"seq": key, "ts": ts, "data": json.loads(payload)})
+ return result
+
+ def prune(self, seq: int) -> None:
+ self.db.execute("DELETE FROM events WHERE seq <= ?", (seq,))
+ self.db.commit()
+ self.size = self.db.execute(
+ "SELECT COALESCE(SUM(LENGTH(CAST(payload AS BLOB))), 0) FROM events"
+ ).fetchone()[0]
+
+ def close(self) -> None:
+ self.db.close()
+
+
+def _human_result(data: dict) -> bool:
+ origin = data.get("origin")
+ kind = origin.get("kind") if isinstance(origin, dict) else None
+ return data.get("type") == "result" and kind in (None, "human")
+
+
+class Session:
+ def __init__(self, directory: Path, metadata: dict, factory=None):
+ self.id = uuid4().hex
+ self.metadata = dict(metadata)
+ self.journal_path = directory / f"{self.id}.sqlite3"
+ self.journal = Journal(self.journal_path)
+ self.factory = factory
+ self.client = None
+ self.controller = None
+ self.reader = None
+ self.changed = asyncio.Condition()
+ self.callbacks: dict[str, tuple[dict, asyncio.Future]] = {}
+ self.callback_answers: dict[str, object] = {}
+ self.initializers: dict[str, dict] = {}
+ self.turn: dict | None = None
+ self.steers = PendingSteers()
+ # Controller replacement may happen before an attachment's native echo.
+ # Keep ownership here until exact terminal commit or explicit close.
+ self.steer_attachment_dirs: set[str] = set()
+ self.origin_id: str | None = None
+ self.ack = 0
+ self.terminal_seq: int | None = None
+ self.failure: str | None = None
+ self.controls: dict = {}
+ self.task_seeds: dict[str, dict] = {}
+ self.background_turns: list[dict] = []
+ self.submitted_turns: set[str] = set()
+ self.question_answers: dict[str, object] = {}
+ self.closed = False
+ self.mutations: dict[str, asyncio.Task] = {}
+ self.mutation_fingerprints: dict[str, str] = {}
+ self.lock = asyncio.Lock()
+
+ async def notify(self) -> None:
+ async with self.changed:
+ self.changed.notify_all()
+
+ @property
+ def background_start(self) -> int | None:
+ return self.background_turns[0]["start_seq"] if self.background_turns else None
+
+ async def callback(self, kind: str, payload: dict):
+ if len(self.callbacks) >= MAX_CALLS:
+ raise RuntimeError("Claude callback capacity reached")
+ key = "claude-call-" + uuid4().hex
+ future = asyncio.get_running_loop().create_future()
+ self.callbacks[key] = ({"id": key, "kind": kind, **payload}, future)
+ await self.notify()
+ try:
+ return await future
+ finally:
+ self.callbacks.pop(key, None)
+ await self.notify()
+
+ def mcp_proxy(self, name: str):
+ import anyio
+ from mcp import types
+ from mcp.server import Server
+ from mcp.shared.message import SessionMessage
+
+ session = self
+
+ class Proxy(Server):
+ async def run(self, read_stream, write_stream, initialization_options, **kwargs):
+ async def forward(item):
+ value = item.message.model_dump(mode="json", by_alias=True, exclude_none=True)
+ if value.get("method") == "initialize":
+ session.initializers[name] = value
+ reply = await session.callback("mcp", {"name": name, "message": value})
+ if reply is not None:
+ await write_stream.send(SessionMessage(types.JSONRPCMessage.model_validate(reply)))
+
+ async with anyio.create_task_group() as group:
+ async for item in read_stream:
+ group.start_soon(forward, item)
+
+ return Proxy(name)
+
+ async def start(self, options: dict, isolated: bool) -> None:
+ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
+
+ from cc_remote.wrapper.claude_transport import account_isolated_transport
+
+ async def permission(name, tool_input, context):
+ # The SDK's signal is process-local; pending native requests remain
+ # owned here. All public context fields retain their SDK types.
+ context = dataclasses.replace(context, signal=None)
+ answer = await self.callback("permission", {
+ "name": name, "input": tool_input, "context": encode_sdk(context),
+ })
+ return decode_sdk(answer)
+
+ values = decode_sdk(options)
+ servers = values.get("mcp_servers", {})
+ if isinstance(servers, dict):
+ for name, config in servers.items():
+ if config.get("type") == "sdk":
+ config["instance"] = self.mcp_proxy(name)
+ opts = ClaudeAgentOptions(**values, can_use_tool=permission, stderr=lambda _: None)
+ self.controls = {"model": opts.model, "permission_mode": opts.permission_mode,
+ "effort": opts.effort}
+ factory = self.factory or ClaudeSDKClient
+ kwargs = {"options": opts}
+ if isolated:
+ kwargs["transport"] = account_isolated_transport(opts)
+ self.client = factory(**kwargs)
+ await self.client.connect()
+ self.reader = asyncio.create_task(self.read_messages())
+
+ async def read_messages(self) -> None:
+ try:
+ async for value in self.client._query.receive_messages():
+ if self.closed:
+ return
+ value = self.steers.annotate(value)
+ if "__cc_steer" in value:
+ self.origin_id = value["__cc_steer"]["id"]
+ # 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})
+ if value.get("type") == "result" and kind not in (None, "human"):
+ for turn in reversed(self.background_turns):
+ if turn["terminal_seq"] is None:
+ turn["terminal_seq"] = seq
+ break
+ if value.get("type") == "system":
+ subtype = value.get("subtype")
+ task_id = value.get("task_id")
+ if subtype in {"task_started", "task_notification"} and task_id:
+ self.task_seeds[str(task_id)] = {
+ "data": value, "origin_id": self.origin_id, "seq": seq,
+ }
+ sid = value.get("session_id")
+ if isinstance(sid, str) and sid:
+ self.metadata["session_id"] = sid
+ if (self.turn is not None and _human_result(value)
+ and not value.get("__cc_steer_intermediate")):
+ self.terminal_seq = seq
+ await self.notify()
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc:
+ self.failure = type(exc).__name__
+ else:
+ self.failure = "SDKStreamClosed"
+ finally:
+ await self.notify()
+
+ def description(self) -> dict:
+ return {
+ "id": self.id, "metadata": self.metadata, "turn": self.turn,
+ "origin_id": self.origin_id, "terminal_seq": self.terminal_seq,
+ "after": self.turn["start_seq"] if self.turn else (
+ min(self.ack, self.background_start)
+ if self.background_start is not None else self.ack),
+ "initializers": self.initializers, "failure": self.failure,
+ "head": self.journal.seq, "controls": self.controls, "pid": os.getpid(),
+ "task_seeds": list(self.task_seeds.values()),
+ "native_steering": True,
+ }
+
+ async def events(self, after: int) -> dict:
+ async with self.changed:
+ with contextlib.suppress(TimeoutError):
+ await asyncio.wait_for(self.changed.wait_for(
+ lambda: self.closed or self.failure or self.journal.seq > after
+ ), 20)
+ return {"events": self.journal.after(after), "failure": self.failure}
+
+ async def pending(self, known: list[str]) -> dict:
+ async with self.changed:
+ with contextlib.suppress(TimeoutError):
+ await asyncio.wait_for(self.changed.wait_for(
+ lambda: self.closed or set(known) != {
+ key for key, (_, future) in self.callbacks.items() if not future.done()
+ }
+ ), 20)
+ pending = {key: value for key, (value, future) in self.callbacks.items() if not future.done()}
+ return {"pending": [value for key, value in pending.items() if key not in known],
+ "closed": [key for key in known if key not in pending]}
+
+ async def mutate(self, request_id: str, method: str, params: dict):
+ # Accepted writes outlive their connection. A lost acknowledgement may
+ # be retried with the same ID but must never submit a second model turn.
+ identity = [method, params]
+ if method == "steer" and params.get("metadata", {}).get("fingerprint"):
+ # 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"],
+ params["metadata"]["fingerprint"]]
+ fingerprint = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
+ previous = self.mutation_fingerprints.get(request_id)
+ if previous is not None and previous != fingerprint:
+ raise ValueError("Claude request identity was reused with different data")
+ task = self.mutations.get(request_id)
+ if task is None:
+ if len(self.mutations) >= 1024:
+ done = [key for key, value in self.mutations.items() if value.done()]
+ for key in done[:512]:
+ self.mutations.pop(key)
+ self.mutation_fingerprints.pop(key, None)
+ if len(self.mutations) >= 1024:
+ raise RuntimeError("Claude service request capacity reached")
+ task = asyncio.create_task(self._mutate(method, params))
+ self.mutations[request_id] = task
+ self.mutation_fingerprints[request_id] = fingerprint
+ return await asyncio.shield(task)
+
+ async def _mutate(self, method: str, params: dict):
+ if method == "remember_answer":
+ key = params["ask_id"]
+ if key in self.question_answers and self.question_answers[key] != params["answer"]:
+ raise ValueError("Claude question was already answered differently")
+ self.question_answers[key] = params["answer"]
+ return None
+ if method == "answer":
+ key = params["callback_id"]
+ pending = self.callbacks.get(key)
+ if pending is not None and not pending[1].done():
+ pending[1].set_result(params.get("value"))
+ self.callback_answers[key] = True
+ elif key not in self.callback_answers:
+ raise ValueError("Claude callback is no longer pending")
+ return None
+ if method == "interrupt":
+ return await self.steers.interrupt(self.client)
+ if method == "control":
+ result = await self.client._query._send_control_request(
+ params["request"], timeout=params.get("timeout", 60))
+ request = params["request"]
+ if request.get("subtype") == "set_model":
+ self.controls["model"] = request.get("model")
+ elif request.get("subtype") == "set_permission_mode":
+ self.controls["permission_mode"] = request.get("mode")
+ 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"]):
+ return False
+ self.steers.add(params["native_id"], params["metadata"])
+ directory = params["metadata"].get("attachment_dir")
+ if directory:
+ self.steer_attachment_dirs.add(directory)
+
+ async def stream():
+ yield steer_message(params["prompt"], params["native_id"])
+
+ await self.client.query(stream())
+ return True
+ if method == "query":
+ if params["turn"]["id"] in self.submitted_turns:
+ raise ValueError("Claude turn was already submitted")
+ if self.turn is not None or self.background_start is not None or self.failure:
+ raise RuntimeError("Claude session is busy or unavailable")
+ prompt = params["prompt"]
+ if isinstance(prompt, list):
+ if not prompt or not all(isinstance(item, dict) for item in prompt):
+ raise ValueError("Claude prompt must contain message objects")
+ # Validate before claiming the turn. Once a transport write
+ # starts, an exception cannot prove that delivery failed.
+ json.dumps(prompt)
+ 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.terminal_seq = None
+ self.submitted_turns.add(self.turn["id"])
+ self.callback_answers.clear()
+ self.task_seeds = {
+ key: seed for key, seed in self.task_seeds.items()
+ if seed["data"].get("subtype") != "task_notification"
+ }
+ # Retain the accepted operation even on a transport exception:
+ # acceptance may be unknown, so automatic resubmission is unsafe.
+ await self.client.query(prompt)
+ return None
+ if method == "commit":
+ if self.turn and params["turn_id"] == self.turn["id"]:
+ 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.turn = None
+ self._cleanup_steer_attachments()
+ self.journal.prune(
+ min(self.ack, self.background_start)
+ if self.background_start is not None else self.ack)
+ await self.notify()
+ return None
+ if method == "ack":
+ seq = params["seq"]
+ 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)
+ }
+ 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)
+ self.journal.prune(boundary)
+ await self.notify()
+ return None
+ if method == "metadata":
+ self.metadata.update(params["value"])
+ return None
+ raise ValueError("unknown Claude service operation")
+
+ def _cleanup_steer_attachments(self) -> None:
+ for directory in self.steer_attachment_dirs:
+ shutil.rmtree(directory, ignore_errors=True)
+ self.steer_attachment_dirs.clear()
+
+ async def close(self) -> None:
+ self.closed = True
+ await self.notify()
+ if self.reader is not None:
+ self.reader.cancel()
+ await asyncio.gather(self.reader, return_exceptions=True)
+ if self.client is not None:
+ await self.client.disconnect()
+ self._cleanup_steer_attachments()
+ for _, future in self.callbacks.values():
+ future.cancel()
+ self.journal.close()
+ self.journal_path.unlink(missing_ok=True)
+
+
+class Service:
+ def __init__(self, directory: Path, *, factory=None):
+ private_directory(directory)
+ self.directory = directory
+ self.factory = factory
+ self.sessions: dict[str, Session] = {}
+ self.open_lock = asyncio.Lock()
+
+ async def connection(self, reader, writer) -> None:
+ if not same_user(writer):
+ writer.close()
+ return
+ owner = object()
+ send_lock = asyncio.Lock()
+ tasks: set[asyncio.Task] = set()
+
+ async def request(frame):
+ key = frame.get("id")
+ try:
+ value = await self.dispatch(owner, key, frame["method"], frame.get("params", {}))
+ reply = {"id": key, "result": value}
+ except Exception as exc:
+ # SDK errors may contain account/provider data; the local client
+ # gets a classification, never a credential-bearing traceback.
+ from cc_remote.wrapper.sdk import _is_control_request_timeout
+
+ subtype = frame.get("params", {}).get("request", {}).get("subtype", "")
+ reply = {"id": key, "error": type(exc).__name__, "timeout": bool(
+ subtype and _is_control_request_timeout(exc, subtype=subtype))}
+ async with send_lock:
+ await write_frame(writer, reply)
+
+ try:
+ while True:
+ frame = await read_frame(reader)
+ if len(tasks) >= MAX_CALLS:
+ raise ValueError("too many Claude service requests")
+ task = asyncio.create_task(request(frame))
+ tasks.add(task)
+ task.add_done_callback(tasks.discard)
+ except (asyncio.IncompleteReadError, ConnectionError, ValueError):
+ pass
+ finally:
+ # Cancel connection waiters, never the shielded mutations or SDK.
+ for task in tasks:
+ task.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
+ for session in self.sessions.values():
+ if session.controller is owner:
+ session.controller = None
+ writer.close()
+ with contextlib.suppress(ConnectionError):
+ await writer.wait_closed()
+
+ async def dispatch(self, owner, request_id, method, params):
+ if method == "hello":
+ from importlib.metadata import version
+
+ return {"sdk_version": version("claude-agent-sdk"), "pid": os.getpid(),
+ "strict_controller_leases": True,
+ "source": str(Path(__file__).resolve().parents[2])}
+ if method == "list":
+ return [session.description() for session in self.sessions.values() if not session.closed]
+ if method == "open":
+ async with self.open_lock:
+ identity = params["metadata"]
+ session = self.sessions.get(params.get("session"))
+ if params.get("strict_session") and params.get("session") is not None and session is None:
+ raise KeyError("Claude service recovery worker no longer exists")
+ if session is None and identity.get("session_id") and not params.get("fork"):
+ session = next((item for item in self.sessions.values() if all(
+ item.metadata.get(key) == identity.get(key)
+ for key in ("profile_root", "session_id", "space", "work_id", "btw")
+ )), None)
+ attached = session is not None
+ if session is not None:
+ if any(session.metadata.get(key) != identity.get(key) for key in (
+ "profile_root", "session_id", "space", "work_id", "btw", "cwd",
+ )):
+ raise PermissionError("Claude session identity mismatch")
+ if session.controller is not None and session.controller is not owner:
+ raise ControllerLeaseConflict("Claude service already has a controller")
+ else:
+ if len(self.sessions) >= MAX_SESSIONS:
+ raise RuntimeError("Claude service session capacity reached")
+ session = Session(self.directory, identity, self.factory)
+ self.sessions[session.id] = session
+ try:
+ await session.start(params["options"], params.get("isolated", False))
+ except BaseException:
+ self.sessions.pop(session.id, None)
+ await session.close()
+ raise
+ session.controller = owner
+ return {**session.description(), "attached": attached}
+ session = self.sessions[params["session"]]
+ if session.controller is not owner:
+ raise PermissionError("Claude service controller lease is required")
+ if method == "events":
+ return await session.events(params["after"])
+ if method == "callbacks":
+ return await session.pending(params.get("known", []))
+ if method == "question_answer":
+ key = params["ask_id"]
+ return {"found": key in session.question_answers, "answer": session.question_answers.get(key)}
+ if method == "close":
+ await session.close()
+ self.sessions.pop(session.id, None)
+ return None
+ return await session.mutate(request_id, method, params)
diff --git a/cc_remote/claude_service/wire.py b/cc_remote/claude_service/wire.py
new file mode 100644
index 00000000..acdc8d69
--- /dev/null
+++ b/cc_remote/claude_service/wire.py
@@ -0,0 +1,87 @@
+"""Bounded local framing. This is not the browser/relay wire protocol."""
+
+from __future__ import annotations
+
+import asyncio
+import dataclasses
+import json
+import os
+import socket
+import struct
+import sys
+from pathlib import Path
+
+VERSION = 1
+MAX_FRAME = 32 * 1024 * 1024
+
+
+class ControllerLeaseConflict(RuntimeError):
+ """An existing native worker still belongs to another controller."""
+
+
+async def read_frame(reader: asyncio.StreamReader) -> dict:
+ size = struct.unpack("!I", await reader.readexactly(4))[0]
+ if not 0 < size <= MAX_FRAME:
+ raise ValueError("invalid Claude service frame size")
+ value = json.loads(await reader.readexactly(size))
+ if not isinstance(value, dict) or value.get("v") != VERSION:
+ raise ValueError("incompatible Claude service protocol")
+ return value
+
+
+async def write_frame(writer: asyncio.StreamWriter, value: dict) -> None:
+ data = json.dumps({**value, "v": VERSION}, ensure_ascii=False).encode()
+ if len(data) > MAX_FRAME:
+ raise ValueError("Claude service frame too large")
+ writer.write(struct.pack("!I", len(data)) + data)
+ await writer.drain()
+
+
+def same_user(writer: asyncio.StreamWriter) -> bool:
+ sock = writer.get_extra_info("socket")
+ if sys.platform == "darwin":
+ # SOL_LOCAL / LOCAL_PEERCRED: xucred = version, uid, ngroups, groups.
+ cred = sock.getsockopt(0, 1, 80)
+ return struct.unpack_from("=I", cred, 4)[0] == os.getuid()
+ cred = sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
+ return struct.unpack("3i", cred)[1] == os.getuid()
+
+
+def private_directory(path: Path) -> None:
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
+ info = path.lstat()
+ if path.is_symlink() or info.st_uid != os.getuid() or info.st_mode & 0o077:
+ raise PermissionError("Claude service directory must be private and owned by this user")
+
+
+def encode_sdk(value):
+ """Only SDK dataclasses cross this boundary; never pickle executable data."""
+ if dataclasses.is_dataclass(value):
+ return {"sdk_type": type(value).__name__, "fields": {
+ field.name: encode_sdk(getattr(value, field.name))
+ for field in dataclasses.fields(value)
+ }}
+ if isinstance(value, dict):
+ return {key: encode_sdk(item) for key, item in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [encode_sdk(item) for item in value]
+ if isinstance(value, Path):
+ return str(value)
+ if value is None or isinstance(value, (str, int, float, bool)):
+ return value
+ raise TypeError(f"unsupported SDK value: {type(value).__name__}")
+
+
+def decode_sdk(value):
+ if isinstance(value, list):
+ return [decode_sdk(item) for item in value]
+ if not isinstance(value, dict):
+ return value
+ if set(value) == {"sdk_type", "fields"}:
+ from claude_agent_sdk import types
+
+ cls = getattr(types, value["sdk_type"], None)
+ if not isinstance(cls, type) or not dataclasses.is_dataclass(cls):
+ raise ValueError("unknown SDK data type")
+ return cls(**{key: decode_sdk(item) for key, item in value["fields"].items()})
+ return {key: decode_sdk(item) for key, item in value.items()}
diff --git a/cc_remote/claude_steering.py b/cc_remote/claude_steering.py
new file mode 100644
index 00000000..24422fe2
--- /dev/null
+++ b/cc_remote/claude_steering.py
@@ -0,0 +1,62 @@
+"""Native streaming-input steering, shared by the SDK owner and its controller."""
+
+from __future__ import annotations
+
+
+class ClaudeSteerRejected(RuntimeError):
+ """The instruction was definitely not written to the native input stream."""
+
+
+def steer_message(prompt, native_id: str) -> dict:
+ # `next` is consumed at the next safe input boundary. `now` interrupts a
+ # running tool; omitting the priority leaves that policy to the CLI default.
+ return {"type": "user", "message": {"role": "user", "content": prompt},
+ "parent_tool_use_id": None, "uuid": native_id, "priority": "next"}
+
+
+class PendingSteers:
+ """Fence accepted inputs against their exact replayed human UUIDs.
+
+ A Result already in flight can precede a just-written input. It ends a
+ physical response, but cannot retire the controller's accepted work. The
+ service journals these annotations so reattachment has the same boundary.
+ """
+
+ def __init__(self):
+ self.pending: dict[str, dict] = {}
+ self.capabilities: set[str] = set()
+
+ def add(self, native_id: str, metadata: dict) -> None:
+ if len(self.pending) >= 32 or native_id in self.pending:
+ raise ClaudeSteerRejected("Claude steering capacity reached")
+ self.pending[native_id] = metadata
+
+ def annotate(self, value: dict) -> dict:
+ if value.get("type") == "system" and value.get("subtype") == "init":
+ self.capabilities = set(value.get("capabilities") or [])
+ if value.get("type") == "command_lifecycle" and value.get("state") == "cancelled":
+ metadata = self.pending.pop(value.get("command_uuid"), None)
+ if metadata is not None:
+ return {**value, "type": "system", "subtype": "cc_remote_steer_cancelled",
+ "__cc_steer_cancelled": metadata}
+ origin = value.get("origin")
+ kind = origin.get("kind") if isinstance(origin, dict) else None
+ if kind not in (None, "human") or value.get("parent_tool_use_id"):
+ return value
+ if value.get("type") == "user":
+ metadata = self.pending.pop(value.get("uuid"), None)
+ if metadata is not None:
+ return {**value, "__cc_steer": metadata}
+ elif value.get("type") == "result" and self.pending:
+ return {**value, "__cc_steer_intermediate": True}
+ return value
+
+ async def interrupt(self, client) -> None:
+ if self.pending and "interrupt_cancel_queued_v1" in self.capabilities:
+ # Ordinary interrupt leaves async inputs queued. Native cancellation
+ # emits exact lifecycle frames before Result, so both controller and
+ # journal can retire only the cancelled inputs without guessing.
+ await client._query._send_control_request(
+ {"subtype": "interrupt", "cancel_queued": True})
+ else:
+ await client.interrupt()
diff --git a/cc_remote/config.py b/cc_remote/config.py
index c2586b23..f058d97f 100644
--- a/cc_remote/config.py
+++ b/cc_remote/config.py
@@ -147,6 +147,34 @@ def _claude_profiles_json() -> str:
return payload.strip()
+def _claude_service_socket(key: str = "socket") -> str:
+ env_key = ("CC_REMOTE_CLAUDE_SERVICE_SOCKET" if key == "socket"
+ else "CC_REMOTE_CLAUDE_SERVICE_DRAIN_SOCKET")
+ explicit = os.environ.get(env_key)
+ if explicit is not None:
+ return explicit.strip()
+ # A system-managed Wrapper can use its user's private registration without
+ # granting that user permission to rewrite root-owned service credentials.
+ directory = Path(_env("CC_REMOTE_STATE_DIR", str(Path.home() / ".cc-remote"))).expanduser()
+ path = directory / "claude-service.json"
+ try:
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
+ except FileNotFoundError:
+ return ""
+ with os.fdopen(fd, "r") as source:
+ info = os.fstat(source.fileno())
+ if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
+ or info.st_mode & 0o077 or info.st_size > 4096):
+ raise ValueError("Claude service registration must be a private user-owned file")
+ payload = json.loads(source.read(4097))
+ if not isinstance(payload, dict) or not isinstance(payload.get("socket"), str):
+ raise ValueError("invalid Claude service registration")
+ value = payload.get(key, "")
+ if not isinstance(value, str):
+ raise ValueError("invalid Claude service registration")
+ return value
+
+
def _default_device_db_path() -> str:
push_path = _env("PUSH_DB_PATH", "").strip()
if push_path:
@@ -277,6 +305,14 @@ class WrapperConfig:
# direct native Claude owners are mirrored read-only and explicitly taken
# over by the SDK instead of sharing a PTY input state machine.
claude_broker_socket: str = field(default_factory=default_socket_path)
+ # Installed separately from the Wrapper's service/cgroup. An empty value
+ # retains the in-process SDK for unmanaged development installations.
+ claude_service_socket: str = field(
+ default_factory=lambda: _env("CC_REMOTE_CLAUDE_SERVICE_SOCKET", ""))
+ # During an SDK-service upgrade, existing native work may finish on the
+ # previous service while newly opened sessions use the primary socket.
+ claude_service_drain_socket: str = field(
+ default_factory=lambda: _env("CC_REMOTE_CLAUDE_SERVICE_DRAIN_SOCKET", ""))
experimental_claude_broker: bool = field(
default_factory=lambda: _bool(
"CC_REMOTE_EXPERIMENTAL_CLAUDE_BROKER", False))
@@ -551,7 +587,10 @@ def validate_relay_config(cfg: RelayConfig) -> None:
def wrapper_config() -> WrapperConfig:
- return WrapperConfig()
+ # Read the opt-in installation registration only at actual Wrapper startup;
+ # constructing test/development config objects must not attach live workers.
+ return WrapperConfig(claude_service_socket=_claude_service_socket(),
+ claude_service_drain_socket=_claude_service_socket("drain_socket"))
def validate_wrapper_config(cfg: WrapperConfig) -> None:
@@ -638,6 +677,19 @@ def validate_wrapper_config(cfg: WrapperConfig) -> None:
elif not os.path.isabs(os.path.expanduser(cfg.claude_broker_socket)):
errors.append(
"CC_REMOTE_CLAUDE_BROKER_SOCKET must be an absolute path")
+ if cfg.claude_service_socket:
+ service_socket = os.path.expanduser(cfg.claude_service_socket)
+ if (not os.path.isabs(service_socket) or "\x00" in service_socket
+ or len(os.fsencode(service_socket)) > 103):
+ errors.append("CC_REMOTE_CLAUDE_SERVICE_SOCKET must be an absolute Unix socket path of at most 103 bytes")
+ if cfg.experimental_claude_broker:
+ errors.append("Claude SDK service and experimental PTY broker cannot be enabled together")
+ if cfg.claude_service_drain_socket:
+ drain_socket = os.path.expanduser(cfg.claude_service_drain_socket)
+ if (not cfg.claude_service_socket or not os.path.isabs(drain_socket)
+ or "\x00" in drain_socket or len(os.fsencode(drain_socket)) > 103
+ or drain_socket == os.path.expanduser(cfg.claude_service_socket)):
+ errors.append("CC_REMOTE_CLAUDE_SERVICE_DRAIN_SOCKET requires a distinct absolute Unix socket path")
if not (12 * 1024 * 1024 <= cfg.ws_max_size_bytes <= 64 * 1024 * 1024):
errors.append("WS_MAX_SIZE_BYTES must be between 12582912 and 67108864")
diff --git a/cc_remote/protocol.py b/cc_remote/protocol.py
index 60e0cfdf..4154eb4e 100644
--- a/cc_remote/protocol.py
+++ b/cc_remote/protocol.py
@@ -28,7 +28,7 @@
MAX_SINGLE_ATTACHMENT_BYTES,
)
-PROTOCOL_VERSION = 67
+PROTOCOL_VERSION = 71
# 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
@@ -468,7 +468,7 @@ class QueryQueueState(_Base):
class Steer(_Command):
- """Append input to the active Codex turn without interrupting it."""
+ """Append input to the active engine turn without interrupting it."""
type: Literal["steer"] = "steer"
# Steer has no pre-v21 compatibility form. Requiring the reliable identity
# prevents an ACK-lost retry from appending the same instruction twice.
@@ -675,6 +675,22 @@ def must_be_routed_to_origin(self):
# ---- wrapper -> client (via relay); all carry seq ----
+class TokenUsage(BaseModel):
+ """Native token counts. Input includes cache reads/writes; null is unknown."""
+ model_config = ConfigDict(extra="forbid")
+ input_tokens: Optional[int] = Field(default=None, ge=0, le=MAX_SAFE_WIRE_INTEGER, strict=True)
+ output_tokens: Optional[int] = Field(default=None, ge=0, le=MAX_SAFE_WIRE_INTEGER, strict=True)
+ cache_read_tokens: Optional[int] = Field(default=None, ge=0, le=MAX_SAFE_WIRE_INTEGER, strict=True)
+ cache_write_tokens: Optional[int] = Field(default=None, ge=0, le=MAX_SAFE_WIRE_INTEGER, strict=True)
+
+
+class TurnUsage(_Base):
+ """Replace-only totals for one exact native/logical turn, never a delta."""
+ type: Literal["turn_usage"] = "turn_usage"
+ turn_id: WireId
+ usage: TokenUsage
+
+
class ReplayStart(_Base):
type: Literal["replay_start"] = "replay_start"
from_seq: int
@@ -692,6 +708,7 @@ class ReplayEnd(_Base):
type: Literal["replay_end"] = "replay_end"
to_seq: int
truncated: bool
+ turn_usage: list[TurnUsage] = Field(default_factory=list, max_length=8)
class Snapshot(_Base):
@@ -705,6 +722,7 @@ class Snapshot(_Base):
# a live SessionControl event; embedding it here closes reconnect races when
# the corresponding control event has already fallen out of the ring.
control: Optional[SessionControl] = None
+ turn_usage: list[TurnUsage] = Field(default_factory=list, max_length=8)
class StateEvent(_Base):
@@ -834,6 +852,25 @@ class BtwClosed(_Base):
revision: int = Field(ge=0, le=9_007_199_254_740_991)
+class TimedMessage(BaseModel):
+ """Explicit local scheduler receipt, never inferred from message text."""
+ model_config = ConfigDict(extra="forbid")
+ task_id: WireId
+ title: str = Field(min_length=1, max_length=120)
+ scheduled_at: float = Field(ge=0, le=MAX_SAFE_WIRE_TIMESTAMP_SECONDS)
+
+
+class TimedTaskInfo(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ task_id: WireId
+ title: str = Field(min_length=1, max_length=120)
+ next_message_at: float = Field(ge=0, le=MAX_SAFE_WIRE_TIMESTAMP_SECONDS)
+ interval_seconds: float = Field(ge=1, le=31_536_000)
+ sent_count: int = Field(ge=0, le=1000)
+ total_count: int = Field(ge=1, le=1000)
+ valid_until: float = Field(ge=0, le=MAX_SAFE_WIRE_TIMESTAMP_SECONDS)
+
+
class UserMsg(_Base):
"""A user's query, broadcast to all clients so every device sees the full
conversation (prompt + response). The originating client dedups by msg_id
@@ -846,6 +883,7 @@ class UserMsg(_Base):
# a source-derived id. Carry both so a history-first race can deduplicate
# the later live echo.
client_msg_id: Optional[WireId] = None
+ timed_task: Optional[TimedMessage] = 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
@@ -854,7 +892,7 @@ class UserMsg(_Base):
class TurnSteered(_Base):
- """A user message appended to the active Codex turn."""
+ """A user message accepted by the active engine turn."""
type: Literal["turn_steered"] = "turn_steered"
msg_id: WireId
turn_id: WireId
@@ -874,6 +912,8 @@ class AssistantMsgStart(_Base):
class Delta(_Base):
+ # Replace a replayed message prefix with its authoritative text.
+ replace: bool = False
type: Literal["delta"] = "delta"
message_id: WireId
turn_id: Optional[WireId] = None
@@ -1184,6 +1224,7 @@ class SessionInfo(BaseModel):
"""A row in the sessions sidebar (subset of SDK SDKSessionInfo)."""
model_config = ConfigDict(extra="forbid")
session_id: WireId
+ timed_tasks: list[TimedTaskInfo] = Field(default_factory=list, max_length=32)
summary: Optional[str] = None
last_modified: Optional[str] = None
first_prompt: Optional[str] = None
@@ -2430,6 +2471,7 @@ class ConversationTurn(BaseModel):
model_config = ConfigDict(extra="forbid")
id: WireId
clientMsgId: Optional[WireId] = None
+ timedTask: Optional[TimedMessage] = None
prompt: str = Field(default="", max_length=128 * 1024)
blocks: list[dict[str, Any]] = Field(default_factory=list, max_length=32)
done: bool = False
@@ -2836,7 +2878,7 @@ def unread_requires_identity(self):
AcknowledgeCompletion, CompletionState,
UserMsg, TurnSteered, AssistantMsgStart, Delta, ToolUse, ToolDelta, ToolResult,
AssistantMsgEnd, ProcessEvent, TurnPlan, TurnDiff, TurnFileChanges, TurnBinding,
- TurnEnd, Error, WrapperDisconnected, WrapperReconnected,
+ TurnUsage, TurnEnd, Error, WrapperDisconnected, WrapperReconnected,
]
# Session-narrative events the wrapper seqs and buffers. Replay/snapshot/
@@ -2850,7 +2892,7 @@ def unread_requires_identity(self):
"collaboration_mode", "session_control", "query_queue", "btw_opened",
"assistant_msg_start", "delta", "tool_use", "tool_delta", "tool_result",
"assistant_msg_end", "process", "turn_plan", "turn_diff", "turn_file_changes", "turn_binding",
- "turn_end", "completion_state",
+ "turn_usage", "turn_end", "completion_state",
"error", "ask_user", "ask_user_closed", "history_invalidated", "artifact_invalidated",
})
@@ -3004,6 +3046,7 @@ def unread_requires_identity(self):
"turn_file_changes": TurnFileChanges,
"turn_binding": TurnBinding,
"turn_end": TurnEnd,
+ "turn_usage": TurnUsage,
"error": Error,
"wrapper_disconnected": WrapperDisconnected,
"wrapper_reconnected": WrapperReconnected,
diff --git a/cc_remote/timed_tasks.py b/cc_remote/timed_tasks.py
new file mode 100644
index 00000000..2eae6bc8
--- /dev/null
+++ b/cc_remote/timed_tasks.py
@@ -0,0 +1,322 @@
+"""Local scheduled-message receipts; prompts and account paths stay on the host.
+
+The clock is a detached helper, not a Goal. Delivery uses the existing official
+Codex daemon's queue and an explicit client message id. Reads never start an
+engine, and ambiguous delivery is never retried automatically.
+"""
+from __future__ import annotations
+
+import argparse
+import asyncio
+from contextlib import contextmanager
+import fcntl
+import json
+import os
+from pathlib import Path
+import sqlite3
+import stat
+import subprocess
+import sys
+import time
+from uuid import UUID, uuid4
+
+from websockets.asyncio.client import unix_connect
+
+MAX_TASKS = 32
+MAX_SENDS = 1000
+LEASE_SECONDS = 90
+
+
+class TimedTaskStore:
+ def __init__(self, state_dir: Path):
+ self.path = Path(state_dir) / "timed-tasks.sqlite3"
+
+ @contextmanager
+ def connect(self, *, write=False):
+ if write:
+ self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
+ if self.path.exists() or self.path.is_symlink():
+ info = self.path.lstat()
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
+ raise ValueError("Invalid task store")
+ elif not write:
+ yield None
+ return
+ db = sqlite3.connect(self.path.as_uri() + ("?mode=rwc" if write else "?mode=ro"),
+ uri=True, timeout=1)
+ db.row_factory = sqlite3.Row
+ try:
+ if write:
+ os.chmod(self.path, 0o600)
+ db.executescript("""
+ CREATE TABLE IF NOT EXISTS tasks (
+ id TEXT PRIMARY KEY, home TEXT NOT NULL, sid TEXT NOT NULL,
+ title TEXT NOT NULL, prompt TEXT NOT NULL,
+ interval REAL NOT NULL, count INTEGER NOT NULL,
+ sent INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL,
+ next_at REAL, lease_until REAL NOT NULL, updated REAL NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS task_owner ON tasks(home,sid,updated);
+ CREATE TABLE IF NOT EXISTS deliveries (
+ id TEXT PRIMARY KEY, task_id TEXT NOT NULL,
+ ordinal INTEGER NOT NULL, scheduled_at REAL NOT NULL,
+ native_id TEXT, turn_id TEXT, accepted INTEGER NOT NULL DEFAULT 0,
+ UNIQUE(task_id, ordinal)
+ );
+ CREATE INDEX IF NOT EXISTS delivery_task ON deliveries(task_id);
+ CREATE INDEX IF NOT EXISTS delivery_native ON deliveries(native_id);
+ CREATE INDEX IF NOT EXISTS delivery_turn ON deliveries(turn_id);
+ """)
+ else:
+ db.execute("PRAGMA query_only=ON")
+ yield db
+ if write:
+ db.commit()
+ finally:
+ db.close()
+
+ def create(self, home: str, sid: str, title: str, prompt: str,
+ delay: float, interval: float, count: int) -> str:
+ UUID(sid)
+ if not (1 <= count <= MAX_SENDS and 1 <= delay <= 31_536_000
+ and 1 <= interval <= 31_536_000 and 0 < len(prompt) <= 32_768
+ and 0 < len(title) <= 120):
+ raise ValueError("Invalid scheduled message")
+ task_id, now = str(uuid4()), time.time()
+ home = str(Path(home).expanduser().resolve(strict=True))
+ with self.connect(write=True) as db:
+ db.execute("BEGIN IMMEDIATE")
+ active = db.execute("SELECT count(*) FROM tasks WHERE home=? AND sid=? "
+ "AND state='running' AND lease_until>?", (home, sid, now)).fetchone()[0]
+ if active >= MAX_TASKS:
+ raise ValueError("Too many active scheduled tasks")
+ db.execute("INSERT INTO tasks VALUES(?,?,?,?,?,?,?,0,'running',?,?,?)",
+ (task_id, home, sid, title, prompt, interval, count,
+ now + delay, now + LEASE_SECONDS, now))
+ return task_id
+
+ def get(self, task_id: str) -> dict | None:
+ with self.connect() as db:
+ row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() if db else None
+ return dict(row) if row else None
+
+ def heartbeat(self, task_id: str) -> bool:
+ now = time.time()
+ with self.connect(write=True) as db:
+ return bool(db.execute("UPDATE tasks SET lease_until=?,updated=? "
+ "WHERE id=? AND state='running'",
+ (now + LEASE_SECONDS, now, task_id)).rowcount)
+
+ def finish(self, task_id: str, state: str) -> None:
+ if state not in {"completed", "failed", "cancelled", "unknown"}:
+ raise ValueError("Invalid terminal task state")
+ with self.connect(write=True) as db:
+ db.execute("UPDATE tasks SET state=?,next_at=NULL,updated=? WHERE id=?",
+ (state, time.time(), task_id))
+
+ def begin_delivery(self, task_id: str) -> str:
+ delivery = str(uuid4())
+ with self.connect(write=True) as db:
+ db.execute("BEGIN IMMEDIATE")
+ row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
+ if not row or row["state"] != "running" or row["sent"] >= row["count"]:
+ raise ValueError("Task is no longer sending")
+ # Persist before I/O. A crashed/ambiguous attempt at this ordinal
+ # cannot be sent again by a restarted helper.
+ db.execute("INSERT INTO deliveries(id,task_id,ordinal,scheduled_at) VALUES(?,?,?,?)",
+ (delivery, task_id, row["sent"], row["next_at"]))
+ return delivery
+
+ def accepted(self, task_id: str, delivery: str, turn_id: str | None = None) -> None:
+ with self.connect(write=True) as db:
+ db.execute("BEGIN IMMEDIATE")
+ changed = db.execute("UPDATE deliveries SET accepted=1,turn_id=COALESCE(?,turn_id) "
+ "WHERE id=? AND task_id=? AND accepted=0",
+ (turn_id, delivery, task_id)).rowcount
+ if changed:
+ now = time.time()
+ db.execute("UPDATE tasks SET sent=sent+1,"
+ "state=CASE WHEN state='running' AND sent+1>=count THEN 'completed' ELSE state END,"
+ "next_at=CASE WHEN sent+1>=count THEN NULL ELSE MAX(next_at+interval,?+interval) END,"
+ "updated=? WHERE id=?", (now, now, task_id))
+
+ def active(self, *, now: float | None = None) -> dict[tuple[str, str], list[dict]]:
+ """One bounded read for the sidebar; private ownership keys stay local."""
+ now = time.time() if now is None else now
+ with self.connect() as db:
+ rows = db.execute("SELECT * FROM tasks WHERE state='running' AND lease_until>? "
+ "AND sent list[dict]:
+ now = time.time() if now is None else now
+ with self.connect() as db:
+ if db is None:
+ return []
+ rows = db.execute("SELECT * FROM tasks WHERE home=? AND sid=? AND state='running' "
+ "AND lease_until>? AND sent dict | None:
+ ids = list(dict.fromkeys(v for v in ids if isinstance(v, str) and 0 < len(v) <= 512))[:4]
+ if not ids:
+ return None
+ marks = ",".join("?" for _ in ids)
+ with self.connect(write=bind is not None and self.path.exists()) as db:
+ if db is None:
+ return None
+ rows = db.execute(f"SELECT d.*,t.title FROM deliveries d JOIN tasks t ON t.id=d.task_id "
+ f"WHERE t.home=? AND t.sid=? AND (d.id IN ({marks}) "
+ f"OR d.native_id IN ({marks}) OR d.turn_id IN ({marks})) LIMIT 2",
+ (home, sid, *ids, *ids, *ids)).fetchall()
+ if len(rows) != 1:
+ return None
+ row = rows[0]
+ if bind and row["native_id"] is None:
+ db.execute("UPDATE deliveries SET native_id=? WHERE id=?", (bind, row["id"]))
+ return {"task_id": row["task_id"], "title": row["title"],
+ "scheduled_at": row["scheduled_at"]}
+
+
+class NativeQueueError(RuntimeError):
+ pass
+
+
+async def deliver(task: dict, delivery_id: str, on_accepted) -> None:
+ """Queue on the existing account daemon, without resuming or creating a thread."""
+ socket = Path(task["home"]) / "app-server-control/app-server-control.sock"
+ info = socket.lstat()
+ if not stat.S_ISSOCK(info.st_mode) or info.st_uid != os.getuid():
+ raise NativeQueueError("Shared daemon unavailable")
+ async with asyncio.timeout(30), unix_connect(
+ str(socket), uri="ws://localhost/rpc", compression=None,
+ max_size=2**20, open_timeout=5, close_timeout=1,
+ ) as ws:
+ seq = 0
+
+ async def rpc(method, params):
+ nonlocal seq
+ seq += 1
+ await ws.send(json.dumps({"id": seq, "method": method, "params": params}))
+ while True:
+ message = json.loads(await ws.recv())
+ if message.get("id") == seq:
+ if "error" in message:
+ raise NativeQueueError("Native queue request rejected")
+ return message["result"]
+
+ await rpc("initialize", {"clientInfo": {"name": "cc-remote-timed-task", "version": "1"},
+ "capabilities": {"experimentalApi": True}})
+ await ws.send(json.dumps({"method": "initialized"}))
+ await rpc("thread/read", {"threadId": task["sid"], "includeTurns": False})
+ reply = await rpc("thread/queue/add", {"threadId": task["sid"],
+ "input": [{"type": "text", "text": task["prompt"], "text_elements": []}],
+ "clientUserMessageId": delivery_id})
+ queued = reply["queuedSubmission"]
+ if queued.get("clientUserMessageId") != delivery_id:
+ raise NativeQueueError("Unconfirmed scheduled message identity")
+ on_accepted()
+ # Queue acceptance is durable. An idle thread can start now; otherwise
+ # the official queue waits for its native current-turn boundary.
+ try:
+ state = await rpc("thread/read", {"threadId": task["sid"], "includeTurns": False})
+ if state["thread"]["status"]["type"] == "idle":
+ await rpc("thread/queue/start", {
+ "threadId": task["sid"], "queuedSubmissionId": queued["id"]})
+ return
+ except Exception:
+ # Another client may already have consumed this exact queue entry.
+ # Do not add it again or substitute an ordinary turn/start.
+ pass
+ return None
+
+
+async def run_task(store: TimedTaskStore, task_id: str) -> None:
+ UUID(task_id)
+ # One helper owns a task. The persistent delivery ordinal additionally
+ # fences retries after process death or an uncertain socket response.
+ lock_path = store.path.parent / f"timed-task-{task_id}.lock"
+ fd = os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
+ try:
+ try:
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except BlockingIOError:
+ return
+ await _run_task(store, task_id)
+ finally:
+ os.close(fd)
+
+
+async def _run_task(store: TimedTaskStore, task_id: str) -> None:
+ while task := store.get(task_id):
+ if task["state"] != "running":
+ return
+ if task["sent"] >= task["count"]:
+ store.finish(task_id, "completed")
+ return
+ if not store.heartbeat(task_id):
+ return
+ delay = task["next_at"] - time.time()
+ if delay > 0:
+ await asyncio.sleep(min(delay, 20))
+ continue
+ try:
+ delivery_id = store.begin_delivery(task_id)
+ await deliver(task, delivery_id, lambda: store.accepted(task_id, delivery_id))
+ except Exception:
+ current = store.get(task_id)
+ if current and current["state"] == "running" and current["sent"] == task["sent"]:
+ store.finish(task_id, "unknown")
+ return
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Scheduled messages to an existing Codex session")
+ parser.add_argument("--state-dir", type=Path, default=Path.home() / ".cc-remote")
+ commands = parser.add_subparsers(dest="command", required=True)
+ start = commands.add_parser("start")
+ start.add_argument("--codex-home", type=Path, required=True)
+ start.add_argument("--thread", required=True)
+ start.add_argument("--title", default="定时任务")
+ start.add_argument("--message", required=True)
+ start.add_argument("--after", type=float, required=True, help="Seconds until the first message")
+ start.add_argument("--every", type=float, default=60, help="Seconds between messages")
+ start.add_argument("--count", type=int, default=1)
+ for name in ("run", "cancel", "status"):
+ commands.add_parser(name).add_argument("task_id")
+ args = parser.parse_args()
+ store = TimedTaskStore(args.state_dir.expanduser().resolve())
+ if args.command == "start":
+ task_id = store.create(str(args.codex_home), args.thread, args.title, args.message,
+ args.after, args.every, args.count)
+ try:
+ subprocess.Popen([sys.executable, "-m", "cc_remote.timed_tasks", "--state-dir",
+ str(store.path.parent), "run", task_id],
+ cwd=Path(__file__).resolve().parent.parent,
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, start_new_session=True)
+ except OSError:
+ store.finish(task_id, "failed")
+ raise
+ print(json.dumps({"task_id": task_id, "next_message_at": store.get(task_id)["next_at"]}))
+ elif args.command == "run":
+ asyncio.run(run_task(store, args.task_id))
+ elif args.command == "cancel":
+ store.finish(args.task_id, "cancelled")
+ else:
+ task = store.get(args.task_id)
+ print(json.dumps({k: v for k, v in (task or {}).items() if k not in {"home", "prompt", "sid"}}))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/cc_remote/tui_state.py b/cc_remote/tui_state.py
index b1e4ef6b..1b24270c 100644
--- a/cc_remote/tui_state.py
+++ b/cc_remote/tui_state.py
@@ -845,7 +845,7 @@ def event(self, event: dict) -> None:
seq,
data=data,
),
- append=True,
+ append=not (kind == "delta" and event.get("replace")),
)
elif kind == "tool_use":
inputs = bounded(event.get("input") or {})
diff --git a/cc_remote/workspaces.py b/cc_remote/workspaces.py
index 31b5fa10..69c850a4 100644
--- a/cc_remote/workspaces.py
+++ b/cc_remote/workspaces.py
@@ -36,14 +36,14 @@
_WORK_ARTIFACT_PREVIEW_SUFFIXES = frozenset({
".c", ".cc", ".conf", ".cpp", ".css", ".csv", ".go", ".h", ".hpp",
".htm", ".html", ".ini", ".java", ".js", ".json", ".jsonl", ".log", ".md",
- ".mdown", ".markdown", ".mjs", ".py", ".rs", ".sh", ".sql", ".svg",
+ ".mdown", ".markdown", ".mmd", ".mermaid", ".mjs", ".py", ".rs", ".sh", ".sql", ".svg",
".toml", ".ts", ".tsx", ".txt", ".xml", ".yaml", ".yml",
".avif", ".doc", ".docx", ".gif", ".jpeg", ".jpg", ".odp", ".ods",
".odt", ".pdf", ".png", ".ppt", ".pptx", ".rtf", ".webp", ".xls",
".xlsx",
})
_WORK_ARTIFACT_KIND_SUFFIXES = {
- "document": frozenset({".doc", ".docx", ".md", ".odt", ".rtf", ".txt"}),
+ "document": frozenset({".doc", ".docx", ".md", ".mmd", ".mermaid", ".odt", ".rtf", ".txt"}),
"spreadsheet": frozenset({".csv", ".ods", ".xls", ".xlsx"}),
"presentation": frozenset({".odp", ".ppt", ".pptx"}),
"image": frozenset({".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"}),
diff --git a/cc_remote/wrapper/claude_agents.py b/cc_remote/wrapper/claude_agents.py
index 66e3f256..e053c86c 100644
--- a/cc_remote/wrapper/claude_agents.py
+++ b/cc_remote/wrapper/claude_agents.py
@@ -11,6 +11,7 @@
import json
import os
import re
+import time
from dataclasses import dataclass, field, replace
from types import SimpleNamespace
from typing import Any
@@ -116,6 +117,8 @@ class AgentRunProjection:
revision_epoch: int = 0
subscribers: dict[str, None] = field(default_factory=dict)
owned_tool_ids: set[str] = field(default_factory=set)
+ pending_stream_events: list[dict[str, Any]] = field(default_factory=list)
+ last_stream_flush: float | None = None
def ensure_translator(self, tool_result_max: int) -> StreamTranslator:
if self.translator is None:
@@ -171,6 +174,7 @@ def __init__(self, tool_result_max: int):
self.run_by_tool: dict[str, str] = {}
self.run_by_agent: dict[str, str] = {}
self.tool_owner: dict[str, str] = {}
+ self.task_owner: dict[str, str] = {}
def _ensure_run(
self,
@@ -289,11 +293,6 @@ def _without_direct_parent(message: object) -> object:
def _record_detail_events(
self, run: AgentRunProjection, message: object,
) -> AgentRoute:
- # Partial deltas are intentionally not retained here. The assembled
- # AssistantMessage follows with the exact same content and prevents a
- # token-rate AgentDetail broadcast from flooding the relay.
- if isinstance(message, StreamEvent):
- return AgentRoute("detail", run.run_id)
translator = run.ensure_translator(self.tool_result_max)
translated = translator.feed(self._without_direct_parent(message))
public_events = []
@@ -311,6 +310,31 @@ def _record_detail_events(
parent_run_id=run.run_id,
)
public_events.append(event.model_dump(mode="json"))
+ # Show child output before its complete AssistantMessage arrives.
+ # Coalesce the burst rather than broadcasting an empty status packet
+ # for every native token. Assembled/tool messages flush pending text;
+ # StreamTranslator already deduplicates their repeated content.
+ for event in public_events:
+ previous = run.pending_stream_events[-1] if run.pending_stream_events else None
+ if (previous and event["type"] == previous["type"] == "delta"
+ and event.get("message_id") == previous.get("message_id")
+ and event.get("channel") == previous.get("channel")
+ and len(previous["text"]) + len(event["text"]) <= 32 * 1024):
+ previous["text"] += event["text"]
+ else:
+ run.pending_stream_events.append(event)
+ now = time.monotonic()
+ if (isinstance(message, StreamEvent)
+ and run.last_stream_flush is not None
+ and now - run.last_stream_flush < 0.1
+ and sum(len(event.get("text", ""))
+ for event in run.pending_stream_events) < 32 * 1024):
+ return AgentRoute("detail", run.run_id)
+ public_events = run.pending_stream_events
+ run.pending_stream_events = []
+ if not public_events:
+ return AgentRoute("detail", run.run_id)
+ run.last_stream_flush = now
seq = run.append(public_events)
return AgentRoute(
"detail", run.run_id, tuple(public_events), seq, (run.run_id,))
@@ -354,6 +378,21 @@ def route(self, message: object) -> AgentRoute:
)):
run = self._run_for_task(message)
if run is None:
+ # Child Bash tasks are forwarded as top-level SDK system
+ # messages, without parent_tool_use_id. Their tool identity
+ # still belongs to the child. Keep later task-id-only updates
+ # there too; they cannot reserve a response in the main turn.
+ tool_id = _tool_id_from_data(message)
+ task_id = getattr(message, "task_id", None)
+ owner = self.runs.get(
+ self.tool_owner.get(tool_id or "", "")
+ or self.task_owner.get(task_id, ""))
+ if owner is not None:
+ if isinstance(task_id, str) and task_id:
+ self.task_owner[task_id] = owner.run_id
+ while len(self.task_owner) > _MAX_AGENT_TOOL_OWNERS:
+ self.task_owner.pop(next(iter(self.task_owner)))
+ return self._record_detail_events(owner, message)
return AgentRoute("main")
if isinstance(message, TaskStartedMessage):
self._bind_agent(run, message.task_id)
diff --git a/cc_remote/wrapper/claude_compaction.py b/cc_remote/wrapper/claude_compaction.py
new file mode 100644
index 00000000..ee9ce39f
--- /dev/null
+++ b/cc_remote/wrapper/claude_compaction.py
@@ -0,0 +1,42 @@
+"""Normalize native Claude compact metadata from live and persisted events."""
+from __future__ import annotations
+
+from typing import Any
+
+from cc_remote.protocol import MAX_SAFE_WIRE_INTEGER
+
+
+def compact_metadata(row: object) -> dict[str, Any]:
+ """Use the SDK's snake case while accepting the transcript's camel case."""
+ if not isinstance(row, dict):
+ return {}
+ metadata = row.get("compact_metadata", row.get("compactMetadata"))
+ if not isinstance(metadata, dict):
+ return {}
+ result: dict[str, Any] = {}
+ trigger = metadata.get("trigger")
+ if isinstance(trigger, str) and trigger in {"auto", "manual"}:
+ result["trigger"] = trigger
+ for name, alias in (
+ ("pre_tokens", "preTokens"),
+ ("post_tokens", "postTokens"),
+ ("duration_ms", "durationMs"),
+ ):
+ value = metadata.get(name, metadata.get(alias))
+ if (isinstance(value, int) and not isinstance(value, bool)
+ and 0 <= value <= MAX_SAFE_WIRE_INTEGER):
+ result[name] = value
+ return result
+
+
+def compact_context_usage(row: object) -> dict[str, int] | None:
+ """Read a main-session boundary without counting the summarizer's input."""
+ if (not isinstance(row, dict)
+ or row.get("type") != "system"
+ or row.get("subtype") != "compact_boundary"
+ or row.get("isSidechain") is True
+ or row.get("parentToolUseID") is not None
+ or row.get("parent_tool_use_id") is not None):
+ return None
+ post_tokens = compact_metadata(row).get("post_tokens")
+ return {"totalTokens": post_tokens} if post_tokens is not None else None
diff --git a/cc_remote/wrapper/claude_external.py b/cc_remote/wrapper/claude_external.py
index acb15398..6a8f4cfb 100644
--- a/cc_remote/wrapper/claude_external.py
+++ b/cc_remote/wrapper/claude_external.py
@@ -266,12 +266,13 @@ def _is_descendant(
wrapper_pid: int,
parent_by_pid: Mapping[int, int] | None = None,
proc_root: Path | None = None,
+ owned_roots: Collection[int] = (),
) -> bool:
"""Return whether pid belongs to the wrapper's complete SDK process tree."""
seen = {pid}
current = parent_pid
for _ in range(64):
- if current == wrapper_pid:
+ if current == wrapper_pid or current in owned_roots:
return True
if current <= 1 or current in seen:
return False
@@ -336,17 +337,20 @@ def _darwin_claude_session_holders(
config_dirs: Mapping[str, str] | None,
native_session_ids: Mapping[str, str] | None,
default_config_dir: str | None,
+ owner_identities: Collection[ProcessIdentity] = (),
) -> HolderScan:
holders = {sid: set() for sid in paths}
snapshot, complete = darwin_process_snapshot()
if not complete:
return HolderScan(holders, False)
parent_by_pid = {info[0].pid: info[1] for info in snapshot}
+ owned_roots = {info[0].pid for info in snapshot if info[0] in owner_identities}
candidates = [
info for info in snapshot
if _is_claude_cli(info[3])
and not _is_descendant(
- info[0].pid, info[1], wrapper_pid, parent_by_pid=parent_by_pid)
+ info[0].pid, info[1], wrapper_pid, parent_by_pid=parent_by_pid,
+ owned_roots=owned_roots)
]
if len(candidates) > _MAX_DARWIN_CLAUDE_CANDIDATES:
return HolderScan(holders, False)
@@ -457,6 +461,7 @@ def claude_session_holders(
config_dirs: Mapping[str, str] | None = None,
native_session_ids: Mapping[str, str] | None = None,
default_config_dir: str | None = None,
+ owner_identities: Collection[ProcessIdentity] = (),
) -> HolderScan:
"""Return stable external Claude process identities for watched sessions.
@@ -489,7 +494,12 @@ def claude_session_holders(
config_dirs=config_dirs,
native_session_ids=native_session_ids,
default_config_dir=default_config_dir,
+ owner_identities=owner_identities,
)
+ owned_roots = {
+ identity.pid for identity in owner_identities
+ if process_identity(identity.pid, proc_root=proc_root) == identity
+ }
cwd_sids: dict[str, set[str]] = {}
for sid in paths:
cwd = cwds.get(sid)
@@ -522,7 +532,7 @@ def claude_session_holders(
continue
if _is_descendant(
int(proc_dir.name), parent_pid, wrapper_pid,
- proc_root=root):
+ proc_root=root, owned_roots=owned_roots):
continue
identity = ProcessIdentity(int(proc_dir.name), start_ticks)
process_cwd: str | None = None
diff --git a/cc_remote/wrapper/claude_runtime.py b/cc_remote/wrapper/claude_runtime.py
index f16e7fae..5501150f 100644
--- a/cc_remote/wrapper/claude_runtime.py
+++ b/cc_remote/wrapper/claude_runtime.py
@@ -17,7 +17,7 @@
VERIFIED_SDK_VERSION = "0.2.151"
-MINIMUM_CLAUDE_CLI_VERSION = "2.1.258"
+MINIMUM_CLAUDE_CLI_VERSION = "2.1.263"
_CLI_VERSION_TIMEOUT = 3.0
_VERSION_RE = re.compile(
r"(? None:
+ """An offline replay retains when native output arrived, not reconnect time."""
+ ts = getattr(message, "_cc_service_ts", None)
+ if ts is not None:
+ for event in events:
+ event.ts = ts
+
+
+class ReplayProjection:
+ """Coalesce a native backlog before exposing it to an existing browser.
+
+ The translator still consumes every frame to rebuild tool/channel state.
+ Replayed text replaces its exact message's old prefix once; it must not be
+ appended onto the browser's already-rendered copy of that same prefix.
+ """
+
+ def __init__(self, head: int):
+ self.head = head
+ self.events = []
+ self.text: dict[tuple, int] = {}
+
+ def add(self, events) -> None:
+ for event in events:
+ if isinstance(event, Delta):
+ key = (event.message_id, event.channel)
+ index = self.text.get(key)
+ if index is None:
+ self.text[key] = len(self.events)
+ self.events.append(event.model_copy(update={"replace": True}))
+ else:
+ current = self.events[index]
+ current.text = event.text if event.replace else current.text + event.text
+ else:
+ self.events.append(event)
+
+ def drain(self):
+ events, self.events = self.events, []
+ self.text.clear()
+ return events
+
+
+def configure(machine, ctx) -> None:
+ if not machine.cfg.claude_service_socket or ctx.btw:
+ return
+ profile = machine._claude_profile(ctx.claude_profile_id)
+ ctx.sdk.service_metadata = {
+ "profile_id": profile.id,
+ "profile_root": str(profile.config_dir),
+ "session_id": ctx.session_id,
+ "cwd": ctx.cwd,
+ "space": ctx.space,
+ "work_id": ctx.work_id,
+ "btw": False,
+ }
+
+
+async def restore(machine) -> None:
+ if not machine.cfg.claude_service_socket:
+ return
+ sockets = [("primary", machine.cfg.claude_service_socket)]
+ if machine.cfg.claude_service_drain_socket:
+ sockets.insert(0, ("drain", machine.cfg.claude_service_drain_socket))
+ sessions = []
+ identities = set()
+ for role, socket in sockets:
+ try:
+ listed = await _list_sessions(socket)
+ except Exception as exc:
+ log.warning("persistent Claude service listing failed",
+ service_role=role, error_type=type(exc).__name__)
+ continue
+ for item in listed:
+ metadata = item["metadata"]
+ identity = (metadata.get("profile_root"), metadata.get("session_id"),
+ metadata.get("space"), metadata.get("work_id"), metadata.get("btw"))
+ if metadata.get("session_id") and identity in identities:
+ raise RuntimeError("Claude session exists in both SDK services")
+ identities.add(identity)
+ sessions.append((socket, item))
+ for socket, item in sessions:
+ try:
+ await _restore_session(machine, socket, item)
+ except Exception as exc:
+ # All reachable services passed the identity check. A failed session
+ # must not strand other sessions' already accepted turns.
+ log.warning("persistent Claude session recovery failed",
+ service_id=item.get("id"), error_type=type(exc).__name__)
+
+
+async def _list_sessions(socket):
+ connection = Connection(socket)
+ await connection.connect()
+ try:
+ return await connection.call("list")
+ finally:
+ await connection.disconnect()
+
+
+async def _restore_session(machine, socket, item) -> None:
+ metadata = item["metadata"]
+ if metadata.get("btw"):
+ return
+ # Resolve the current registry, then bind its native account root. A
+ # changed profile name must never attach an old account's SDK by UUID.
+ try:
+ profile = machine._claude_profile(metadata["profile_id"])
+ except (ValueError, KeyError):
+ return
+ if Path(profile.config_dir).resolve() != Path(metadata["profile_root"]).resolve():
+ return
+ ctx = await machine._spawn(
+ resume_id=metadata.get("session_id"), cwd=metadata["cwd"],
+ claude_profile_id=profile.id, space=metadata["space"],
+ work_id=metadata.get("work_id"), _service_recovering=True,
+ _service_worker_id=item["id"],
+ _service_socket=socket,
+ )
+ if ctx is None:
+ raise RuntimeError("could not reattach a persistent Claude session")
+
+
+async def activate(machine, ctx) -> None:
+ client = getattr(ctx.sdk, "client", None)
+ if not hasattr(client, "description"):
+ return
+ recovery = ctx.sdk.service_recovery
+ await client.call("metadata", {"value": {"key": ctx.key}})
+ if client.description["head"] > client.description["after"]:
+ ctx.claude_service_background_replay = ReplayProjection(client.description["head"])
+ if recovery is not None:
+ ctx.active_msg_id = recovery["id"]
+ ctx.claude_write_active = True
+ ctx.needs_reload = False
+ await machine._emit(ctx, UserMsg(
+ msg_id=recovery["id"], prompt=recovery.get("prompt", ""),
+ images=recovery.get("images"), files=recovery.get("files"),
+ ts=recovery["started_at"],
+ ))
+ await machine._set_state(ctx, "running")
+ ctx.turn_task = asyncio.create_task(machine._run_turn(
+ ctx, recovery.get("prompt", ""), _recover_service=True,
+ ))
+ ctx.sdk.start_service_events()
+ ctx.sdk.service_defer_events = False
diff --git a/cc_remote/wrapper/claude_steer.py b/cc_remote/wrapper/claude_steer.py
new file mode 100644
index 00000000..2d4588aa
--- /dev/null
+++ b/cc_remote/wrapper/claude_steer.py
@@ -0,0 +1,95 @@
+"""Claude's non-interrupting input and native narrative boundaries."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import shutil
+import tempfile
+from uuid import NAMESPACE_URL, uuid5
+
+from cc_remote.attachments import validate_attachments
+from cc_remote.claude_steering import ClaudeSteerRejected
+from cc_remote.protocol import ERR_NOT_STEERABLE, ERR_STEER_UNKNOWN, Error, TurnSteered
+
+
+async def handle(machine, ctx, cmd, reject):
+ if (ctx.state != "running" or ctx.write_state != "writable"
+ or not ctx.active_msg_id or ctx.translator is None):
+ return await reject(ERR_NOT_STEERABLE, "当前没有可引导的 Claude 任务。")
+ if not cmd.prompt and not cmd.images and not cmd.files:
+ return await reject(ERR_NOT_STEERABLE, "消息内容为空。")
+ if validate_attachments(cmd.images, cmd.files):
+ return await reject(ERR_NOT_STEERABLE, "附件不符合要求,请调整后重试。")
+ directory = None
+ attempted = False
+ native_id = str(uuid5(NAMESPACE_URL, json.dumps(
+ [cmd.sid, cmd.client_id, cmd.cmd_id])))
+ try:
+ prompt = cmd.prompt
+ if cmd.files:
+ directory = tempfile.mkdtemp(prefix="cc-remote-steer-")
+ os.chmod(directory, 0o700)
+ prompt = machine._stash_files(prompt, cmd.files, directory, ctx.engine)
+ if cmd.images:
+ prompt = ([{"type": "text", "text": prompt}] if prompt else []) + [
+ {"type": "image", "source": {"type": "base64", **image}}
+ for image in cmd.images
+ ]
+ metadata = {"id": cmd.msg_id, "prompt": cmd.prompt, "images": cmd.images,
+ "files": ([{"filename": f["filename"]} for f in cmd.files]
+ if cmd.files else None),
+ "attachment_dir": directory,
+ "fingerprint": hashlib.sha256(json.dumps(
+ [cmd.msg_id, cmd.prompt, cmd.images, cmd.files],
+ sort_keys=True).encode()).hexdigest()}
+ async with ctx.steer_lock:
+ if ctx.state != "running" or ctx.write_state != "writable":
+ raise ClaudeSteerRejected("Claude is no longer running")
+ attempted = True
+ if directory:
+ ctx.claude_steer_attachment_dirs.append(directory)
+ await ctx.sdk.steer(prompt, native_id=native_id, metadata=metadata)
+ # The command ACK transfers ownership. The replayed native UserMessage
+ # publishes TurnSteered later, after the preceding tool/text finishes.
+ except ClaudeSteerRejected:
+ attempted = False
+ return await reject(ERR_NOT_STEERABLE,
+ "Claude 当前无法接收引导,本次未发送;请稍后重试或排队。")
+ except Exception:
+ if not attempted:
+ return await reject(ERR_NOT_STEERABLE, "本次引导未发送,请稍后重试。")
+ return await reject(ERR_STEER_UNKNOWN,
+ "Claude 尚未确认本次引导,请等待后续输出,避免重复发送。")
+ finally:
+ if directory and not attempted:
+ if directory in ctx.claude_steer_attachment_dirs:
+ ctx.claude_steer_attachment_dirs.remove(directory)
+ shutil.rmtree(directory, ignore_errors=True)
+
+
+async def apply_echo(machine, ctx, message, native_id):
+ cancelled = getattr(message, "_cc_steer_cancelled", None)
+ metadata = cancelled or getattr(message, "_cc_steer", None)
+ directory = metadata.get("attachment_dir") if metadata else None
+ if directory and directory not in ctx.claude_steer_attachment_dirs:
+ ctx.claude_steer_attachment_dirs.append(directory)
+ if cancelled:
+ return Error(code=ERR_NOT_STEERABLE, msg_id=cancelled["id"],
+ message="本次引导已取消。")
+ if not metadata or not native_id:
+ return None
+ ctx.active_msg_id = metadata["id"]
+ ctx.translator.rebind_turn(ctx.active_msg_id)
+ await machine._remember_claude_client_message_id(
+ ctx, native_id, emit_binding=False)
+ return TurnSteered(msg_id=metadata["id"], turn_id=native_id,
+ prompt=metadata["prompt"], images=metadata.get("images"),
+ files=metadata.get("files"))
+
+
+def cleanup(ctx):
+ for directory in ctx.claude_steer_attachment_dirs:
+ shutil.rmtree(directory, ignore_errors=True)
+ ctx.claude_steer_attachment_dirs.clear()
diff --git a/cc_remote/wrapper/codex_handle.py b/cc_remote/wrapper/codex_handle.py
index ee7b6187..1d2009c8 100644
--- a/cc_remote/wrapper/codex_handle.py
+++ b/cc_remote/wrapper/codex_handle.py
@@ -36,6 +36,7 @@
from cc_remote import __version__
from cc_remote.log import logger
+from cc_remote.wrapper.token_usage import CodexUsageTracker
from cc_remote.protocol import (
MAX_SAFE_WIRE_INTEGER,
MAX_SAFE_WIRE_TIMESTAMP_SECONDS,
@@ -1144,7 +1145,7 @@ async def get(self) -> object:
"model/verification",
})
_TURN_QUEUE_METHODS = frozenset({
- "error", "thread/compacted", *_MODEL_TURN_METHODS,
+ "error", "thread/compacted", "thread/tokenUsage/updated", *_MODEL_TURN_METHODS,
})
@@ -1645,6 +1646,7 @@ def __init__(self, cfg, cwd: Optional[str] = None,
self._http_provider_repair_tasks: set[asyncio.Task] = set()
self._http_provider_repair_stop = asyncio.Event()
self.last_token_usage: Optional[dict] = None
+ self._turn_usage = CodexUsageTracker()
self._context_usage_turn_id: Optional[str] = None
self.context_window: Optional[int] = None
self._rollout_context_recovery_attempted = False
@@ -2044,6 +2046,7 @@ async def _open_process(
self._compaction_continuation_turn_id = None
self._discard_managed_compaction_continuation()
self.last_token_usage = None
+ self._turn_usage = CodexUsageTracker()
self._context_usage_turn_id = None
self.context_window = None
self._rollout_context_recovery_attempted = False
@@ -7117,6 +7120,14 @@ async def _dispatch(self, m: dict, raw_size: Optional[int] = None) -> None:
)
managed_queue_message = self._logicalize_managed_compaction_notification(
m)
+ usage = self._turn_usage.feed(managed_queue_message)
+ if usage is not None:
+ # Keep accounting on the same logical owner across a native
+ # compaction continuation, just like the narrative stream.
+ fields = {"_cc_remote_usage": usage.usage.model_dump()}
+ m = {**m, **fields}
+ managed_queue_message = {**managed_queue_message, **fields}
+ raw_size = self._notification_wire_size(m)
managed_queue_raw_size = (
raw_size
if managed_queue_message is m
diff --git a/cc_remote/wrapper/codex_stream.py b/cc_remote/wrapper/codex_stream.py
index 90eff461..2a1cea44 100644
--- a/cc_remote/wrapper/codex_stream.py
+++ b/cc_remote/wrapper/codex_stream.py
@@ -32,7 +32,7 @@
from cc_remote.protocol import (
AssistantMsgStart, Delta, ToolUse, ToolDelta, ToolResult, AssistantMsgEnd,
AsyncQuestionSpec,
- ProcessEvent, TurnPlan, TurnDiff, TurnEnd, TurnResult, TurnBinding, UserMsg, Error,
+ ProcessEvent, TurnPlan, TurnDiff, TurnEnd, TurnResult, TurnBinding, TurnUsage, UserMsg, Error,
StateEvent, ERR_CC_CRASH,
)
from cc_remote.wrapper.codex_external import (
@@ -2387,7 +2387,13 @@ def feed(
p = msg.get("params") if isinstance(msg.get("params"), dict) else {}
out: list = []
- if method == "item/agentMessage/delta":
+ if method == "thread/tokenUsage/updated":
+ owner = _optional_wire_id(p.get("turnId"), "turn")
+ usage = msg.get("_cc_remote_usage")
+ if owner and isinstance(usage, dict) and not self._turn_closed:
+ out.append(TurnUsage(turn_id=owner, usage=usage))
+
+ elif method == "item/agentMessage/delta":
iid = _live_id(p.get("itemId"), "agent-message")
if not self._admit_live_item(iid, out):
return out
@@ -2925,7 +2931,7 @@ def feed(
self._turn_closed = True
# everything else (raw reasoning, userMessage, mcpServer/startupStatus,
- # thread/status, account/rateLimits, tokenUsage, remoteControl…) -> skip.
+ # thread/status, account/rateLimits, remoteControl…) -> skip.
return out
# ---- helpers ----
diff --git a/cc_remote/wrapper/history_store.py b/cc_remote/wrapper/history_store.py
index 35d4dfdc..63c128f5 100644
--- a/cc_remote/wrapper/history_store.py
+++ b/cc_remote/wrapper/history_store.py
@@ -62,7 +62,12 @@
# v37 restores reviewed policy-refusal copy across both branch histories.
# Rebuild pages which promoted an RPC-accepted steer's presentation clock to
# process presence before the native user segment existed.
-_SCHEMA_VERSION = 38
+# v39 hides local command caveats and preserves the pre-compact answer clock.
+# v40 gives real Claude background replies their source completion time while
+# retaining the old boundary for task bookkeeping without a reply.
+# v41 keeps native isMeta recovery prompts inside their original human turn.
+# v42 replaces recovered text prefixes and bounds summary answer block counts.
+_SCHEMA_VERSION = 42
_FINGERPRINT_SAMPLE_BYTES = 64 * 1024
_DEFAULT_MAX_ENTRIES = 128
_DEFAULT_MAX_BYTES = 64 * 1024 * 1024
@@ -726,10 +731,13 @@ def touch_process(
if message_id not in texts:
texts[message_id] = []
text_order.append(message_id)
- texts[message_id].append(event["text"])
+ if event.get("replace"):
+ texts[message_id] = [event["text"]]
+ else:
+ texts[message_id].append(event["text"])
block = add_live_text(message_id, channels[message_id])
if block is not None:
- block["text"] += event["text"]
+ block["text"] = event["text"] if event.get("replace") else block["text"] + event["text"]
stamp = _event_ms(event.get("ts"))
if stamp is not None:
text_first_ms.setdefault(message_id, stamp)
@@ -1018,10 +1026,14 @@ def touch_process(
and (started_ms is None or started_ms > done_ms)):
started_ms = max(0, done_ms - (duration_ms or 0))
blocks = []
- final_block_count = sum(
- bool("".join(texts.get(message_id, ())))
- for message_id in final_ids
- )
+ # A turn may contain arbitrarily many native answers/questions. The
+ # wire summary is bounded by both characters and block count; full
+ # source events remain available through GetTurnDetail.
+ final_ids = [message_id for message_id in final_ids
+ if any(texts.get(message_id, ()))]
+ summary_truncated = len(final_ids) > _SUMMARY_BLOCK_MAX
+ final_ids = final_ids[-_SUMMARY_BLOCK_MAX:]
+ final_block_count = len(final_ids)
notice_limit = max(0, _SUMMARY_BLOCK_MAX - final_block_count)
notices = list(model_notices.values())[-notice_limit:] if notice_limit else []
image_limit = max(0, _SUMMARY_BLOCK_MAX - final_block_count - len(notices))
@@ -1107,7 +1119,6 @@ def touch_process(
blocks.extend(notices)
blocks.extend(image_summaries)
remaining_summary_chars = _SUMMARY_TEXT_MAX_CHARS
- summary_truncated = False
for message_id in final_ids:
text = "".join(texts.get(message_id, ()))
if text:
@@ -1283,6 +1294,36 @@ def _select_page_row(
def _ensure_schema(self) -> None:
with self._connect() as connection:
current = int(connection.execute("PRAGMA user_version").fetchone()[0])
+ if current in range(10, 42):
+ # v42 bounds answer summaries and handles recovered prefixes.
+ # Full source events, images and native graph indexes remain
+ # valid; only materialized pages require a fresh projection.
+ connection.execute(
+ "DELETE FROM history_pages WHERE engine IN ('claude', 'codex')")
+ if current in range(10, 41):
+ # isMeta changes both turn boundaries and the visible-user
+ # graph without changing native bytes. Rebuild Claude's derived
+ # narrative/index only; retain Codex and source-bound assets.
+ for table in ("history_pages", "history_turn_details"):
+ connection.execute(f"DELETE FROM {table} WHERE engine='claude'")
+ for table in (
+ "claude_compact_sources", "claude_compact_records", "claude_compact_queue",
+ ):
+ connection.execute(f"DROP TABLE IF EXISTS {table}")
+ if current in range(10, 40):
+ # Real background replies had the original answer's timestamp.
+ # Only Claude narrative changes; keep all source-bound assets.
+ for table in ("history_pages", "history_turn_details"):
+ connection.execute(f"DELETE FROM {table} WHERE engine='claude'")
+ if current in range(10, 39):
+ # Native /compact envelopes are not human turns. Rebuild the
+ # Claude narrative and cached visible-user graph together.
+ for table in ("history_pages", "history_turn_details"):
+ connection.execute(f"DELETE FROM {table} WHERE engine='claude'")
+ for table in (
+ "claude_compact_sources", "claude_compact_records", "claude_compact_queue",
+ ):
+ connection.execute(f"DROP TABLE IF EXISTS {table}")
if current in range(10, 38):
# RPC-accepted steers could cache a phantom process. Invalidate
# only derived Codex narrative; retain other engines and assets.
@@ -1407,8 +1448,8 @@ def _ensure_schema(self) -> None:
for table in ("history_pages", "history_turn_details"):
connection.execute(
f"DELETE FROM {table} WHERE engine='codex'")
- elif current in (21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
- # The independent v22-v38 invalidations above suffice.
+ elif current in (21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41):
+ # The independent v22-v42 invalidations above suffice.
pass
elif current not in (0, _SCHEMA_VERSION):
# v9 changes the invariant of history_turn_details: those rows
diff --git a/cc_remote/wrapper/machine.py b/cc_remote/wrapper/machine.py
index 7d23b5b0..e91a2392 100644
--- a/cc_remote/wrapper/machine.py
+++ b/cc_remote/wrapper/machine.py
@@ -37,6 +37,8 @@
"""
from __future__ import annotations
+from cc_remote.wrapper import claude_service
+
import asyncio
import base64
import codecs
@@ -69,6 +71,7 @@
ResultMessage, SystemMessage, TaskNotificationMessage,
TaskProgressMessage, TaskStartedMessage, TaskUpdatedMessage,
RateLimitEvent, TERMINAL_TASK_STATUSES, UserMessage,
+ ToolResultBlock, ServerToolResultBlock,
)
from cc_remote.attachments import (
@@ -101,18 +104,19 @@
)
from cc_remote.audio_preview import AUDIO_PREVIEW_MEDIA_TYPES, validate_audio_preview
from cc_remote.log import logger
+from cc_remote.timed_tasks import TimedTaskStore
from cc_remote.workspaces import (
WORK_UPLOADS_MARKER,
WORK_UPLOADS_MARKER_PAYLOAD,
WorkStores,
)
from cc_remote.protocol import (
+ TimedMessage,
ASK_OPTION_MAX_COUNT, ARTIFACT_PREVIEW_MAX_BYTES, FILE_PREVIEW_MAX_BYTES,
MAX_BACKGROUND_PROCESS_COMMAND_CHARS,
MAX_BACKGROUND_PROCESS_CWD_CHARS,
MAX_BACKGROUND_PROCESS_ITEMS,
MAX_BACKGROUND_PROCESS_SUMMARY_CHARS,
- MIN_AUTO_COMPACT_TOKENS,
MAX_SAFE_WIRE_INTEGER,
MAX_QUERY_QUEUE_BYTES, MAX_QUERY_QUEUE_ITEMS, PREVIEW_ASSET_MAX_BYTES,
Error, Hello, Query, QueryQueueState, QueuedQueryDetail, QueuedQueryInfo,
@@ -221,6 +225,7 @@
CLAUDE_DEFAULT_EFFORT,
CLAUDE_DEFAULT_MODEL,
ClaudeAutonomousFollowupPending,
+ ClaudeServiceReplayRequired,
SdkHandle,
normalize_claude_model_selection,
same_claude_model_selection,
@@ -2012,6 +2017,8 @@ class WrapperMachine:
def __init__(self, cfg: WrapperConfig, transport: WrapperTransport):
self.cfg = cfg
+ self._timed_tasks = TimedTaskStore(cfg.state_dir)
+ self._timed_task_catalog = {}
self.transport = transport
self._command_router = CommandRouter(self)
self.instance_id = uuid4().hex
@@ -5023,6 +5030,7 @@ async def _remember_claude_client_message_id(
*,
expected_msg_id: str | None = None,
expected_generation: int | None = None,
+ emit_binding: bool = True,
) -> bool:
client_message_id = expected_msg_id or ctx.active_msg_id
if (
@@ -5056,10 +5064,11 @@ async def _remember_claude_client_message_id(
error_type=type(exc).__name__,
)
try:
- await self._emit(ctx, TurnBinding(
- msg_id=client_message_id,
- turn_id=native_message_id,
- ))
+ if emit_binding:
+ await self._emit(ctx, TurnBinding(
+ msg_id=client_message_id,
+ turn_id=native_message_id,
+ ))
except Exception as exc:
log.warning(
"Claude turn binding could not be published",
@@ -6281,6 +6290,10 @@ def _configure_claude_sdk_callbacks(
lambda error: self._on_claude_message_pump_failure(ctx, error))
sdk.lifecycle_reset_callback = (
lambda: self._reset_claude_task_lifecycle(ctx))
+ from cc_remote.wrapper import claude_steer
+
+ sdk.native_close_callback = lambda: claude_steer.cleanup(ctx)
+ claude_service.configure(self, ctx)
@staticmethod
def _copy_claude_runtime_options(
@@ -6502,6 +6515,17 @@ def _retire_claude_followup(
return True
return False
+ @staticmethod
+ def _claude_injected_user_boundary(message: object) -> bool:
+ if not isinstance(message, UserMessage) or message.parent_tool_use_id:
+ return False
+ origin = getattr(message, "origin", None)
+ if not isinstance(origin, dict) or origin.get("kind") in (None, "human"):
+ return False
+ return not (isinstance(message.content, list) and any(
+ isinstance(block, (ToolResultBlock, ServerToolResultBlock))
+ for block in message.content))
+
def _observe_claude_task_lifecycle(
self,
ctx: SessionContext,
@@ -6539,16 +6563,7 @@ def _observe_claude_task_lifecycle(
return
if isinstance(message, UserMessage):
- origin = getattr(message, "origin", None)
- origin_kind = (
- origin.get("kind") if isinstance(origin, dict) else None
- )
- if (
- background
- and isinstance(origin_kind, str)
- and origin_kind != "human"
- and not getattr(message, "parent_tool_use_id", None)
- ):
+ if background and self._claude_injected_user_boundary(message):
# The pinned SDK marks every injected turn at its replayed
# top-level user boundary. This is stronger than inferring a
# continuation only from the last active task: channel/peer
@@ -6582,11 +6597,15 @@ def _observe_claude_task_lifecycle(
# injected turn: that owner still requires its exact Result.
if ctx.claude_background_followups.get(key) == "notified":
ctx.claude_background_followups.pop(key, None)
- elif background and isinstance(message, TaskNotificationMessage):
+ elif (background and isinstance(message, TaskNotificationMessage)
+ and not getattr(message, "_cc_service_seed", False)):
# A task_updated terminal is a task status, not evidence of a
# new model response. In particular killed tasks often emit no
# notification and no Result at all. Only a real notification
# can reserve the subsequent injected User/Result boundary.
+ # Reattach seeds describe already-consumed task state, not a
+ # fresh notification. Any unacknowledged continuation follows
+ # separately in the service's ordered User/Result replay.
overflowed = self._claim_claude_followup_notification(
ctx, task_id)
if overflowed:
@@ -6881,14 +6900,14 @@ async def _on_claude_message_pump_failure(
ctx: SessionContext,
error: BaseException,
) -> None:
- """Release an idle/autonomous session whose sole SDK reader stopped."""
+ """Release idle UI claims when native reading or projection stops."""
self._reset_claude_task_lifecycle(ctx)
if not self._is_resident_context(ctx):
return
managed = ctx.turn_task
if managed is not None and not managed.done():
- # The managed reader receives the same failure sentinel and owns its
- # visible Error/idle transition. Waking its queue here is enough.
+ # The managed reader or its service commit raises the same failure
+ # and owns its visible Error/idle transition.
return
log.warning(
"Claude idle message pump failed",
@@ -6900,6 +6919,8 @@ async def _on_claude_message_pump_failure(
ctx.interrupt_event.clear()
if ctx.state != "idle":
await self._set_state(ctx, "idle")
+ if isinstance(error, ClaudeServiceReplayRequired):
+ await self._emit(ctx, Error(code=ERR_CC_CRASH, message=str(error)))
def _schedule_claude_autonomous_interrupt_watchdog(
self, ctx: SessionContext,
@@ -7114,30 +7135,10 @@ def _claude_setting_needs_compaction(
"""Whether applying a smaller custom window first needs /compact."""
total = cls._claude_cached_context_total(ctx)
target = event.threshold_tokens if event.mode == "custom" else None
- if target is None:
- # ``auto`` and ``inherit`` reveal their effective threshold only in
- # the replacement child. A prior custom window may already hold
- # more context than that unknown target. Avoid reconnecting into an
- # oversized first request; only a known context below the smallest
- # selectable threshold can safely skip the boundary.
- return total is None or total >= MIN_AUTO_COMPACT_TOKENS
- if total is not None:
- return total >= target
- current_threshold = (
- event.applied_threshold_tokens
- if event.applied_mode == "custom" else
- getattr(ctx.sdk, "effective_auto_compact_threshold_tokens", None)
- )
- # Unknown usage while reducing a proven larger window is not permission
- # to gamble the next provider request. Compact conservatively once.
- if (
- isinstance(current_threshold, int)
- and not isinstance(current_threshold, bool)
- ):
- return current_threshold >= target
- # ``inherit``/``auto`` without a successful control reading is an
- # unknown upper bound, not proof that the current context fits.
- return event.applied_mode in {"inherit", "auto", None}
+ # Missing usage/capacity is not evidence of overflow. Apply the native
+ # CLI option without issuing a speculative model call; Claude will
+ # enforce its own threshold when the next normal turn starts.
+ return total is not None and target is not None and total >= target
async def _compact_managed_claude_context(
self,
@@ -7179,17 +7180,13 @@ async def _compact_managed_claude_context(
native_sid = extract_session_id(message)
if native_sid and not ctx.session_id:
await self._capture_session_id(ctx, native_sid)
- if (
- isinstance(message, SystemMessage)
- and message.subtype == "compact_boundary"
- ):
- for event in translator.feed(message):
- if (
- isinstance(event, ProcessEvent)
- and event.kind == "compaction"
- ):
+ for event in translator.feed(message):
+ if (isinstance(event, ProcessEvent)
+ and event.kind == "compaction"):
+ if (isinstance(message, SystemMessage)
+ and message.subtype == "compact_boundary"):
compact_event = event
- await self._emit(ctx, event)
+ await self._emit(ctx, event)
if isinstance(message, ResultMessage):
terminal = message
break
@@ -7200,7 +7197,7 @@ async def _compact_managed_claude_context(
release_background()
ctx.claude_write_active = False
if ctx.session_id:
- self._resync_watch(ctx.session_id)
+ self._resync_watch(self._ctx_wire_sid(ctx))
if terminal is None:
raise RuntimeError("Claude compact ended without ResultMessage")
@@ -7216,7 +7213,10 @@ async def _compact_managed_claude_context(
raise RuntimeError(
"Claude reported compact success without compact_boundary")
- self._invalidate_claude_context_usage(ctx)
+ # SdkHandle already replaced the old count at the native boundary.
+ # Do not discard its post-compaction sample again after the result.
+ if not isinstance(ctx.sdk, SdkHandle):
+ self._invalidate_claude_context_usage(ctx)
ctx.claude_compaction_revision += 1
log.info(
"Claude native context compacted",
@@ -7326,8 +7326,8 @@ async def _apply_pending_claude_auto_compact(
except Exception as compact_exc:
ctx.auto_compact_phase = "blocked"
ctx.auto_compact_error = (
- "当前上下文高于目标窗口,但原生压缩未产生有效边界;"
- "仍保留原阈值,未发送新的模型请求。可以 Fork/新建会话后继续。"
+ "尚未确认原生压缩完成,暂时保留原阈值。"
+ "请检查压缩结果后重试。"
)
await self._persist_claude_session_controls(ctx)
event, _ = await self._publish_claude_auto_compact(
@@ -7529,7 +7529,7 @@ async def _persist_claude_session_controls(self, ctx: SessionContext) -> None:
# their engine mutations in either order; each durable write must
# therefore reflect the newest complete SDK state at its own
# serialized boundary, never a snapshot captured while waiting.
- session_id = ctx.session_id
+ session_id = self._ctx_wire_sid(ctx)
if (ctx.engine != "claude" or not session_id or ctx.btw
or getattr(ctx.sdk, "is_claude_broker", False)
or (ctx.key is not None
@@ -7597,7 +7597,7 @@ async def _persist_claude_session_controls(self, ctx: SessionContext) -> None:
# keeps the user's desired value until compact + reconnect make
# it safe to publish here as applied.
await self._claude_broker.set_preferences(
- session_id,
+ ctx.session_id,
model=model,
effort=effort,
permission_mode=permission_mode,
@@ -8818,6 +8818,18 @@ async def _cleanup_private_btw_sessions(self) -> None:
for session_id, entry in list(self._private_btw_sessions.items()):
await self._delete_private_btw(session_id, entry["cwd"])
+ async def _delete_claude_btw_transcripts(
+ self, ctx: SessionContext, *, forget: bool,
+ ) -> None:
+ if ctx.engine != "claude":
+ return
+ for sid in dict.fromkeys((ctx.btw_real_id, ctx.btw_reserved_id)):
+ if sid:
+ await self._delete_private_btw(
+ sid, ctx.cwd, forget=forget,
+ claude_profile_id=ctx.claude_profile_id,
+ )
+
# ---- lifecycle ----
async def prepare_codex_daemons(self) -> None:
@@ -8934,6 +8946,11 @@ async def run(self) -> None:
# first; recovery may temporarily exceed the normal resident cap
# because those native turns are already consuming daemon capacity.
await self._restore_codex_owned_turns()
+ try:
+ await claude_service.restore(self)
+ except Exception as exc:
+ log.warning("Claude service recovery unavailable; no private SDK fallback",
+ error_type=type(exc).__name__)
ctx = (
self._ctx_by_sid(bootstrap_wire_sid)
if bootstrap_wire_sid else None
@@ -9161,19 +9178,19 @@ async def run(self) -> None:
for c in list(self.sessions.values()):
disconnected = False
try:
- await c.sdk.disconnect()
+ detach = getattr(c.sdk, "detach_for_shutdown", None)
+ if detach is not None:
+ await detach()
+ else:
+ await c.sdk.disconnect()
disconnected = True
except Exception:
pass
finally:
await self._cleanup_codex_steer_attachments(c)
- if c.btw and c.engine != "codex" and c.btw_real_id:
- await self._delete_private_btw(
- c.btw_real_id,
- c.cwd,
- forget=disconnected,
- claude_profile_id=c.claude_profile_id,
- )
+ if c.btw:
+ await self._delete_claude_btw_transcripts(
+ c, forget=disconnected)
terminal_tasks = list(self._codex_terminal_persist_tasks)
# Stop every producer first, then give the remaining small fsyncs a
# chance to finish. Draining earlier could miss a terminal emitted
@@ -10222,6 +10239,18 @@ async def _emit_locked(self, ctx: SessionContext, msg) -> None:
# treats an absent ``to`` as broadcast, so fail closed for an impossible
# ownerless fork rather than leaking its contents.
msg.sid = self._ctx_wire_sid(ctx) or ctx.key
+ if isinstance(msg, Snapshot):
+ msg.turn_usage = ctx.buffer.latest_turn_usage()
+ if isinstance(msg, UserMsg) and ctx.engine == "codex" and not ctx.btw:
+ try:
+ profile, native_sid = self._codex_target(msg.sid)
+ source = await asyncio.to_thread(
+ self._timed_tasks.source, str(profile.home), native_sid,
+ [msg.msg_id, msg.client_msg_id], bind=msg.msg_id)
+ if source:
+ msg.timed_task = TimedMessage(**source)
+ except Exception:
+ log.warning("Timed message receipt unavailable", sid=msg.sid)
if ctx.btw:
if not ctx.owner_client_id:
log.error("dropping frame for ownerless btw", sid=ctx.key,
@@ -11516,6 +11545,7 @@ def _refresh_cached_response(self, response):
if ctx is not None:
replay.state = ctx.buffer.latest_state() or ctx.state
replay.tail_text = ctx.buffer.latest_tail_text()
+ replay.turn_usage = ctx.buffer.latest_turn_usage()
replay.cc_session_id = self._ctx_wire_sid(ctx)
replay.cwd = ctx.cwd
replay.generation = self.instance_id
@@ -11991,6 +12021,7 @@ async def _handle_client_hello(self, cmd) -> None:
cc_session_id=sid,
state=st,
tail_text=tail,
+ turn_usage=ctx.buffer.latest_turn_usage(),
cwd=ctx.cwd,
generation=self.instance_id,
control=self._session_control(ctx),
@@ -12892,6 +12923,14 @@ async def _probe_claude_holders(
"default_config_dir": str(
(Path.home() / ".claude").resolve(strict=False)),
}
+ service_owners = {
+ identity for ctx in self.sessions.values()
+ if ctx.engine == "claude"
+ and (identity := getattr(
+ getattr(ctx.sdk, "client", None), "owner_identity", None)) is not None
+ }
+ if service_owners:
+ profile_kwargs["owner_identities"] = service_owners
scan = await asyncio.to_thread(
claude_session_holders,
paths,
@@ -13859,6 +13898,7 @@ async def _watch_loop(self) -> None:
and ctx.session_id not in self._watch):
self._watch_session(ctx.session_id)
await self._poll_watches_once()
+ await self._refresh_timed_tasks()
except asyncio.CancelledError:
raise
except Exception:
@@ -13900,6 +13940,53 @@ async def _build_history(
self, sid: str, before=None, limit=None, cwd_hint=None,
detail: str = "full",
*, allow_stale: bool = False,
+ ) -> History:
+ history = await self._build_history_source(
+ sid, before, limit, cwd_hint, detail, allow_stale=allow_stale)
+ return await self._annotate_timed_history(sid, history)
+
+ async def _annotate_timed_history(self, sid: str, history: History) -> History:
+ """Overlay local receipts regardless of the native history provider."""
+ ctx = self._ctx_by_sid(sid)
+ if ctx is not None and ctx.engine != "codex":
+ return history
+ try:
+ profile, native_sid = self._codex_target(sid)
+ def annotate():
+ for turn in history.turns:
+ source = self._timed_tasks.source(
+ str(profile.home), native_sid, [turn.id, turn.clientMsgId])
+ if source:
+ turn.timedTask = TimedMessage(**source)
+ for event in history.events:
+ if event.get("type") != "user_msg":
+ continue
+ source = self._timed_tasks.source(
+ str(profile.home), native_sid,
+ [event.get("msg_id"), event.get("client_msg_id")])
+ if source:
+ event["timed_task"] = source
+ await asyncio.to_thread(annotate)
+ except Exception:
+ log.warning("Timed history receipts unavailable", sid=sid)
+ return history
+
+ async def _refresh_timed_tasks(self) -> dict:
+ try:
+ current = await asyncio.to_thread(self._timed_tasks.active)
+ except Exception:
+ # Receipt I/O must not disrupt ordinary transcript mirroring.
+ return self._timed_task_catalog
+ if current != self._timed_task_catalog:
+ self._timed_task_catalog = current
+ await self._invalidate_session_list("codex", "code")
+ await self._invalidate_session_list("codex", "work")
+ return current
+
+ async def _build_history_source(
+ self, sid: str, before=None, limit=None, cwd_hint=None,
+ detail: str = "full",
+ *, allow_stale: bool = False,
) -> History:
"""Read a session's transcript and assemble ONE History frame. Shared by
GetHistory (routed to the requester) and the watcher (broadcast on external
@@ -15932,6 +16019,7 @@ async def with_terminal_snapshot(history: History) -> History:
try:
history = await self._build_official_codex_history(
sid, before=before, limit=limit)
+ history = await self._annotate_timed_history(sid, history)
if (
not _background and before is None
and history.authoritative is False
@@ -17864,7 +17952,7 @@ def settle_unstarted_launch(_task: asyncio.Task) -> None:
turn_task.add_done_callback(settle_unstarted_launch)
async def _handle_steer(self, cmd):
- """Append one user instruction to the exact active Codex turn.
+ """Append one user instruction to the exact active engine turn.
Steering is neither a new engine turn nor an interrupt. The successful
narrative echo is replayable so every browser splits the visible task at
@@ -17883,7 +17971,8 @@ async def reject(code: str, message: str) -> Error:
sid=self._ctx_wire_sid(ctx) if ctx is not None else sid,
)
log.info(
- "Codex steer not accepted",
+ "steer not accepted",
+ engine=ctx.engine if ctx else None,
session_id=error.sid,
msg_id=error.msg_id,
error_code=code,
@@ -17898,11 +17987,10 @@ async def reject(code: str, message: str) -> Error:
if ctx is None:
return await reject(
ERR_NOT_STEERABLE, "该会话未启动,无法引导当前任务")
- if ctx.engine != "codex":
- return await reject(
- ERR_NOT_STEERABLE,
- "Claude 当前不支持无打断引导;请使用打断并发送或排队。",
- )
+ if ctx.engine == "claude":
+ from cc_remote.wrapper import claude_steer
+
+ return await claude_steer.handle(self, ctx, cmd, reject)
if ctx.state != "running":
return await reject(
ERR_NOT_STEERABLE, "当前没有可引导的 Codex 任务")
@@ -19897,13 +19985,8 @@ async def _handle_close_btw(self, cmd):
# Codex forks are ephemeral (no rollout). Claude fork_session persists a
# transcript under btw_real_id; keep its tombstone on deletion failure so
# it stays hidden and cannot be cold-resumed.
- if ctx.engine != "codex" and ctx.btw_real_id:
- await self._delete_private_btw(
- ctx.btw_real_id,
- ctx.cwd,
- forget=disconnected,
- claude_profile_id=ctx.claude_profile_id,
- )
+ await self._delete_claude_btw_transcripts(
+ ctx, forget=disconnected)
log.info("btw closed", btw_sid=sid)
return close_event
@@ -20551,7 +20634,41 @@ async def _handle_get_context(self, cmd):
async with ctx.query_lock:
if not self._is_resident_context(ctx):
return await self._missing_session_error(cmd, "读取上下文")
- return await self._handle_get_context_locked(ctx, cmd)
+ sdk = ctx.sdk
+ live_summary = bool(
+ ctx.engine == "claude" and getattr(cmd, "refresh", False)
+ and self._claude_context_work_active(ctx)
+ and callable(getattr(sdk, "get_context_summary", None))
+ and not getattr(sdk, "is_claude_broker", False)
+ and not getattr(sdk, "control_plane_failed", False)
+ and not getattr(sdk, "context_probe_suppressed", False)
+ and ctx.write_state == "writable" and not ctx.needs_reload
+ )
+ if not live_summary:
+ return await self._handle_get_context_locked(ctx, cmd)
+ client = getattr(sdk, "client", None)
+ revision = getattr(sdk, "context_revision", None)
+
+ # This capability is the regular SDK's bounded, local-only summary.
+ # Release query_lock before the RPC: the stream's terminal/background
+ # callbacks may need it while the control reply travels through the sole
+ # SDK reader. Never adopt, resume, or repair an engine from this path.
+ summary = None
+ try:
+ summary = await sdk.get_context_summary()
+ if not _has_claude_context_total(summary):
+ summary = None
+ except Exception as exc:
+ log.warning("live Claude context summary unavailable",
+ error_type=type(exc).__name__, session_id=ctx.session_id)
+ async with ctx.query_lock:
+ if not self._is_resident_context(ctx):
+ return await self._missing_session_error(cmd, "读取上下文")
+ if (ctx.sdk is not sdk or getattr(sdk, "client", None) is not client
+ or getattr(sdk, "context_revision", None) != revision):
+ summary = None
+ return await self._handle_get_context_locked(
+ ctx, cmd, prefer_cached_claude=True, claude_summary=summary)
@staticmethod
def _claude_context_model(
@@ -20751,6 +20868,9 @@ async def reconnect_if_still_quiescent() -> bool:
reason=reason,
fork=fork,
)
+ # One recovery is enough. A normal ResultMessage re-enables
+ # inspection; reopening the popover must not restart again.
+ expected_sdk.context_probe_suppressed = True
except Exception as reconnect_exc:
log.warning(
"Claude context timeout recovery failed",
@@ -20802,6 +20922,7 @@ async def _handle_get_context_locked(
cmd,
*,
prefer_cached_claude: bool = False,
+ claude_summary: dict | None = None,
):
if ctx.engine == "codex":
await self._publish_codex_context(ctx)
@@ -20818,8 +20939,7 @@ async def _handle_get_context_locked(
recent_reader = getattr(
ctx.sdk, "cached_recent_context_usage", None)
recent = recent_reader() if callable(recent_reader) else None
- if (not _has_claude_context_total(recent)
- or recent.get("totalTokens") == 0):
+ if not _has_claude_context_total(recent):
recent = None
# A successful generation probe is newer than the transcript
# it resumed. Read disk only when no exact/live cache exists.
@@ -20843,6 +20963,7 @@ async def _handle_get_context_locked(
refresh = bool(
getattr(cmd, "refresh", False)
and not prefer_cached_claude
+ and not getattr(ctx.sdk, "context_probe_suppressed", False)
)
refresh_readiness: Literal[
"ready", "busy", "external", "stale", "changed"
@@ -20945,26 +21066,28 @@ async def _handle_get_context_locked(
)
)
work_claimed = self._claude_context_work_active(ctx)
- error = Error(
- code=ERR_BUSY if work_claimed else ERR_INTERNAL,
- message=(
- "Claude 已开始处理后台任务结果,将在空闲后重试。"
- if work_claimed else
- "上下文读取超时,Claude 会话已安全恢复,请重试。"
- if poisoned and recovered else
- "上下文读取暂不可用,请稍后重试。"
- ),
- request_id=getattr(cmd, "cmd_id", None),
- to=getattr(cmd, "client_id", None),
- )
- await self._emit(ctx, error)
- return error
+ if work_claimed or poisoned and not recovered:
+ error = Error(
+ code=ERR_BUSY if work_claimed else ERR_INTERNAL,
+ message="上下文读取暂不可用,请稍后重试。",
+ request_id=getattr(cmd, "cmd_id", None),
+ to=getattr(cmd, "client_id", None),
+ )
+ await self._emit(ctx, error)
+ return error
+ # A failed metadata read does not invalidate a valid
+ # sample. Publish the cached reading below, keeping
+ # its source and without surfacing a global error.
else:
context_source = "control"
exact = refreshed_usage
usage = refreshed_usage
recent = None
+ if claude_summary is not None and recent is None:
+ usage = claude_summary
+ context_source = "control"
+
if context_source != "control":
if recent is not None:
usage = dict(recent)
@@ -21445,20 +21568,6 @@ async def _on_claude_background_message(
await self._observe_claude_model_fallback(ctx, message)
is_result = isinstance(message, ResultMessage)
followup_was_pending = self._claude_autonomous_followup_pending(ctx)
- self._observe_claude_task_lifecycle(
- ctx, message, background=True)
- followup_is_pending = self._claude_autonomous_followup_pending(ctx)
- if not is_result and followup_is_pending and not followup_was_pending:
- # Parent Result may already have published idle, or may still be in
- # its finalizer. Either way the autonomous continuation is now the
- # real running owner and Stop must remain available.
- if ctx.state == "idle":
- await self._set_state(ctx, "running")
- elif ctx.state in {"interrupting", "draining"}:
- # Stop was accepted against the parent just before the task
- # notification exposed its autonomous continuation. Extend the
- # same absolute drain deadline to that newly-visible owner.
- self._schedule_claude_autonomous_interrupt_watchdog(ctx)
try:
thread_id = self._ctx_wire_sid(ctx)
route = AgentRoute("main")
@@ -21475,8 +21584,23 @@ async def _on_claude_background_message(
await self._publish_claude_agent_route(ctx, route)
if route.target != "detail":
+ self._observe_claude_task_lifecycle(
+ ctx, message, background=True)
+ followup_is_pending = self._claude_autonomous_followup_pending(ctx)
+ if not is_result and followup_is_pending and not followup_was_pending:
+ # Only a main-session continuation owns its running
+ # state. A child's background command has its own
+ # notification/Result consumer inside that Agent.
+ if ctx.state == "idle":
+ await self._set_state(ctx, "running")
+ elif ctx.state in {"interrupting", "draining"}:
+ self._schedule_claude_autonomous_interrupt_watchdog(ctx)
translator = ctx.claude_background_translator
- if translator is None:
+ if translator is None or self._claude_injected_user_boundary(message):
+ # Idle system updates can create a translator before
+ # any user turn exists. Rebind at the native injected
+ # boundary so the answer extends its actual owner,
+ # never a prompt-less row with a stale/empty identity.
translator = StreamTranslator(
self.cfg.tool_result_max,
turn_id=turn_id,
@@ -21486,7 +21610,18 @@ async def _on_claude_background_message(
item_commands=ctx.claude_item_commands,
)
ctx.claude_background_translator = translator
- for event in translator.feed(message):
+ events = translator.feed(message)
+ claude_service.preserve_timestamp(events, message)
+ projection = ctx.claude_service_background_replay
+ if projection is not None:
+ projection.add(events)
+ if (getattr(message, "_cc_service_seq", 0) >= projection.head
+ or is_result):
+ events = projection.drain()
+ ctx.claude_service_background_replay = None
+ else:
+ events = []
+ for event in events:
if isinstance(event, TurnEnd):
# Persist the real autonomous boundary without
# inventing another visible main-turn completion.
@@ -21549,6 +21684,8 @@ async def _publish_claude_agent_route(
self, ctx: SessionContext, route: AgentRoute,
) -> None:
"""Send unbuffered Agent detail hints only to interested browsers."""
+ if not route.events and not route.touched_run_ids:
+ return
registry = ctx.claude_agents
sid = self._ctx_wire_sid(ctx)
if registry is None or not sid:
@@ -24352,6 +24489,22 @@ async def _on_ask_locked(
to: str | None = None,
) -> str | list[str]:
"""Run one question while the caller owns ``ctx.ask_lock``."""
+ service_client = getattr(ctx.sdk, "client", None)
+ if ask_id is None and hasattr(service_client, "description"):
+ from cc_remote.claude_service.client import callback_identity
+
+ origin = callback_identity.get()
+ try:
+ origin = f"mcp-{ctx.sdk.ask_server.request_context.request_id}"
+ except (AttributeError, LookupError):
+ pass
+ if origin is not None:
+ stable = json.dumps([service_client.id, origin, question, options, header],
+ ensure_ascii=False, sort_keys=True)
+ ask_id = "ask-" + hashlib.sha256(stable.encode()).hexdigest()[:32]
+ cached = await service_client.call("question_answer", {"ask_id": ask_id})
+ if cached["found"]:
+ return cached["answer"]
# ask_id is an identity, not a downstream sequence. Consuming next_seq
# here would leave an invisible hole before _emit assigns AskUser.seq;
# reconnect replay would then appear to have lost a frame.
@@ -24777,6 +24930,15 @@ async def reject_locked(code: str, message: str):
return await reject_locked(
ERR_BAD_PROMPT, "回答不属于该问题的可选项")
+ service_client = getattr(ctx.sdk, "client", None)
+ if hasattr(service_client, "description"):
+ try:
+ await service_client.call("remember_answer", {
+ "ask_id": cmd.ask_id, "answer": normalized,
+ }, request_id="question-answer-" + cmd.ask_id, timeout=5)
+ except (ConnectionError, RuntimeError, TimeoutError):
+ return await reject_locked(ERR_INTERNAL, "回答尚未送达会话,请稍后重试。")
+
await self._close_pending_ask_locked(
ctx,
cmd.ask_id,
@@ -25489,12 +25651,12 @@ async def _handle_list_sessions(self, cmd) -> None:
)
await self.transport.send(error)
return error
- # Claude may create the fork transcript before its init/session id reaches
- # our turn consumer. Until capture durably tombstones that real id, scanning
- # the global session store could publish it to another client. Fail closed
- # for this one requester; never broadcast a control-plane privacy error.
+ # New Claude BTW forks reserve a durably hidden native id before startup.
+ # Only a legacy/unidentified fork needs this guard: waiting for the first
+ # user message on a correctly reserved empty fork must not block catalogs.
if any(
ctx.btw and ctx.engine != "codex" and not ctx.btw_real_id
+ and not ctx.btw_reserved_id
for ctx in self.sessions.values()
):
client_id = getattr(cmd, "client_id", None)
@@ -26445,41 +26607,11 @@ async def read(profile: CodexProfile):
item["summary"] = "派生会话"
normalized.append(item)
- materialized_native_ids = {
- row.get("native_session_id")
- for row in normalized
- if isinstance(row.get("native_session_id"), str)
- }
- # ``thread/started`` is an account-scoped, source-filtered native
- # event. During the very small state-DB commit gap it is sufficient
- # to keep a fresh CLI row discoverable; older hints remain gated by
- # an exact DB hit so an externally deleted thread cannot live forever.
- for native_sid, candidate_info in profile_candidates.items():
- if (
- native_sid in materialized_native_ids
- or not candidate_info.get("hint_fresh")
- ):
- continue
- try:
- wire_sid = self._codex_wire_sid(profile, native_sid)
- except ValueError:
- continue
- normalized.append({
- "session_id": wire_sid,
- "native_session_id": native_sid,
- "summary": "新会话",
- "first_prompt": None,
- "cwd": None,
- "last_modified": str(time.time()),
- "git_branch": None,
- "forked_from_id": candidate_info.get("forked_from_id"),
- "status": None,
- "tag": None,
- "codex_profile_id": profile.id,
- "codex_profile_label": profile.label,
- _CODEX_CATALOG_PRIORITY: 2,
- })
- materialized_native_ids.add(native_sid)
+ # A sibling thread/started is only a discovery hint. Native
+ # ephemeral helpers can emit it without ever creating a resumable
+ # rollout. Keep the exact DB repair above, but never manufacture a
+ # clickable row from the hint alone. Resident threads below carry
+ # their own live handle and remain visible during catalog lag.
# A just-started resident is stronger evidence than a lagging DB
# projection: thread/start already returned its durable id and this
@@ -26761,6 +26893,7 @@ async def _send_codex_session_list(
# BTW fork can finish connecting during one of those awaits, so
# close that race immediately before the synchronous wire build.
raw = self._filter_private_codex_btw_rows(raw)
+ timed_catalog = await self._refresh_timed_tasks()
sessions = []
for row in raw:
wire_sid = row["session_id"]
@@ -26772,6 +26905,8 @@ async def _send_codex_session_list(
continue
sessions.append(SessionInfo(
session_id=wire_sid,
+ timed_tasks=timed_catalog.get((
+ str(self._codex_target(wire_sid)[0].home), native_sid), []),
summary=(record.title if record and record.title
else row.get("summary")),
first_prompt=row.get("first_prompt"),
@@ -34827,7 +34962,10 @@ async def _spawn(self, resume_id: Optional[str], cwd: Optional[str] = None,
service_tier: Optional[str] = None,
space: str = "code",
work_id: Optional[str] = None,
- raise_on_failure: bool = False) -> Optional[SessionContext]:
+ raise_on_failure: bool = False,
+ _service_recovering: bool = False,
+ _service_worker_id: str | None = None,
+ _service_socket: str | None = None) -> Optional[SessionContext]:
"""Create a SessionContext, connect its SDK subprocess, load history.
Returns the ctx (added to the pool under its real or temp key) or None
on legacy-route failure (an Error has been emitted). NewSession uses
@@ -34986,7 +35124,7 @@ async def reject(
# non-focused session (tear down its subprocess; the client keeps its
# runtime and re-spawns on re-focus). Only reject if ALL are running —
# so merely browsing between sessions never wedges you.
- if not bootstrap and len(self.sessions) >= self.cfg.max_concurrent_sessions:
+ if not bootstrap and not _service_recovering and len(self.sessions) >= self.cfg.max_concurrent_sessions:
victim = next((k for k, c in self.sessions.items()
if k != self.focused_sid and c.state == "idle"
and not c.btw and not c.queued_queries
@@ -35614,6 +35752,10 @@ async def codex_profile_allowed(profile_id: str) -> bool:
# handles approvals through its own app-server protocol, so skip it.
if engine != "codex" and broker_handle is None:
self._configure_claude_sdk_callbacks(ctx, ctx.sdk)
+ ctx.sdk.service_defer_events = True
+ if _service_worker_id is not None:
+ ctx.sdk.service_metadata["service_id"] = _service_worker_id
+ ctx.sdk.service_socket_override = _service_socket
elif engine == "codex":
ctx.sdk.approval_callback = (
lambda method, params: self._on_codex_approval(
@@ -35899,7 +36041,11 @@ async def codex_profile_allowed(profile_id: str) -> bool:
# cc model is a runtime switch on the live subprocess (set_model), so apply
# a pre-selected model now that we're connected. codex was set pre-connect.
- if model and engine != "codex":
+ service_attached = bool(getattr(
+ getattr(ctx.sdk, "client", None), "description", {}).get("attached"))
+ if service_attached:
+ model = getattr(ctx.sdk, "model", None)
+ if model and engine != "codex" and not service_attached:
try:
await ctx.sdk.set_model(model)
except Exception as e:
@@ -35948,6 +36094,13 @@ async def codex_profile_allowed(profile_id: str) -> bool:
)
self.sessions[key] = ctx
ctx.key = key
+ if service_attached and not ctx.session_id:
+ old_key = ctx.sdk.client.description["metadata"].get("key")
+ if isinstance(old_key, str) and old_key.startswith("tmp-"):
+ self.sessions.pop(key)
+ key = old_key
+ ctx.key = key
+ self.sessions[key] = ctx
if resume_id:
route_sid = key if engine in {"claude", "codex"} else resume_id
self._watch_session(route_sid)
@@ -35997,6 +36150,8 @@ async def codex_profile_allowed(profile_id: str) -> bool:
await self._publish_claude_auto_compact(ctx, force=True)
log.info("session spawned", resume=resume_id, cwd=target_cwd, key=key,
resident=len(self.sessions))
+ if engine == "claude" and broker_handle is None:
+ await claude_service.activate(self, ctx)
return ctx
async def _spawn_btw(
@@ -36023,7 +36178,7 @@ async def _spawn_btw(
pending_private_forks = sum(
1 for resident in self.sessions.values()
if resident.btw and resident.engine != "codex"
- and not resident.btw_real_id
+ and not resident.btw_real_id and not resident.btw_reserved_id
)
if (len(self._private_btw_sessions) + pending_private_forks
>= self.PRIVATE_BTW_CAP):
@@ -36260,6 +36415,21 @@ async def _spawn_btw(
ctx, thread_id))
ctx.sdk.runtime_event_callback = (
lambda event: self._on_codex_runtime_event(ctx, event))
+ if engine == "claude":
+ reserved_id = str(uuid4())
+ try:
+ # SDK session_id pins the native fork identity before any
+ # transcript can appear. Persist first, including on crashes
+ # during connect; never launch an unregistered private writer.
+ self._remember_private_btw(
+ self._claude_wire_sid(claude_profile, reserved_id),
+ ctx.cwd,
+ )
+ except Exception as exc:
+ raise _BtwSpawnFailure(
+ ERR_INTERNAL, "侧边对话的私有状态暂时无法保存,请稍后重试。",
+ ) from exc
+ ctx.btw_reserved_id = ctx.sdk.fork_session_id = reserved_id
try:
await ctx.sdk.connect(
resume_id=parent_id, cwd=parent.cwd, fork=True)
@@ -36296,20 +36466,28 @@ async def _spawn_btw(
ctx, preferred=BTW_DEFAULT_EFFORT)
await self._stamp_codex_daemon_epoch(ctx)
except asyncio.CancelledError:
+ disconnected = False
try:
await ctx.sdk.disconnect()
+ disconnected = True
except Exception:
log.warning("btw fork cancellation cleanup failed")
+ await self._delete_claude_btw_transcripts(
+ ctx, forget=disconnected)
raise
except Exception as e:
# connect() can fail after starting a private app-server/SDK child,
# and the post-connect effort probe can fail too. The context is not
# resident yet, so no later pool cleanup can reach that partial
# handle; close it here before returning the correlated rejection.
+ disconnected = False
try:
await ctx.sdk.disconnect()
+ disconnected = True
except Exception:
log.warning("btw fork failure cleanup failed")
+ await self._delete_claude_btw_transcripts(
+ ctx, forget=disconnected)
log.exception("btw fork initialization failed", error=str(e))
raise _BtwSpawnFailure(
ERR_CC_CRASH, "临时侧边会话暂时无法打开,请稍后重试。"
@@ -37029,9 +37207,22 @@ async def _run_turn(
files: Optional[list] = None,
*,
launch_receipt: asyncio.Future[bool] | None = None,
+ _recover_service: bool = False,
) -> None:
is_codex = ctx.engine == "codex"
is_codex_shared = self._codex_shared_affinity(ctx)
+ service_client = getattr(ctx.sdk, "client", None)
+ service_replay = (
+ claude_service.ReplayProjection(service_client.description["head"])
+ if _recover_service else None
+ )
+ if not is_codex:
+ ctx.sdk.service_turn_metadata = {
+ "id": ctx.active_msg_id, "prompt": prompt,
+ "images": images,
+ "files": ([{"filename": item.get("filename", "attachment")}
+ for item in (files or [])] or None),
+ }
display_prompt = prompt
ctx.translator = (CodexStreamTranslator(self.cfg.tool_result_max) if is_codex
else StreamTranslator(
@@ -37509,6 +37700,9 @@ async def handoff_codex_account_switch(
async def reconnect_claude(reason: str) -> None:
"""Reconnect without hiding transcript changes during the await."""
+ check_delivery = getattr(ctx.sdk, "check_service_delivery", None)
+ if check_delivery is not None:
+ check_delivery()
external_change = reason.startswith("external transcript change")
if external_change:
self._invalidate_claude_context_usage(ctx)
@@ -37534,209 +37728,138 @@ async def reconnect_claude(reason: str) -> None:
await self._publish_claude_auto_compact(ctx)
try:
- # An EXTERNAL process (a native `claude`/`codex` in the user's terminal)
- # appended to this session's transcript since we resumed it, so our child's
- # in-memory context is STALE — continuing from it would fork the
- # conversation. Reload by resuming afresh before issuing the turn.
- if ctx.needs_reload and ctx.session_id and is_codex_shared:
- # Rollout growth from another official proxy is already in the
- # shared daemon's authoritative thread state. Reconnecting here
- # only risks falling back to a private stdio process and creating
- # the split-brain this mode exists to avoid.
- ctx.needs_reload = False
- elif ctx.needs_reload and ctx.session_id:
- log.info("reloading session after external transcript change",
- sid=ctx.session_id)
- if is_codex:
- if ctx.codex_checkpoint not in (None, False):
- await self._retire_codex_checkpoint(
- ctx,
- reason="external transcript change before query",
- allow_restart=True,
- )
- await self._refresh_codex_collaboration_mode(ctx)
- await ctx.sdk.force_reconnect(
- resume_id=ctx.session_id, cwd=ctx.cwd,
- reason="external transcript change")
- await self._publish_codex_model_effort(ctx)
- ctx.needs_reload = False
- else:
- # Clear first so a watcher that observes a new external write
- # during reconnect can set it again without being overwritten.
+ if not _recover_service:
+ if not is_codex:
+ check_delivery = getattr(ctx.sdk, "check_service_delivery", None)
+ if check_delivery is not None:
+ check_delivery()
+ # An EXTERNAL process (a native `claude`/`codex` in the user's terminal)
+ # appended to this session's transcript since we resumed it, so our child's
+ # in-memory context is STALE — continuing from it would fork the
+ # conversation. Reload by resuming afresh before issuing the turn.
+ if ctx.needs_reload and ctx.session_id and is_codex_shared:
+ # Rollout growth from another official proxy is already in the
+ # shared daemon's authoritative thread state. Reconnecting here
+ # only risks falling back to a private stdio process and creating
+ # the split-brain this mode exists to avoid.
ctx.needs_reload = False
- await reconnect_claude("external transcript change")
- # A get_context_usage timeout leaves the request running inside the
- # Claude child even after the SDK has dropped its local waiter. That
- # generation must never receive a prompt: resume first, with the
- # handle's context probe suppression carried into the replacement.
- if (
- not is_codex
- and getattr(ctx.sdk, "control_plane_failed", False)
- ):
- log.warning(
- "recovering failed Claude control plane before query",
- sid=ctx.session_id,
- )
- await reconnect_claude("control plane failure")
- # A failed sole SDK reader cannot be reused even though its Claude
- # child may have kept writing the transcript. External transcript
- # authority above takes precedence when both conditions are present;
- # otherwise resume now before accepting another prompt. Never replay
- # the failed prompt automatically because it may already have run.
- if (
- not is_codex
- and getattr(ctx.sdk, "message_pump_failed", False)
- ):
- log.warning(
- "recovering failed Claude SDK message pump before query",
- sid=ctx.session_id,
- )
- await reconnect_claude("message pump failure")
- if (not is_codex
- and self._claude_auto_compact_event(ctx).pending):
- async with ctx.query_lock:
- auto_event, _ = await self._apply_pending_claude_auto_compact(
- ctx, reason="autocompact before next turn")
- if auto_event.pending:
- await self._emit(ctx, Error(
- code=ERR_BUSY,
- message=(
- auto_event.error
- or "自动压缩设置尚未安全生效,本次消息未发送。"
- ),
- msg_id=ctx.active_msg_id,
- ))
- await close_unsubmitted_turn()
- return
- # apply a pending effort change: --effort is spawn-time, so respawn the
- # cc subprocess (resume preserves context) before issuing this turn. Only
- # fires when the level actually changed since the live client was spawned;
- # costs one resume (cold prompt cache) on the first turn after a change.
- if not is_codex and ctx.sdk.effort != ctx.sdk.applied_effort:
- log.info("applying effort change via reconnect", sid=ctx.session_id,
- effort=ctx.sdk.effort, was=ctx.sdk.applied_effort)
- await reconnect_claude("effort change")
- # Serialize the final launch window against interrupt(). An interrupt
- # may have arrived while one of the reconnects above was in flight; in
- # that case it targeted no live turn and we must not submit the prompt
- # afterwards. Re-check after every later ownership/reconnect await as
- # well; UserMsg is published only after the engine accepts the query.
- async with ctx.launch_lock:
- if ctx.interrupt_event.is_set() or ctx.state == "interrupting":
- # Other clients do not have the origin's optimistic turn. Echo
- # it before the terminal marker so TurnEnd cannot accidentally
- # close the previous visible turn on those clients.
- await close_unsubmitted_turn()
- return
-
- stage_images = bool(images and (
- is_codex or ctx.space == "work"))
- staged_image_paths: list[str] = []
- if ctx.space == "work" and (files or stage_images):
- prompt, staged_image_paths, temp_dir = (
- self._stash_work_attachments(
- prompt,
- files,
- images if stage_images else None,
- ctx.cwd,
- ctx.active_msg_id or uuid4().hex,
- ctx.engine,
- )
+ elif ctx.needs_reload and ctx.session_id:
+ log.info("reloading session after external transcript change",
+ sid=ctx.session_id)
+ if is_codex:
+ if ctx.codex_checkpoint not in (None, False):
+ await self._retire_codex_checkpoint(
+ ctx,
+ reason="external transcript change before query",
+ allow_restart=True,
+ )
+ await self._refresh_codex_collaboration_mode(ctx)
+ await ctx.sdk.force_reconnect(
+ resume_id=ctx.session_id, cwd=ctx.cwd,
+ reason="external transcript change")
+ await self._publish_codex_model_effort(ctx)
+ ctx.needs_reload = False
+ else:
+ # Clear first so a watcher that observes a new external write
+ # during reconnect can set it again without being overwritten.
+ ctx.needs_reload = False
+ await reconnect_claude("external transcript change")
+ # A get_context_usage timeout leaves the request running inside the
+ # Claude child even after the SDK has dropped its local waiter. That
+ # generation must never receive a prompt: resume first, with the
+ # handle's context probe suppression carried into the replacement.
+ if (
+ not is_codex
+ and getattr(ctx.sdk, "control_plane_failed", False)
+ ):
+ log.warning(
+ "recovering failed Claude control plane before query",
+ sid=ctx.session_id,
)
- persistent_attachments = True
- else:
- if files or stage_images:
- temp_dir = tempfile.mkdtemp(prefix="cc-remote-turn-")
- os.chmod(temp_dir, 0o700)
- if files:
- prompt = self._stash_files(
- prompt, files, temp_dir, ctx.engine)
- if stage_images:
- staged_image_paths = self._stash_images(images, temp_dir)
- if ctx.interrupt_event.is_set() or ctx.state == "interrupting":
- await close_unsubmitted_turn()
- return
- if not is_codex and ctx.session_id:
- # A terminal can append after _handle_query's probe but before
- # this task reaches sdk.query(). Consume both process state and
- # transcript growth again at the final launch boundary.
- external = await self._prime_claude_ownership(ctx.session_id)
- if (ctx.interrupt_event.is_set()
- or ctx.state == "interrupting"):
- await close_unsubmitted_turn()
- return
- if external:
+ await reconnect_claude("control plane failure")
+ # A failed sole SDK reader cannot be reused even though its Claude
+ # child may have kept writing the transcript. External transcript
+ # authority above takes precedence when both conditions are present;
+ # otherwise resume now before accepting another prompt. Never replay
+ # the failed prompt automatically because it may already have run.
+ if (
+ not is_codex
+ and getattr(ctx.sdk, "message_pump_failed", False)
+ ):
+ log.warning(
+ "recovering failed Claude SDK message pump before query",
+ sid=ctx.session_id,
+ )
+ await reconnect_claude("message pump failure")
+ if (not is_codex
+ and self._claude_auto_compact_event(ctx).pending):
+ async with ctx.query_lock:
+ auto_event, _ = await self._apply_pending_claude_auto_compact(
+ ctx, reason="autocompact before next turn")
+ if auto_event.pending:
await self._emit(ctx, Error(
code=ERR_BUSY,
- message=("该 Claude 会话刚被本机终端打开,本次发送已取消;"
- "请退出终端或点击『接管』后重试"),
+ message=(
+ auto_event.error
+ or "自动压缩设置尚未安全生效,本次消息未发送。"
+ ),
msg_id=ctx.active_msg_id,
))
- await self._set_idle_after_managed_turn(ctx)
+ await close_unsubmitted_turn()
return
- if ctx.needs_reload:
- log.info(
- "reloading Claude session after transcript change "
- "found at final preflight",
- sid=ctx.session_id,
- )
- ctx.needs_reload = False
- await reconnect_claude(
- "external transcript change at final preflight")
- if (ctx.interrupt_event.is_set()
- or ctx.state == "interrupting"):
- await close_unsubmitted_turn()
- return
- external = await self._prime_claude_ownership(
- ctx.session_id)
- if (ctx.interrupt_event.is_set()
- or ctx.state == "interrupting"):
- await close_unsubmitted_turn()
- return
- if external or ctx.needs_reload:
- message = (
- "该 Claude 会话在重载期间又被本机终端更新,"
- "本次发送已取消;请退出终端后重试"
- if external else
- "该 Claude 会话在重载期间仍有未归属的内容更新,"
- "本次发送已取消;请稍后重试"
+ # apply a pending effort change: --effort is spawn-time, so respawn the
+ # cc subprocess (resume preserves context) before issuing this turn. Only
+ # fires when the level actually changed since the live client was spawned;
+ # costs one resume (cold prompt cache) on the first turn after a change.
+ if not is_codex and ctx.sdk.effort != ctx.sdk.applied_effort:
+ log.info("applying effort change via reconnect", sid=ctx.session_id,
+ effort=ctx.sdk.effort, was=ctx.sdk.applied_effort)
+ await reconnect_claude("effort change")
+ # Serialize the final launch window against interrupt(). An interrupt
+ # may have arrived while one of the reconnects above was in flight; in
+ # that case it targeted no live turn and we must not submit the prompt
+ # afterwards. Re-check after every later ownership/reconnect await as
+ # well; UserMsg is published only after the engine accepts the query.
+ async with ctx.launch_lock:
+ if ctx.interrupt_event.is_set() or ctx.state == "interrupting":
+ # Other clients do not have the origin's optimistic turn. Echo
+ # it before the terminal marker so TurnEnd cannot accidentally
+ # close the previous visible turn on those clients.
+ await close_unsubmitted_turn()
+ return
+
+ stage_images = bool(images and (
+ is_codex or ctx.space == "work"))
+ staged_image_paths: list[str] = []
+ if ctx.space == "work" and (files or stage_images):
+ prompt, staged_image_paths, temp_dir = (
+ self._stash_work_attachments(
+ prompt,
+ files,
+ images if stage_images else None,
+ ctx.cwd,
+ ctx.active_msg_id or uuid4().hex,
+ ctx.engine,
)
- await self._emit(ctx, Error(
- code=ERR_BUSY,
- message=message,
- msg_id=ctx.active_msg_id,
- ))
- await self._set_idle_after_managed_turn(ctx)
- return
- if not is_codex:
- # Freeze the exact transcript source/inode/byte boundary at
- # the final launch point. Claude creates its native user UUID
- # inside the child and may append it long before the SDK
- # replays that row through receive_response().
- self._start_claude_client_alias_probe(ctx)
- if is_codex:
- # codex: images -> private temp dir -> localImage items; files already
- # referenced by path in the prompt text above.
- img_paths = staged_image_paths
- # Keep the final ownership check adjacent to turn/start. A
- # short native turn can finish between the earlier reload and
- # this probe: no holder remains, but consuming its markers sets
- # needs_reload. Reconnect once, then probe again before sending.
- if (
- is_codex_shared
- and not await self._ensure_codex_daemon_generation(
- ctx, reason="final query preflight")
- ):
- await self._emit(ctx, Error(
- code=ERR_NOT_RUNNING,
- message="Codex 共享通道重连失败,本次未发送;请重试",
- msg_id=ctx.active_msg_id,
- ))
- await self._set_idle_after_managed_turn(ctx)
+ )
+ persistent_attachments = True
+ else:
+ if files or stage_images:
+ temp_dir = tempfile.mkdtemp(prefix="cc-remote-turn-")
+ os.chmod(temp_dir, 0o700)
+ if files:
+ prompt = self._stash_files(
+ prompt, files, temp_dir, ctx.engine)
+ if stage_images:
+ staged_image_paths = self._stash_images(images, temp_dir)
+ if ctx.interrupt_event.is_set() or ctx.state == "interrupting":
+ await close_unsubmitted_turn()
return
- route_sid = self._ctx_wire_sid(ctx)
- if route_sid and not is_codex_shared:
- external = await self._prime_codex_ownership(route_sid)
+ if not is_codex and ctx.session_id:
+ # A terminal can append after _handle_query's probe but before
+ # this task reaches sdk.query(). Consume both process state and
+ # transcript growth again at the final launch boundary.
+ external = await self._prime_claude_ownership(ctx.session_id)
if (ctx.interrupt_event.is_set()
or ctx.state == "interrupting"):
await close_unsubmitted_turn()
@@ -37744,7 +37867,7 @@ async def reconnect_claude(reason: str) -> None:
if external:
await self._emit(ctx, Error(
code=ERR_BUSY,
- message=("该 Codex 会话刚被本机终端打开,本次发送已取消;"
+ message=("该 Claude 会话刚被本机终端打开,本次发送已取消;"
"请退出终端或点击『接管』后重试"),
msg_id=ctx.active_msg_id,
))
@@ -37752,129 +37875,205 @@ async def reconnect_claude(reason: str) -> None:
return
if ctx.needs_reload:
log.info(
- "reloading session after external transcript change "
+ "reloading Claude session after transcript change "
"found at final preflight",
sid=ctx.session_id,
)
- if ctx.codex_checkpoint not in (None, False):
- await self._retire_codex_checkpoint(
- ctx,
- reason=("external transcript change at final "
- "query preflight"),
- allow_restart=True,
- )
- await self._refresh_codex_collaboration_mode(ctx)
- await ctx.sdk.force_reconnect(
- resume_id=ctx.session_id, cwd=ctx.cwd,
- reason="external transcript change at final preflight",
- )
- await self._publish_codex_model_effort(ctx)
ctx.needs_reload = False
+ await reconnect_claude(
+ "external transcript change at final preflight")
if (ctx.interrupt_event.is_set()
or ctx.state == "interrupting"):
await close_unsubmitted_turn()
return
- external = await self._prime_codex_ownership(
- route_sid)
+ external = await self._prime_claude_ownership(
+ ctx.session_id)
if (ctx.interrupt_event.is_set()
or ctx.state == "interrupting"):
await close_unsubmitted_turn()
return
if external or ctx.needs_reload:
+ message = (
+ "该 Claude 会话在重载期间又被本机终端更新,"
+ "本次发送已取消;请退出终端后重试"
+ if external else
+ "该 Claude 会话在重载期间仍有未归属的内容更新,"
+ "本次发送已取消;请稍后重试"
+ )
await self._emit(ctx, Error(
code=ERR_BUSY,
- message=("该 Codex 会话在重载期间又被本机终端更新,"
- "本次发送已取消;请退出终端或点击『接管』后重试"),
+ message=message,
msg_id=ctx.active_msg_id,
))
await self._set_idle_after_managed_turn(ctx)
return
- await self._resolve_codex_session_effort(ctx)
- await self._begin_codex_checkpoint(ctx)
- if ctx.interrupt_event.is_set() or ctx.state == "interrupting":
- await self._abort_codex_checkpoint(ctx)
- await close_unsubmitted_turn()
- return
- query_generation = getattr(ctx.sdk, "_generation", None)
- native_turn_id = await ctx.sdk.query(
- prompt,
- images=img_paths,
- client_user_message_id=ctx.active_msg_id,
- )
- # Publish only after the native acceptance boundary. A
- # Claude autonomous guard (and every earlier preflight)
- # must not turn an unsent queued prompt into conversation
- # history merely because its runner became visible.
- await self._emit(ctx, UserMsg(
- msg_id=ctx.active_msg_id or uuid4().hex,
- prompt=display_prompt,
- images=images,
- files=file_meta,
- ))
- settle_launch(True)
- current_query_generation = getattr(
- ctx.sdk, "_generation", None)
- codex_query_reconnected = bool(
- isinstance(query_generation, int)
- and isinstance(current_query_generation, int)
- and current_query_generation != query_generation
- )
- # CodexHandle marks turn/start failure by raising with
- # turn_active=False. Reaching here is the authoritative
- # acceptance boundary, including an ultra-fast turn that
- # already completed before the RPC coroutine resumed.
- if native_turn_id:
- self._claim_codex_turn(
- ctx, native_turn_id, ctx.active_msg_id)
- await self._remember_codex_initial_turn_alias(
- ctx, native_turn_id)
- if native_turn_id and ctx.active_msg_id:
- await self._emit(ctx, TurnBinding(
- msg_id=ctx.active_msg_id,
- turn_id=native_turn_id,
+ if not is_codex:
+ # Freeze the exact transcript source/inode/byte boundary at
+ # the final launch point. Claude creates its native user UUID
+ # inside the child and may append it long before the SDK
+ # replays that row through receive_response().
+ self._start_claude_client_alias_probe(ctx)
+ if is_codex:
+ # codex: images -> private temp dir -> localImage items; files already
+ # referenced by path in the prompt text above.
+ img_paths = staged_image_paths
+ # Keep the final ownership check adjacent to turn/start. A
+ # short native turn can finish between the earlier reload and
+ # this probe: no holder remains, but consuming its markers sets
+ # needs_reload. Reconnect once, then probe again before sending.
+ if (
+ is_codex_shared
+ and not await self._ensure_codex_daemon_generation(
+ ctx, reason="final query preflight")
+ ):
+ await self._emit(ctx, Error(
+ code=ERR_NOT_RUNNING,
+ message="Codex 共享通道重连失败,本次未发送;请重试",
+ msg_id=ctx.active_msg_id,
+ ))
+ await self._set_idle_after_managed_turn(ctx)
+ return
+ route_sid = self._ctx_wire_sid(ctx)
+ if route_sid and not is_codex_shared:
+ external = await self._prime_codex_ownership(route_sid)
+ if (ctx.interrupt_event.is_set()
+ or ctx.state == "interrupting"):
+ await close_unsubmitted_turn()
+ return
+ if external:
+ await self._emit(ctx, Error(
+ code=ERR_BUSY,
+ message=("该 Codex 会话刚被本机终端打开,本次发送已取消;"
+ "请退出终端或点击『接管』后重试"),
+ msg_id=ctx.active_msg_id,
+ ))
+ await self._set_idle_after_managed_turn(ctx)
+ return
+ if ctx.needs_reload:
+ log.info(
+ "reloading session after external transcript change "
+ "found at final preflight",
+ sid=ctx.session_id,
+ )
+ if ctx.codex_checkpoint not in (None, False):
+ await self._retire_codex_checkpoint(
+ ctx,
+ reason=("external transcript change at final "
+ "query preflight"),
+ allow_restart=True,
+ )
+ await self._refresh_codex_collaboration_mode(ctx)
+ await ctx.sdk.force_reconnect(
+ resume_id=ctx.session_id, cwd=ctx.cwd,
+ reason="external transcript change at final preflight",
+ )
+ await self._publish_codex_model_effort(ctx)
+ ctx.needs_reload = False
+ if (ctx.interrupt_event.is_set()
+ or ctx.state == "interrupting"):
+ await close_unsubmitted_turn()
+ return
+ external = await self._prime_codex_ownership(
+ route_sid)
+ if (ctx.interrupt_event.is_set()
+ or ctx.state == "interrupting"):
+ await close_unsubmitted_turn()
+ return
+ if external or ctx.needs_reload:
+ await self._emit(ctx, Error(
+ code=ERR_BUSY,
+ message=("该 Codex 会话在重载期间又被本机终端更新,"
+ "本次发送已取消;请退出终端或点击『接管』后重试"),
+ msg_id=ctx.active_msg_id,
+ ))
+ await self._set_idle_after_managed_turn(ctx)
+ return
+ await self._resolve_codex_session_effort(ctx)
+ await self._begin_codex_checkpoint(ctx)
+ if ctx.interrupt_event.is_set() or ctx.state == "interrupting":
+ await self._abort_codex_checkpoint(ctx)
+ await close_unsubmitted_turn()
+ return
+ query_generation = getattr(ctx.sdk, "_generation", None)
+ native_turn_id = await ctx.sdk.query(
+ prompt,
+ images=img_paths,
+ client_user_message_id=ctx.active_msg_id,
+ )
+ # Publish only after the native acceptance boundary. A
+ # Claude autonomous guard (and every earlier preflight)
+ # must not turn an unsent queued prompt into conversation
+ # history merely because its runner became visible.
+ await self._emit(ctx, UserMsg(
+ msg_id=ctx.active_msg_id or uuid4().hex,
+ prompt=display_prompt,
+ images=images,
+ files=file_meta,
))
- if (
- is_codex_shared
- and native_turn_id
- and ctx.codex_daemon_epoch
- ):
- codex_restart_watch_task = asyncio.create_task(
- self._wait_for_codex_account_switch(
- ctx,
- starting_epoch=ctx.codex_daemon_epoch,
- )
+ settle_launch(True)
+ current_query_generation = getattr(
+ ctx.sdk, "_generation", None)
+ codex_query_reconnected = bool(
+ isinstance(query_generation, int)
+ and isinstance(current_query_generation, int)
+ and current_query_generation != query_generation
)
- await self._accept_codex_checkpoint(ctx)
- elif images:
- content: list = []
- if prompt:
- content.append({"type": "text", "text": prompt})
- for img in images:
- content.append({"type": "image", "source": {
- "type": "base64",
- "media_type": img.get("media_type", "image/png"),
- "data": img.get("data", ""),
- }})
-
- async def msg_stream():
- yield {"type": "user", "message": {"role": "user", "content": content},
- "parent_tool_use_id": None}
-
- ctx.sdk.next_turn_id = ctx.active_msg_id
- ctx.claude_write_active = True
- await ctx.sdk.query(msg_stream())
- else:
- ctx.sdk.next_turn_id = ctx.active_msg_id
- ctx.claude_write_active = True
- await ctx.sdk.query(prompt)
- if not is_codex:
- await self._emit(ctx, UserMsg(
- msg_id=ctx.active_msg_id or uuid4().hex,
- prompt=display_prompt,
- images=images,
- files=file_meta,
- ))
- settle_launch(True)
+ # CodexHandle marks turn/start failure by raising with
+ # turn_active=False. Reaching here is the authoritative
+ # acceptance boundary, including an ultra-fast turn that
+ # already completed before the RPC coroutine resumed.
+ if native_turn_id:
+ self._claim_codex_turn(
+ ctx, native_turn_id, ctx.active_msg_id)
+ await self._remember_codex_initial_turn_alias(
+ ctx, native_turn_id)
+ if native_turn_id and ctx.active_msg_id:
+ await self._emit(ctx, TurnBinding(
+ msg_id=ctx.active_msg_id,
+ turn_id=native_turn_id,
+ ))
+ if (
+ is_codex_shared
+ and native_turn_id
+ and ctx.codex_daemon_epoch
+ ):
+ codex_restart_watch_task = asyncio.create_task(
+ self._wait_for_codex_account_switch(
+ ctx,
+ starting_epoch=ctx.codex_daemon_epoch,
+ )
+ )
+ await self._accept_codex_checkpoint(ctx)
+ elif images:
+ content: list = []
+ if prompt:
+ content.append({"type": "text", "text": prompt})
+ for img in images:
+ content.append({"type": "image", "source": {
+ "type": "base64",
+ "media_type": img.get("media_type", "image/png"),
+ "data": img.get("data", ""),
+ }})
+
+ async def msg_stream():
+ yield {"type": "user", "message": {"role": "user", "content": content},
+ "parent_tool_use_id": None}
+
+ ctx.sdk.next_turn_id = ctx.active_msg_id
+ ctx.claude_write_active = True
+ await ctx.sdk.query(msg_stream())
+ else:
+ ctx.sdk.next_turn_id = ctx.active_msg_id
+ ctx.claude_write_active = True
+ await ctx.sdk.query(prompt)
+ if not is_codex:
+ await self._emit(ctx, UserMsg(
+ msg_id=ctx.active_msg_id or uuid4().hex,
+ prompt=display_prompt,
+ images=images,
+ files=file_meta,
+ ))
+ settle_launch(True)
# Codex sessions don't emit a Model event like cc's init SystemMessage,
# so announce the configured codex model (gpt-*) once — else the header
# would keep showing a stale Claude model.
@@ -38041,7 +38240,6 @@ async def msg_stream():
if goal_changed and ctx.goal_visible:
await self._emit(ctx, GoalState(goal=goal))
- self._observe_claude_task_lifecycle(ctx, msg)
registry = ctx.claude_agents
route = (
registry.route(msg)
@@ -38050,8 +38248,13 @@ async def msg_stream():
await self._publish_claude_agent_route(ctx, route)
if route.target == "detail":
continue
+ self._observe_claude_task_lifecycle(ctx, msg)
native_user_id = replayed_user_message_id(msg)
+ from cc_remote.wrapper import claude_steer
+
+ steer_event = await claude_steer.apply_echo(
+ self, ctx, msg, native_user_id)
if native_user_id is not None:
await self._remember_claude_client_message_id(
ctx, native_user_id)
@@ -38078,11 +38281,24 @@ async def msg_stream():
too_large_kind),
msg_id=ctx.active_msg_id,
))
- for ev in ctx.translator.feed(msg):
+ events = ([steer_event] if steer_event else []) + ctx.translator.feed(msg)
+ claude_service.preserve_timestamp(events, msg)
+ if service_replay is not None:
+ service_replay.add(events)
+ if (getattr(msg, "_cc_service_seq", 0) >= service_replay.head
+ or isinstance(msg, ResultMessage)):
+ events = service_replay.drain()
+ service_replay = None
+ else:
+ events = []
+ for ev in events:
await self._emit(ctx, ev)
if isinstance(msg, ResultMessage):
await self._flush_claude_client_message_ids(ctx)
+ ack = getattr(ctx.sdk, "ack_service_message", None)
+ if ack is not None:
+ await ack(msg, turn_id=ctx.active_msg_id)
break
if not is_codex:
@@ -38097,6 +38313,9 @@ async def msg_stream():
# finally. Query/send or reader failures remain ambiguous and
# their growth must be consumed as a reload on the next probe.
claude_turn_completed = True
+ from cc_remote.wrapper import claude_steer
+
+ claude_steer.cleanup(ctx)
if not is_codex and ctx.session_id:
goal = await ctx.sdk.refresh_goal(ctx.session_id)
@@ -38616,6 +38835,7 @@ async def freeze_indeterminate_recovery() -> None:
provider_request_too_large_message(
provider_too_large_kind)
if provider_too_large_kind is not None else
+ str(e) if isinstance(e, ClaudeServiceReplayRequired) else
"本次回复未完成,请重试。"
),
msg_id=ctx.active_msg_id))
@@ -38663,7 +38883,7 @@ async def freeze_indeterminate_recovery() -> None:
# preflight/query/reader failures must not hide an external append.
ctx.claude_write_active = False
if not is_codex and claude_turn_completed and ctx.session_id:
- self._resync_watch(ctx.session_id)
+ self._resync_watch(self._ctx_wire_sid(ctx))
if temp_dir is not None and not persistent_attachments:
try:
shutil.rmtree(temp_dir)
diff --git a/cc_remote/wrapper/ringbuffer.py b/cc_remote/wrapper/ringbuffer.py
index a15bf4f9..8a249612 100644
--- a/cc_remote/wrapper/ringbuffer.py
+++ b/cc_remote/wrapper/ringbuffer.py
@@ -11,7 +11,7 @@
from collections import deque
from typing import Optional
-from cc_remote.protocol import Delta, ReplayStart, ReplayEnd, Snapshot, StateEvent
+from cc_remote.protocol import Delta, ReplayStart, ReplayEnd, Snapshot, StateEvent, TurnUsage
_CURRENT_TURN_DELTA_CHUNK_CHARS = 64 * 1024
@@ -25,12 +25,18 @@ def __init__(self, max_events: int, max_bytes: int):
self._bytes = 0
self._logical_tail_seq = 0
self._dropped_through_seq = 0
+ self._turn_usage: dict[str, TurnUsage] = {}
@staticmethod
def _size(msg) -> int:
return len(msg.model_dump_json().encode()) # type: ignore[attr-defined]
def append(self, msg) -> None:
+ if isinstance(msg, TurnUsage):
+ self._turn_usage.pop(msg.turn_id, None)
+ self._turn_usage[msg.turn_id] = msg
+ while len(self._turn_usage) > 8:
+ del self._turn_usage[next(iter(self._turn_usage))]
size = self._size(msg)
seq = msg.seq # type: ignore[attr-defined]
self._logical_tail_seq = max(self._logical_tail_seq, seq)
@@ -52,6 +58,9 @@ def append(self, msg) -> None:
def head_seq(self) -> int:
return self._buf[0][0] if self._buf else 0
+ def latest_turn_usage(self) -> list[TurnUsage]:
+ return list(self._turn_usage.values())
+
@property
def tail_seq(self) -> int:
return self._logical_tail_seq
@@ -75,7 +84,7 @@ def replay_from(self, last_seq: Optional[int], *, cc_session_id, state,
truncated=truncated, rebuild=True,
generation=generation)]
frames.extend(m for _, m in self._buf)
- frames.append(ReplayEnd(to_seq=tail, truncated=truncated))
+ frames.append(ReplayEnd(turn_usage=self.latest_turn_usage(), to_seq=tail, truncated=truncated))
return frames
if last_seq is None:
@@ -83,7 +92,7 @@ def replay_from(self, last_seq: Optional[int], *, cc_session_id, state,
# Authoritative transcript history is fetched separately via GetHistory.
return [Snapshot(cc_session_id=cc_session_id, state=state,
tail_text=tail_text, cwd=cwd,
- generation=generation)]
+ generation=generation, turn_usage=self.latest_turn_usage())]
# Future cursor: the client's last_seq is beyond our buffer's tail. This
# happens because the seq counter resets to 0 on every wrapper restart,
@@ -102,7 +111,7 @@ def replay_from(self, last_seq: Optional[int], *, cc_session_id, state,
truncated=truncated, rebuild=True,
generation=generation)]
frames.extend(m for _, m in have)
- frames.append(ReplayEnd(to_seq=to_seq, truncated=truncated))
+ frames.append(ReplayEnd(turn_usage=self.latest_turn_usage(), to_seq=to_seq, truncated=truncated))
return frames
have = [(s, m) for s, m in self._buf if s > last_seq]
@@ -114,7 +123,7 @@ def replay_from(self, last_seq: Optional[int], *, cc_session_id, state,
to_seq = max(last_seq, self.tail_seq)
return [ReplayStart(from_seq=last_seq + 1, to_seq=to_seq,
truncated=truncated, generation=generation),
- ReplayEnd(to_seq=to_seq, truncated=truncated)]
+ ReplayEnd(turn_usage=self.latest_turn_usage(), to_seq=to_seq, truncated=truncated)]
from_seq = have[0][0]
to_seq = self.tail_seq
@@ -122,7 +131,7 @@ def replay_from(self, last_seq: Optional[int], *, cc_session_id, state,
truncated=truncated, generation=generation)]
for _, m in have:
frames.append(m)
- frames.append(ReplayEnd(to_seq=to_seq, truncated=truncated))
+ frames.append(ReplayEnd(turn_usage=self.latest_turn_usage(), to_seq=to_seq, truncated=truncated))
return frames
def replay_from_bounded(
@@ -183,6 +192,7 @@ def replay_from_bounded(
)]
frames.extend(selected)
frames.append(ReplayEnd(
+ turn_usage=self.latest_turn_usage(),
to_seq=self.tail_seq,
truncated=truncated,
))
@@ -306,6 +316,7 @@ def current_turn_replay(
]
frames.extend(self._compact_current_turn_suffix(retained))
frames.append(ReplayEnd(
+ turn_usage=self.latest_turn_usage(),
to_seq=self.tail_seq,
truncated=True,
))
@@ -318,7 +329,7 @@ def current_turn_replay(
ReplayStart(
from_seq=self.head_seq, to_seq=self.tail_seq,
truncated=True, generation=generation),
- ReplayEnd(to_seq=self.tail_seq, truncated=True),
+ ReplayEnd(turn_usage=self.latest_turn_usage(), to_seq=self.tail_seq, truncated=True),
]
have = list(self._buf)[start:]
if not have:
@@ -329,7 +340,7 @@ def current_turn_replay(
generation=generation,
)]
frames.extend(message for _, message in have)
- frames.append(ReplayEnd(to_seq=self.tail_seq, truncated=truncated))
+ frames.append(ReplayEnd(turn_usage=self.latest_turn_usage(), to_seq=self.tail_seq, truncated=truncated))
return frames
def latest_state(self):
diff --git a/cc_remote/wrapper/sdk.py b/cc_remote/wrapper/sdk.py
index e4627b6d..a6bbe8c4 100644
--- a/cc_remote/wrapper/sdk.py
+++ b/cc_remote/wrapper/sdk.py
@@ -32,6 +32,7 @@
from mcp.server import Server
from cc_remote.config import WrapperConfig
+from cc_remote.claude_steering import ClaudeSteerRejected, PendingSteers, steer_message
from cc_remote.log import logger
from cc_remote.protocol import MAX_SAFE_WIRE_INTEGER
from cc_remote.wrapper.child_env import claude_profile_child_env
@@ -48,6 +49,7 @@
)
from cc_remote.wrapper.claude_runtime import inspect_claude_runtime
from cc_remote.wrapper.claude_model_fallback import model_fallback_event
+from cc_remote.wrapper.claude_compaction import compact_context_usage, compact_metadata
from cc_remote.wrapper.claude_controls import (
CLAUDE_DEFAULT_AUTO_COMPACT_MODE,
claude_auto_compact_cli_value,
@@ -82,7 +84,7 @@
"claude-mythos-5-1[1m]": "claude-mythos-5-1[1m]",
}
_CONVERSATION_REWIND_PROBE_UUID = "00000000-0000-0000-0000-000000000000"
-_CONTEXT_CONTROL_TIMEOUT = 15.0
+_CONTEXT_CONTROL_TIMEOUT = 60.0
_CONTEXT_STARTUP_TIMEOUT = 5.0
_CURRENT_LAUNCH_VALUE = object()
@@ -141,6 +143,16 @@ class ClaudeAutonomousFollowupPending(RuntimeError):
"""A background Claude continuation owns the next response boundary."""
+class ClaudeServiceReplayRequired(RuntimeError):
+ """The controller must replay retained output before accepting more work."""
+
+ def __init__(self):
+ super().__init__(
+ "Claude 后台消息同步失败,任务记录仍由会话服务保留。"
+ "请重启 Wrapper 以恢复消息。"
+ )
+
+
def _message_origin_kind(message: Any) -> str | None:
"""Return the SDK's authoritative turn provenance when one is present."""
origin = getattr(message, "origin", None)
@@ -207,6 +219,14 @@ def __init__(
self.claude_config_dir = claude_config_dir
self.isolate_account_env = isolate_account_env
self.client: ClaudeSDKClient | None = None
+ self.service_metadata: dict | None = None
+ self.service_socket_override: str | None = None
+ self.service_recovery: dict | None = None
+ self.service_turn_metadata: dict | None = None
+ self.service_defer_events = False
+ self._steers = PendingSteers()
+ self._turn_root_id: str | None = None
+ self._context_revision = 0
# reasoning effort is a spawn-time flag (--effort), not a runtime setter.
# `effort` is the desired level; `applied_effort` is what the live client
# was spawned with — they differ after set_effort until the next reconnect.
@@ -227,6 +247,10 @@ def __init__(
# may expose a proxy's upstream model (for example glm-5.2), so recover
# this from the SDK control plane and preserve it across reconnects.
self.model: str | None = None
+ # Private BTW forks reserve and tombstone this identity before the CLI
+ # starts. An unqueried fork may reconnect from its parent using the same
+ # reservation; ordinary resumes must not pass --session-id again.
+ self.fork_session_id: str | None = None
# Desired and live Claude permission mode. Runtime changes update this
# only after the CLI accepts them; every later reconnect passes the same
# value back through ClaudeAgentOptions instead of silently reverting.
@@ -253,10 +277,9 @@ def __init__(
# process and stdout reader still look alive.
self.control_plane_failed = False
# Compatibility/public state for adapters which expose whether context
- # inspection is temporarily unavailable. A timed-out generation is now
- # replaced instead of quarantining its successor until another model
- # turn: the replacement skips only its eager startup probe and is safe
- # for an explicit user read immediately.
+ # inspection is temporarily unavailable. After a hard timeout, keep the
+ # replacement usable for normal work and pause optional inspection
+ # until a successful turn proves normal traffic has resumed.
self.context_probe_suppressed = False
self._last_context_usage: dict[str, Any] | None = None
# Unlike the rich control response, the latest main-chain assistant
@@ -295,6 +318,9 @@ def __init__(
# invalidates that whole generation, so give it one synchronous reset
# hook rather than leaving stale task ids on the resident context.
self.lifecycle_reset_callback: Callable[[], None] | None = None
+ # Release native resources only after confirmed close, never after a
+ # lost control connection or ordinary persistent-service detach.
+ self.native_close_callback: Callable[[], None] | None = None
# Machine sets this immediately before query(). It is copied onto every
# post-Result background envelope so even a parentless Stop hook or a
# newly-announced task remains attached to the turn that spawned it.
@@ -315,7 +341,11 @@ def __init__(
self._turn_background_release: asyncio.Event | None = None
self._pending_turn_background_release: asyncio.Event | None = None
self._pending_turn_origin_id: str | None = None
+ self._pending_compact = False
self._message_pump_error: BaseException | None = None
+ self._service_delivery_error: Exception | None = None
+ self._service_pending_commit: dict | None = None
+ self._service_ack_lock = asyncio.Lock()
self._message_route_lock = asyncio.Lock()
self._background_callbacks_pending = 0
self._background_callbacks_drained = asyncio.Event()
@@ -366,7 +396,14 @@ def _options(
"enter plan mode for them.\n"
"Modes: default, acceptEdits, plan, auto, bypassPermissions."
)
- extra_args = {"replay-user-messages": None}
+ # showThinkingSummaries is an interactive CLI preference. SDK sessions
+ # must request the readable summary explicitly. Pass only display here:
+ # setting thinking={type: adaptive, ...} would also override the user's
+ # native thinking mode/budget instead of just making its output visible.
+ extra_args = {
+ "replay-user-messages": None,
+ "thinking-display": "summarized",
+ }
auto_compact_mode, auto_compact_threshold = (
auto_compact_override
if auto_compact_override is not None
@@ -446,6 +483,7 @@ def _options(
cwd=session_cwd, # dynamic: must match the resumed session's cwd
cli_path=_explicit_cli_path(self.cfg.claude_bin),
resume=resume_id or None,
+ session_id=self.fork_session_id if fork else None,
# fork_session=True resumes `resume_id`'s context but writes new turns to
# a FRESH session id, leaving the original transcript untouched — used for
# ephemeral /btw side-forks.
@@ -547,7 +585,20 @@ async def connect(
auto_compact_override=launch_auto_compact,
effort_override=launch_effort,
)
- if self.isolate_account_env:
+ if self.cfg.claude_service_socket and self.service_metadata is not None:
+ from cc_remote.claude_service.client import RemoteClient
+
+ self.client = RemoteClient(
+ self.service_socket_override or self.cfg.claude_service_socket,
+ options=opts,
+ metadata={
+ **self.service_metadata, "session_id": resume_id,
+ "applied_auto_compact": list(launch_auto_compact),
+ "applied_effort": launch_effort,
+ },
+ isolated=self.isolate_account_env,
+ )
+ elif self.isolate_account_env:
self.client = ClaudeSDKClient(
options=opts,
transport=account_isolated_transport(opts),
@@ -556,6 +607,16 @@ async def connect(
self.client = ClaudeSDKClient(options=opts)
self._conversation_rewind_capability = None
await self.client.connect()
+ self.service_recovery = getattr(self.client, "recovery", None)
+ description = getattr(self.client, "description", {})
+ if description.get("attached"):
+ controls = description["controls"]
+ self.model = controls.get("model")
+ self.permission_mode = controls.get("permission_mode") or self.permission_mode
+ metadata = description["metadata"]
+ launch_effort = metadata.get("applied_effort")
+ launch_auto_compact = tuple(metadata["applied_auto_compact"])
+ await self._enable_attached_thinking_summaries()
if fork:
# A fork creates a new native conversation even though this handle
# may be reused while a private BTW id is still being captured.
@@ -566,6 +627,11 @@ async def connect(
# ordinary same-session reconnect; explicit transcript mutations and
# fresh forks invalidate it through invalidate_context_usage_cache().
self._last_context_usage = None
+ if self._last_recent_context_usage is not None:
+ # Counts survive a same-session reconnect; the old capacity does
+ # not (the reconnect may be applying a different native window).
+ self._last_recent_context_usage = {
+ "totalTokens": self._last_recent_context_usage["totalTokens"]}
self.effective_auto_compact_threshold_tokens = None
self.raw_context_max_tokens = None
self.control_plane_failed = False
@@ -577,6 +643,7 @@ async def connect(
# learning its provider-selected model and (for Work) startup baseline.
eager_context_probe = bool(
not _suppress_context_probe and resume_id is None and not fork
+ and self.service_recovery is None
)
if eager_context_probe:
try:
@@ -611,6 +678,7 @@ async def connect(
_launch_auto_compact=launch_auto_compact,
_launch_effort=launch_effort,
)
+ self.context_probe_suppressed = True
return
# Model readout is useful control state, but a semantic or
# capability failure does not poison an otherwise live child.
@@ -635,6 +703,15 @@ async def connect(
self.applied_auto_compact_threshold_tokens = (
launch_auto_compact[1])
self._start_message_pump()
+ if self.service_recovery is not None:
+ self._turn_active = True
+ self._turn_root_id = self.service_recovery["id"]
+ self._turn_origin_id = self.service_recovery["id"]
+ self._message_route_owner = "managed"
+ elif hasattr(self.client, "description"):
+ self._turn_origin_id = self.client.description.get("origin_id")
+ if not self.service_defer_events:
+ self.start_service_events()
log.info("sdk connected", resume=bool(resume_id), fork=fork, cwd=opts.cwd,
effort=launch_effort, permission_mode=self.permission_mode,
auto_compact=launch_auto_compact[0],
@@ -642,8 +719,37 @@ async def connect(
context_probe_suppressed=self.context_probe_suppressed,
sdk_version=SDK_VERSION)
+ async def _enable_attached_thinking_summaries(self) -> None:
+ """Opt a service-owned child into summaries without replacing its turn."""
+ # Reattaching does not apply new launch options to the resident CLI.
+ # The pinned SDK has no public setter, but its native control protocol
+ # can update display. cc-remote never sets a runtime thinking-token
+ # budget; omitting it retains the child's spawn-time default (including
+ # disabled thinking) and leaves effort, model and permission untouched.
+ # Claude Code 2.1.269 acknowledges this during a running turn, but that
+ # agent loop keeps its original config. Summaries apply to the next
+ # top-level query; steering does not restart the loop. Do not interrupt
+ # an active turn just to make this display preference take effect.
+ sender = getattr(getattr(self.client, "_query", None),
+ "_send_control_request", None)
+ if not callable(sender):
+ return
+ try:
+ async with self._control_request_lock:
+ await sender({
+ "subtype": "set_max_thinking_tokens",
+ "thinking_display": "summarized",
+ }, timeout=2.0)
+ except Exception as exc:
+ # Display is optional. Never interrupt, resubmit, replace the child,
+ # or make stream recovery fail for an older/unresponsive control.
+ # A later normal child launch still receives --thinking-display.
+ log.warning("Claude thinking summary update unavailable",
+ error_type=type(exc).__name__)
+
async def _read_context_usage_control(
self, *, timeout: float = _CONTEXT_CONTROL_TIMEOUT,
+ detail: str = "summary",
) -> dict:
"""Issue one bounded read on the pinned SDK control protocol."""
async with self._control_request_lock:
@@ -652,16 +758,17 @@ async def _read_context_usage_control(
client = self.client
if client is None:
raise RuntimeError("Claude SDK is not connected")
- # The public helper hardcodes a 60-second timeout. A timeout cannot
- # be cancelled in the child, so bound it tightly and poison this
- # exact generation rather than letting later controls pile up.
+ # The pinned Python helper omits detail and defaults to full,
+ # which calls the provider's token-count API for each category.
+ # The ring needs the CLI's local summary, with the SDK's normal
+ # deadline. Keep a hard timeout guard for a truly stalled child.
query = getattr(client, "_query", None)
send_control = getattr(query, "_send_control_request", None)
if not callable(send_control):
raise RuntimeError("bounded model control request unavailable")
try:
usage = await send_control(
- {"subtype": "get_context_usage"},
+ {"subtype": "get_context_usage", "detail": detail},
timeout=timeout,
)
except Exception as exc:
@@ -719,14 +826,15 @@ def _record_context_usage(
capture_work_baseline: bool = False,
) -> None:
"""Cache one successful reading and update generation metadata."""
- self._last_context_usage = dict(usage)
total_tokens = usage.get("totalTokens")
- if (isinstance(total_tokens, int)
- and not isinstance(total_tokens, bool)
- and 0 <= total_tokens <= MAX_SAFE_WIRE_INTEGER):
+ usable = (isinstance(total_tokens, int)
+ and not isinstance(total_tokens, bool)
+ and 0 <= total_tokens <= MAX_SAFE_WIRE_INTEGER)
+ if usable:
# Only a semantically usable exact response supersedes the latest
# live/transcript fallback. A malformed successful control envelope
# must not erase the last truthful reading.
+ self._last_context_usage = dict(usage)
self._last_recent_context_usage = None
if update_model:
model = usage.get("model") if isinstance(usage, dict) else None
@@ -754,19 +862,18 @@ def _record_context_usage(
# A session with no selection yet takes the branch above, which
# is how a fresh session learns its provider-selected model.
auto_threshold = usage.get("autoCompactThreshold")
- self.effective_auto_compact_threshold_tokens = (
- auto_threshold
- if isinstance(auto_threshold, int)
- and not isinstance(auto_threshold, bool)
- and auto_threshold >= 0 else None
- )
+ if (isinstance(auto_threshold, int)
+ and not isinstance(auto_threshold, bool)
+ and 0 <= auto_threshold <= MAX_SAFE_WIRE_INTEGER):
+ self.effective_auto_compact_threshold_tokens = auto_threshold
+ elif usable:
+ self.effective_auto_compact_threshold_tokens = None
raw_max = usage.get("rawMaxTokens")
- self.raw_context_max_tokens = (
- raw_max
- if isinstance(raw_max, int)
- and not isinstance(raw_max, bool)
- and raw_max >= 0 else None
- )
+ if (isinstance(raw_max, int) and not isinstance(raw_max, bool)
+ and 0 <= raw_max <= MAX_SAFE_WIRE_INTEGER):
+ self.raw_context_max_tokens = raw_max
+ elif usable:
+ self.raw_context_max_tokens = None
if (capture_work_baseline and self.work_mode
and self.work_context_baseline_tokens is None):
total_tokens = usage.get("totalTokens")
@@ -781,6 +888,10 @@ def cached_context_usage(self) -> dict | None:
return None
return dict(self._last_context_usage)
+ @property
+ def context_revision(self) -> int:
+ return self._context_revision
+
def invalidate_context_usage_cache(self) -> None:
"""Discard readings after the native transcript changes identity/depth.
@@ -789,6 +900,7 @@ def invalidate_context_usage_cache(self) -> None:
those cases the machine calls this at the mutation boundary so the next
cache-only read must recover from the current transcript.
"""
+ self._context_revision += 1
self._last_context_usage = None
self._last_recent_context_usage = None
self.effective_auto_compact_threshold_tokens = None
@@ -800,21 +912,43 @@ def _observe_recent_context_usage(self, message: Any) -> None:
return
recovered = claude_recent_context_usage(message.usage)
if recovered is not None:
- self._last_recent_context_usage = recovered
+ capacity = {
+ key: value for key, value in (self._last_recent_context_usage or {}).items()
+ if key in {"maxTokens", "rawMaxTokens", "autoCompactThreshold",
+ "isAutoCompactEnabled", "model"}
+ }
+ self._last_recent_context_usage = {**capacity, **recovered}
def _observe_context_boundary(self, message: Any) -> None:
- """Invalidate cached usage at every real native compact boundary."""
+ """Replace pre-compact counts while retaining this child's capacity."""
if (
isinstance(message, SystemMessage)
and message.subtype == "compact_boundary"
+ and isinstance(message.data, dict)
+ and message.data.get("parent_tool_use_id") is None
+ and message.data.get("parentToolUseID") is None
+ and message.data.get("isSidechain") is not True
):
- self.invalidate_context_usage_cache()
+ self._context_revision += 1
+ capacity = {
+ key: value for key, value in {
+ **(self._last_context_usage or {}),
+ **(self._last_recent_context_usage or {}),
+ }.items()
+ if key in {"maxTokens", "rawMaxTokens", "autoCompactThreshold",
+ "isAutoCompactEnabled", "model"}
+ }
+ self._last_context_usage = None
+ usage = compact_context_usage(message.data)
+ self._last_recent_context_usage = (
+ {**capacity, **usage} if usage is not None else None
+ )
def remember_recent_context_usage(self, usage: dict[str, Any]) -> None:
"""Seed a source-validated transcript fallback after cold resume."""
total = usage.get("totalTokens") if isinstance(usage, dict) else None
if (not isinstance(total, int) or isinstance(total, bool)
- or total <= 0 or total > MAX_SAFE_WIRE_INTEGER):
+ or total < 0 or total > MAX_SAFE_WIRE_INTEGER):
return
self._last_recent_context_usage = dict(usage)
@@ -839,11 +973,13 @@ def _discard_pending_turn_route(self) -> None:
release.set()
self._pending_turn_background_release = None
self._pending_turn_origin_id = None
+ self._pending_compact = False
async def query(self, prompt) -> None:
"""Send a request. `prompt` is a string, or an async iterable of user-
message dicts (used for multimodal input — text + image blocks)."""
async with self._control_request_lock:
+ self.check_service_delivery()
if self.control_plane_failed:
raise RuntimeError("Claude SDK control plane is unhealthy")
client = self.client
@@ -868,6 +1004,7 @@ async def query(self, prompt) -> None:
"Claude SDK message pump is not running"
) from self._message_pump_error
await self._background_callbacks_drained.wait()
+ self.check_service_delivery()
if (
self._message_pump_error is not None
or self._message_pump_task.done()
@@ -892,6 +1029,16 @@ async def query(self, prompt) -> None:
# activated only when the pump sees this submitted turn.
self._pending_turn_background_release = asyncio.Event()
self._pending_turn_origin_id = self.next_turn_id
+ self._turn_root_id = self.next_turn_id
+ if hasattr(client, "next_turn"):
+ client.next_turn = {
+ **(self.service_turn_metadata or {}),
+ "id": self.next_turn_id,
+ }
+ self._pending_compact = bool(
+ isinstance(prompt, str)
+ and prompt.split(maxsplit=1)[:1] == ["/compact"]
+ )
self.next_turn_id = None
self._turn_active = True
try:
@@ -905,9 +1052,34 @@ async def query(self, prompt) -> None:
# going through connect(). Real SDK connections always use the sole pump.
await client.query(prompt)
+ async def steer(self, prompt, *, native_id: str, metadata: dict) -> None:
+ """Write `priority=next` without interrupting or creating another reader."""
+ async with self._control_request_lock:
+ async with self._message_route_lock:
+ self.check_service_delivery()
+ client = self.client
+ if (client is None or self.control_plane_failed
+ or self.message_pump_failed or not self._turn_active
+ or self._message_pump_task is None):
+ raise ClaudeSteerRejected("Claude has no active response")
+ if hasattr(client, "steer"):
+ # The persistent owner checks its live boundary too; its
+ # terminal may already be ahead of this controller's poll.
+ await client.steer(prompt, native_id=native_id,
+ metadata=metadata, turn_id=self._turn_root_id)
+ else:
+ self._steers.add(native_id, metadata)
+
+ async def stream():
+ yield steer_message(prompt, native_id)
+
+ # Keep registration on uncertain writes: a late exact echo
+ # can still confirm acceptance. Never retry this as Query.
+ await client.query(stream())
+
async def interrupt(self) -> None:
assert self.client is not None
- await self.client.interrupt()
+ await self._steers.interrupt(self.client)
async def set_model(self, model: str) -> None:
"""Switch the model for the live cc subprocess (takes effect next query,
@@ -947,12 +1119,53 @@ def set_auto_compact(
self.auto_compact_mode = checked_mode
self.auto_compact_threshold_tokens = checked_threshold
- async def get_context_usage(self) -> dict:
+ async def get_context_usage(self, *, detail: str = "summary") -> dict:
"""Return the cc session's context window usage (matches CLI /context)."""
- usage = await self._read_context_usage_control()
+ if detail not in {"summary", "full"}:
+ raise ValueError("invalid Claude context detail")
+ if self.context_probe_suppressed:
+ raise RuntimeError("Claude context refresh is awaiting normal traffic")
+ usage = await self._read_context_usage_control(detail=detail)
self._record_context_usage(usage, update_model=True)
return usage
+ async def get_context_summary(self) -> dict:
+ """Read the CLI's local summary without waiting for a model turn.
+
+ Unlike the full category breakdown, summary uses local estimates and
+ the last API usage; it never invokes the provider's token-count API.
+ Keep this a separate capability so broker/older adapters cannot route a
+ running read through their potentially expensive context operation.
+ """
+ if self.context_probe_suppressed:
+ raise RuntimeError("Claude context refresh is awaiting normal traffic")
+ if self._control_request_lock.locked():
+ # Metadata must not queue behind a launch/setter that can itself be
+ # waiting for a machine callback to finish accepting the next turn.
+ raise RuntimeError("Claude control request is already in progress")
+ client, revision = self.client, self._context_revision
+ previous_recent = self._last_recent_context_usage
+ usage = await self._read_context_usage_control(
+ timeout=_CONTEXT_STARTUP_TIMEOUT, detail="summary")
+ if self.client is not client or self._context_revision != revision:
+ raise RuntimeError("Claude context changed during summary read")
+ total, maximum = usage.get("totalTokens"), usage.get("maxTokens")
+ if (not isinstance(total, int) or isinstance(total, bool)
+ or not 0 <= total <= MAX_SAFE_WIRE_INTEGER
+ or not isinstance(maximum, int) or isinstance(maximum, bool)
+ or not 0 < maximum <= MAX_SAFE_WIRE_INTEGER):
+ raise ValueError("Claude summary omitted a valid total or capacity")
+ latest_recent = self._last_recent_context_usage
+ self._record_context_usage(usage, update_model=True)
+ if latest_recent is not previous_recent and latest_recent is not None:
+ # The sole stream reader may have received a newer assistant while
+ # the control response was in flight. Keep its total and only borrow
+ # this generation's capacity, never the older category breakdown.
+ self._last_recent_context_usage = latest_recent
+ usage = {**usage, **latest_recent, "categories": []}
+ usage["percentage"] = latest_recent["totalTokens"] / maximum * 100
+ return usage
+
async def rewind_files(self, user_message_id: str) -> None:
"""Restore SDK-checkpointed files to a UserMessage UUID."""
target = validate_rewind_target(
@@ -1253,8 +1466,13 @@ def _start_message_pump(self) -> None:
self._background_messages = asyncio.Queue(maxsize=cap)
self._turn_active = False
self._turn_consumer_active = False
+ self._steers = PendingSteers()
+ self._turn_root_id = None
self._message_route_owner = None
self._message_pump_error = None
+ self._service_delivery_error = None
+ self._service_pending_commit = None
+ self._service_ack_lock = asyncio.Lock()
self._message_route_lock = asyncio.Lock()
self._background_callbacks_pending = 0
self._background_callbacks_drained = asyncio.Event()
@@ -1264,6 +1482,7 @@ def _start_message_pump(self) -> None:
self._turn_background_release = initial_release
self._pending_turn_background_release = None
self._pending_turn_origin_id = None
+ self._pending_compact = False
client = self.client
assert client is not None
self._message_pump_task = asyncio.create_task(
@@ -1284,9 +1503,34 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None:
source = client.receive_messages()
parse_raw = False
async for data in source:
+ service_seed = bool(parse_raw and isinstance(data, dict)
+ and "__cc_service_origin" in data)
+ service_origin = data.pop("__cc_service_origin", None) if service_seed else None
+ service_seq = (
+ data.pop("__cc_service_seq", None)
+ if parse_raw and isinstance(data, dict) else None
+ )
+ service_ts = (
+ data.pop("__cc_service_ts", None)
+ if parse_raw and isinstance(data, dict) else None
+ )
+ if parse_raw and isinstance(data, dict):
+ data = self._steers.annotate(data)
+ steer = data.get("__cc_steer") if parse_raw else None
+ intermediate = bool(parse_raw and data.get("__cc_steer_intermediate"))
message = self._parse_compat_message(data) if parse_raw else data
if message is None:
continue
+ if service_seed:
+ message._cc_service_seed = True
+ if service_seq is not None:
+ message._cc_service_seq = service_seq
+ if service_ts is not None:
+ message._cc_service_ts = service_ts
+ if steer is not None:
+ message._cc_steer = steer
+ if parse_raw and data.get("__cc_steer_cancelled"):
+ message._cc_steer_cancelled = data["__cc_steer_cancelled"]
self._observe_recent_context_usage(message)
self._observe_context_boundary(message)
self._observe_model_fallback(message)
@@ -1299,7 +1543,13 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None:
isinstance(message, UserMessage)
and not message.parent_tool_use_id
)
- if top_level_user:
+ if service_seed:
+ owner = "background"
+ elif getattr(message, "_cc_steer_cancelled", None) and self._turn_active:
+ owner = "managed"
+ self._activate_pending_turn_route()
+ self._message_route_owner = owner
+ elif top_level_user:
if origin_kind is not None and origin_kind != "human":
owner = "background"
elif self._turn_active:
@@ -1308,6 +1558,29 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None:
else:
owner = "background"
self._message_route_owner = owner
+ elif (
+ isinstance(message, SystemMessage)
+ and self._turn_active
+ and self._pending_compact
+ and origin_kind in {None, "human"}
+ and self._message_route_owner != "background"
+ and isinstance(message.data, dict)
+ and message.data.get("parent_tool_use_id") is None
+ and message.data.get("parentToolUseID") is None
+ and (
+ (message.subtype == "compact_boundary"
+ and compact_metadata(message.data).get("trigger") == "manual")
+ or (message.subtype == "status"
+ and message.data.get("status") == "compacting")
+ )
+ ):
+ # Native /compact can emit its boundary before replaying
+ # the submitted UserMessage. Only this pending command
+ # may claim it; ordinary prompts and autonomous turns
+ # keep their existing provenance-based routing.
+ owner = "managed"
+ self._activate_pending_turn_route()
+ self._message_route_owner = owner
elif isinstance(message, ResultMessage):
# Result.origin is the authoritative boundary in the
# pinned SDK. A non-human result closes only the injected
@@ -1343,9 +1616,17 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None:
)
if owner == "managed":
+ if isinstance(message, ResultMessage) and (
+ intermediate or self._steers.pending):
+ # The original response ended just before an accepted
+ # input was consumed. Keep the same sole consumer.
+ continue
+ if steer is not None:
+ self._turn_origin_id = steer["id"]
await self._turn_messages.put(message)
if isinstance(message, ResultMessage):
self._turn_active = False
+ self._pending_compact = False
self._message_route_owner = None
continue
release = self._turn_background_release
@@ -1356,7 +1637,7 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None:
self._background_callbacks_drained.clear()
try:
await self._background_messages.put(
- (message, release, self._turn_origin_id))
+ (message, release, service_origin if service_seed else self._turn_origin_id))
except BaseException:
self._background_callback_completed()
raise
@@ -1421,16 +1702,22 @@ async def _background_message_worker(self) -> None:
if (
callback is not None
and self._message_pump_error is None
+ and self._service_delivery_error is None
):
await callback(message, turn_id)
+ await self.ack_service_message(message)
except asyncio.CancelledError:
raise
except Exception as exc:
- # One malformed/background notification must not kill the sole
- # SDK reader and make the next user query hang forever.
log.warning(
"Claude background message callback failed",
error_type=type(exc).__name__)
+ if (getattr(message, "_cc_service_seq", None) is not None
+ or getattr(message, "_cc_service_seed", False)):
+ # ACK is cumulative. Keep reading (the native task is
+ # healthy), but do not project/ack past this hole. A fresh
+ # controller reconstructs state from the retained journal.
+ await self._fail_service_delivery(exc)
finally:
self._background_callback_completed()
@@ -1475,6 +1762,63 @@ def receive_response(self):
return self._receive_response_pumped()
return self._receive_response_compat()
+ def start_service_events(self) -> None:
+ """Release native replay only after the machine installed its routing."""
+ ready = getattr(self.client, "ready", None)
+ if ready is not None:
+ ready.set()
+
+ def check_service_delivery(self) -> None:
+ if self._service_delivery_error is not None:
+ raise ClaudeServiceReplayRequired() from self._service_delivery_error
+
+ async def _fail_service_delivery(self, error: Exception) -> None:
+ if self._service_delivery_error is None:
+ self._service_delivery_error = error
+ # Retire stale UI claims without poisoning the native reader: its
+ # failure flag triggers a destructive reconnect before the next query.
+ await self._notify_message_pump_failure(ClaudeServiceReplayRequired())
+
+ async def ack_service_message(self, message, *, turn_id=None) -> None:
+ seq = getattr(message, "_cc_service_seq", None)
+ if not hasattr(self.client, "call"):
+ return
+ async with self._service_ack_lock:
+ self.check_service_delivery()
+ try:
+ if turn_id is not None:
+ if seq is None:
+ return
+ self._service_pending_commit = {
+ "turn_id": self._turn_root_id or turn_id, "seq": seq}
+ # A Result can overtake an earlier background callback.
+ # Defer its cumulative commit instead of waiting here:
+ # callbacks may still need the managed Result's release.
+ if self._background_callbacks_pending:
+ return
+ elif seq is not None:
+ await self.client.call("ack", {"seq": seq})
+ # The background worker still counts the delivered callback
+ # until this method returns (including replay seeds with no seq).
+ remaining = self._background_callbacks_pending - (1 if turn_id is None else 0)
+ if (self._service_pending_commit is not None
+ and remaining <= 0):
+ await self.client.call("commit", self._service_pending_commit)
+ self._service_pending_commit = None
+ self.service_recovery = None
+ except Exception as exc:
+ await self._fail_service_delivery(exc)
+ self.check_service_delivery()
+
+ async def detach_for_shutdown(self) -> None:
+ detach = getattr(self.client, "detach", None)
+ if detach is None:
+ await self.disconnect()
+ return
+ await detach()
+ await self._stop_message_pump()
+ self.client = None
+
async def _stop_message_pump(self) -> None:
release = self._turn_background_release
if release is not None:
@@ -1507,6 +1851,12 @@ async def disconnect(self) -> None:
if self.client is not None:
await self._stop_message_pump()
await self.client.disconnect()
+ if self.service_metadata is not None:
+ # A deliberate reconnect may now create a new worker; a
+ # failed close must retain the original recovery identity.
+ self.service_metadata.pop("service_id", None)
+ if self.native_close_callback is not None:
+ self.native_close_callback()
finally:
self.client = None
self._conversation_rewind_capability = None
diff --git a/cc_remote/wrapper/session_ctx.py b/cc_remote/wrapper/session_ctx.py
index 19af3531..b6328604 100644
--- a/cc_remote/wrapper/session_ctx.py
+++ b/cc_remote/wrapper/session_ctx.py
@@ -234,6 +234,7 @@ class SessionContext:
# inherits its context. Never persisted, excluded from the session list, and
# discarded on close. Its turns reuse the normal _run_turn path.
btw: bool = False
+ claude_service_background_replay: object | None = None
parent_sid: Optional[str] = None
# Relay-authenticated account identity for a private side chat. The legacy
# attribute name is retained for state/test compatibility; this is not a
@@ -252,6 +253,10 @@ class SessionContext:
# cc fork_session persists a transcript under a new id (unlike codex's
# ephemeral fork); capture it here so close_btw can hard-delete it.
btw_real_id: Optional[str] = None
+ # Claude's native --session-id, durably hidden before fork startup. Keep it
+ # separate from btw_real_id: before the first turn no transcript exists to
+ # resume, so reconnect must still fork from the parent with this same id.
+ btw_reserved_id: Optional[str] = None
announced_model: Optional[str] = None
announced_effort: Optional[str] = None
# Claude autocompact is a spawn-time session option. Keep the last public
@@ -306,6 +311,7 @@ class SessionContext:
# response. Keep Code's private attachment directories alive until the
# enclosing native turn reaches its authoritative terminal boundary.
codex_steer_attachment_dirs: list[str] = field(default_factory=list)
+ claude_steer_attachment_dirs: list[str] = field(default_factory=list)
# A timed-out turn/steer may still have been accepted by app-server. Keep
# exactly one bounded user boundary until an authoritative userMessage item
# with the same clientId confirms it, or the enclosing turn terminates.
diff --git a/cc_remote/wrapper/stream.py b/cc_remote/wrapper/stream.py
index d8b03ae9..341a71ae 100644
--- a/cc_remote/wrapper/stream.py
+++ b/cc_remote/wrapper/stream.py
@@ -39,8 +39,10 @@
TurnEnd, TurnResult, UserMsg,
)
from cc_remote.wrapper.sanitize import bounded_text, bounded_tool_input
+from cc_remote.wrapper.claude_compaction import compact_metadata
from cc_remote.wrapper.claude_model_fallback import FALLBACK_TOOL, model_fallback_event
from cc_remote.wrapper.turn_changes import native_claude_diff
+from cc_remote.wrapper.token_usage import UsageLedger, native_usage
_SAFE_SESSION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$")
_SAFE_WIRE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$")
@@ -675,6 +677,7 @@ def __init__(self, tool_result_max: int, turn_id: str | None = None,
self.item_commands = (
item_commands if item_commands is not None else {})
self._message_ids: dict[str, str] = {}
+ self._message_turn_id: str | None = None
self._started_channels: set[str] = set()
# Only the emitted prefix LENGTH is needed to deduplicate the assembled
# AssistantMessage after streaming deltas. Retaining and repeatedly
@@ -716,6 +719,19 @@ def __init__(self, tool_result_max: int, turn_id: str | None = None,
# transcript UUID. Keep it separate from the browser's optimistic turn
# id and from tool-result user envelopes.
self._last_user_uuid: str | None = None
+ self._compaction_id: str | None = None
+ self._usage = UsageLedger()
+ self._usage_message: tuple[str, str | None] | None = None
+ self._usage_rebound = False
+
+ def rebind_turn(self, turn_id: str) -> None:
+ """Advance at a native input echo while retaining unfinished item owners."""
+ self._usage_rebound = True
+ self.turn_id = turn_id
+ self._ambiguous_final_mid = None
+ self._has_final_text = False
+ self._last_assistant_uuid = None
+ self._last_user_uuid = None
def _remember_turn(self, item_id: str, parent_id: str | None = None) -> str | None:
turn = (self.item_turns.get(item_id)
@@ -754,8 +770,10 @@ def _ensure_channel(self, events: list, channel_key: str, channel: str,
suggested: str | None = None) -> str:
mid = self._message_id(channel_key, suggested)
if channel_key not in self._started_channels:
+ if not self._started_channels:
+ self._message_turn_id = self.turn_id
events.append(AssistantMsgStart(
- message_id=mid, turn_id=self.turn_id, channel=channel))
+ message_id=mid, turn_id=self._message_turn_id, channel=channel))
self._started_channels.add(channel_key)
return mid
@@ -768,7 +786,7 @@ def _append_text(self, events: list, channel_key: str, channel: str,
return
mid = self._ensure_channel(events, channel_key, channel, suggested)
events.append(Delta(
- message_id=mid, turn_id=self.turn_id,
+ message_id=mid, turn_id=self._message_turn_id,
text=bounded, channel=channel))
self._emitted[channel_key] += len(bounded)
@@ -776,11 +794,11 @@ def _finish_message(self, events: list, text_channel: str) -> None:
if "thinking" in self._started_channels:
events.append(AssistantMsgEnd(
message_id=self._message_ids["thinking"],
- turn_id=self.turn_id, channel="thinking"))
+ turn_id=self._message_turn_id, channel="thinking"))
if "text" in self._started_channels:
events.append(AssistantMsgEnd(
message_id=self._message_ids["text"],
- turn_id=self.turn_id, channel=text_channel))
+ turn_id=self._message_turn_id, channel=text_channel))
self._message_ids.clear()
self._started_channels.clear()
self._emitted = {"thinking": 0, "text": 0}
@@ -1082,6 +1100,21 @@ def _flush_tool_deltas(self, tool_id: str, except_stream: str | None = None) ->
def _feed_stream_event(self, msg: StreamEvent) -> list:
events: list = []
ev = msg.event if isinstance(msg.event, dict) else {}
+ if not msg.parent_tool_use_id:
+ if ev.get("type") == "message_start":
+ message = ev.get("message")
+ if not isinstance(message, dict):
+ self._usage_message = None
+ return events
+ mid = message.get("id")
+ self._usage_message = (mid, self.turn_id) if isinstance(mid, str) else None
+ if self._usage_message:
+ events.extend(self._usage.update(self.turn_id, mid,
+ native_usage(message.get("usage"), "claude", output=False)))
+ elif ev.get("type") == "message_delta" and self._usage_message:
+ mid, owner = self._usage_message
+ events.extend(self._usage.update(owner, mid,
+ native_usage(ev.get("usage"), "claude")))
if ev.get("type") != "content_block_delta":
return events
delta = ev.get("delta") if isinstance(ev.get("delta"), dict) else {}
@@ -1097,6 +1130,13 @@ def _feed_stream_event(self, msg: StreamEvent) -> list:
def _feed_assistant(self, msg: AssistantMessage) -> list:
events: list = []
+ if not msg.parent_tool_use_id and msg.message_id:
+ owner = (self._usage_message[1] if self._usage_message
+ and self._usage_message[0] == msg.message_id else self.turn_id)
+ # Assembled blocks repeat the message-start usage, including an
+ # output placeholder. Only message_delta/Result can supply output.
+ events.extend(self._usage.update(owner, msg.message_id,
+ native_usage(msg.usage, "claude", output=False)))
if (isinstance(msg.uuid, str)
and _CLAUDE_MESSAGE_UUID.fullmatch(msg.uuid)):
self._last_assistant_uuid = msg.uuid
@@ -1436,8 +1476,37 @@ def _feed_compaction(self, msg: SystemMessage) -> list[ProcessEvent]:
if event is None:
return []
event.turn_id = self.turn_id
+ if self._compaction_id is not None:
+ # Status UUIDs and persisted boundary UUIDs differ. Bind the live
+ # placeholder to its actual boundary so later history deduplicates.
+ event.input = {"compaction_started_id": self._compaction_id}
+ self._compaction_id = None
return [event]
+ def _feed_compaction_status(self, msg: SystemMessage) -> list[ProcessEvent]:
+ data = msg.data if isinstance(msg.data, dict) else {}
+ if data.get("status") != "compacting":
+ if data.get("compact_error") and self._compaction_id is not None:
+ return self._end_unfinished_compaction("failed")
+ return []
+ if self._compaction_id is not None:
+ return [] # Native heartbeats do not create new animations.
+ self._compaction_id = _wire_id(
+ data.get("uuid") or str(uuid.uuid4()), "compaction")
+ return [ProcessEvent(
+ item_id=self._compaction_id, kind="compaction", phase="start",
+ status="running", title="压缩上下文", turn_id=self.turn_id,
+ )]
+
+ def _end_unfinished_compaction(self, status: str) -> list[ProcessEvent]:
+ if self._compaction_id is None:
+ return []
+ item_id, self._compaction_id = self._compaction_id, None
+ return [ProcessEvent(
+ item_id=item_id, kind="compaction", phase="end", status=status,
+ title="压缩上下文", turn_id=self.turn_id,
+ )]
+
def _feed_hook(self, msg: HookEventMessage) -> list:
data = msg.data if isinstance(msg.data, dict) else {}
parent_raw = (data.get("tool_use_id") or data.get("toolUseID")
@@ -1512,9 +1581,17 @@ def feed(self, msg) -> list:
return self._feed_background_tasks_changed(msg)
if msg.subtype == "compact_boundary":
return self._feed_compaction(msg)
+ if msg.subtype == "status":
+ return self._feed_compaction_status(msg)
return []
if isinstance(msg, ResultMessage):
- events = []
+ # A terminal without a boundary must not leave an eternal spinner
+ # or invent a successful compact (including interrupted commands).
+ events = self._end_unfinished_compaction(
+ "interrupted" if msg.is_error else "failed")
+ if not self._usage_rebound and not msg.is_error:
+ events.extend(self._usage.replace(self.turn_id,
+ native_usage(msg.usage, "claude")))
if (not msg.is_error and not self._has_final_text
and self._ambiguous_final_mid is not None):
events.append(AssistantMsgEnd(
@@ -1728,6 +1805,7 @@ def _compact_visible_user(row: dict[str, Any]) -> bool:
origin = row.get("origin")
if (
row.get("type") != "user"
+ or bool(row.get("isMeta"))
or origin == "task-notification"
or (
isinstance(origin, dict)
@@ -2144,6 +2222,7 @@ def _delayed_retry_tail(
if isinstance(row.get("sessionId"), str) else None
),
message=message,
+ is_meta=bool(row.get("isMeta")),
parent_tool_use_id=(
row.get("parentToolUseID")
or row.get("parent_tool_use_id")
@@ -2383,6 +2462,7 @@ def _load_compact_chain_messages(
uuid=uid,
session_id=session_id,
message=message,
+ is_meta=bool(row.get("isMeta")),
parent_tool_use_id=(
row.get("parentToolUseID")
or row.get("parent_tool_use_id")
@@ -2625,12 +2705,10 @@ def _compaction_event_from_row(row: dict[str, Any]) -> ProcessEvent | None:
and _SAFE_WIRE_ID.fullmatch(uid)
):
return None
- metadata = row.get("compactMetadata")
- if not isinstance(metadata, dict):
- metadata = {}
+ metadata = compact_metadata(row)
trigger = metadata.get("trigger")
- pre_tokens = metadata.get("preTokens")
- post_tokens = metadata.get("postTokens")
+ pre_tokens = metadata.get("pre_tokens")
+ post_tokens = metadata.get("post_tokens")
summary_bits: list[str] = []
if trigger == "auto":
summary_bits.append("自动压缩")
@@ -2641,7 +2719,7 @@ def _compaction_event_from_row(row: dict[str, Any]) -> ProcessEvent | None:
for value in (pre_tokens, post_tokens)
):
summary_bits.append(f"{pre_tokens:,} → {post_tokens:,} tokens")
- duration = metadata.get("durationMs")
+ duration = metadata.get("duration_ms")
duration_ms = (
duration
if isinstance(duration, int) and not isinstance(duration, bool)
@@ -2968,6 +3046,12 @@ def close_turn(
message_uid = _history_id(source_uid, "msg", str(message_index))
if role == "user":
+ if (getattr(m, "is_meta", False)
+ and source_uid not in (internal_user_events or {})):
+ # Native recovery/continuation prompts are part of the current
+ # human turn. Match the SDK's isMeta filter even when reading
+ # raw compact ancestry, without guessing from prompt text.
+ continue
if isinstance(content, str):
internal_event = (internal_user_events or {}).get(source_uid)
if internal_event is not None:
@@ -3003,6 +3087,13 @@ def close_turn(
current_turn_id = message_uid
background_followup = False
elif isinstance(content, list):
+ if content and all(
+ isinstance(block, dict) and block.get("type") == "text"
+ and isinstance(block.get("text"), str)
+ and _is_meta_user_text(block["text"])
+ for block in content
+ ):
+ continue
if _is_interrupted_user_content(content):
misplaced_alias = client_message_ids.get(message_uid)
pending_interrupted_alias = (
@@ -3108,6 +3199,10 @@ def close_turn(
elif role == "system":
internal_event = (internal_user_events or {}).get(source_uid)
if internal_event is not None:
+ # Manual compaction can finish minutes after an answer. Keep
+ # its process timestamp without retiming the settled response.
+ if settled_answer_seen or ambiguous_final_mid is not None:
+ advance_terminal_clock = False
event = internal_event.model_copy(deep=True)
event.turn_id = event.turn_id or current_turn_id
timestamp = _ts(source_uid)
@@ -3356,7 +3451,10 @@ def close_turn(
if (
mts is not None
and advance_terminal_clock
- and not background_followup
+ # A notification alone cannot extend a settled answer. Actual
+ # assistant output after it is a new conversational continuation
+ # and must advance the footer to its real source time.
+ and (not background_followup or role == "assistant")
):
last_ts = mts
# Claude's transcript does not persist the SDK ResultMessage. EOF normally
@@ -3562,6 +3660,7 @@ def _is_meta_user_text(text: str) -> bool:
or t.startswith("")
or t.startswith("")
or t.startswith("")
+ or t.startswith("")
or t.startswith("")
or t.startswith("")
)
diff --git a/cc_remote/wrapper/token_usage.py b/cc_remote/wrapper/token_usage.py
new file mode 100644
index 00000000..7b9a22f9
--- /dev/null
+++ b/cc_remote/wrapper/token_usage.py
@@ -0,0 +1,139 @@
+"""Bounded, replace-only token accounting from native usage (never text estimates)."""
+from __future__ import annotations
+
+from collections import OrderedDict
+import re
+
+from cc_remote.protocol import MAX_SAFE_WIRE_INTEGER, TokenUsage, TurnUsage
+
+
+def count(value: object) -> int | None:
+ return value if type(value) is int and 0 <= value <= MAX_SAFE_WIRE_INTEGER else None
+
+
+def native_usage(raw: object, engine: str, *, output: bool = True) -> TokenUsage | None:
+ if not isinstance(raw, dict):
+ return None
+ if engine == "claude":
+ keys = ("input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens")
+ else:
+ keys = ("inputTokens", "outputTokens", "cachedInputTokens" if engine == "codex" else "cacheReadTokens", "cacheWriteTokens")
+ values = [count(raw.get(key)) for key in keys]
+ if not output:
+ values[1] = None
+ # Claude and DSH report disjoint cache/input categories; Codex includes
+ # cached input in inputTokens already. Missing optional cache fields are 0.
+ if values[0] is not None and engine != "codex":
+ values[0] = count(values[0] + (values[2] or 0) + (values[3] or 0))
+ if all(value is None for value in values):
+ return None
+ return TokenUsage(input_tokens=values[0], output_tokens=values[1],
+ cache_read_tokens=values[2], cache_write_tokens=values[3])
+
+
+class UsageLedger:
+ """Each native response owns one cumulative sample, even after replay.
+
+ Never evict individual samples within a turn: their tombstones prevent a
+ replayed response from being counted twice. Excess samples are ignored.
+ """
+
+ def __init__(self):
+ self.turns: OrderedDict[str, dict[str, TokenUsage]] = OrderedDict()
+ self.totals: dict[str, TokenUsage] = {}
+
+ def update(self, owner: str | None, key: str, usage: TokenUsage | None) -> list[TurnUsage]:
+ if not owner or usage is None:
+ return []
+ if owner not in self.turns:
+ self.turns[owner] = {}
+ if len(self.turns) > 32:
+ old, _ = self.turns.popitem(last=False)
+ self.totals.pop(old, None)
+ samples = self.turns[owner]
+ if key not in samples and len(samples) >= 4096:
+ return []
+ previous = samples.get(key, TokenUsage())
+ merged = previous.model_copy(update={
+ name: max(value, getattr(previous, name) or 0)
+ for name, value in usage.model_dump().items() if value is not None
+ })
+ if merged == previous:
+ return []
+ samples[key] = merged
+ fields = {}
+ for name in TokenUsage.model_fields:
+ values = [getattr(sample, name) for sample in samples.values()
+ if getattr(sample, name) is not None]
+ fields[name] = count(sum(values)) if values else None
+ total = TokenUsage(**fields)
+ return self.replace(owner, total)
+
+ def replace(self, owner: str | None, total: TokenUsage | None) -> list[TurnUsage]:
+ if not owner or total is None or self.totals.get(owner) == total:
+ return []
+ self.totals[owner] = total
+ return [TurnUsage(turn_id=owner, usage=total)]
+
+ def settle(self, owner: str, provisional: str, durable: str) -> None:
+ samples = self.turns.get(owner, {})
+ sample = samples.pop(provisional, None)
+ if sample is not None:
+ samples.setdefault(durable, sample)
+
+
+class CodexUsageTracker:
+ """Difference the native thread totals at exact turn boundaries.
+
+ A cold attachment without a baseline starts with the latest reported
+ request. Later updates use cumulative totals, so duplicate notifications
+ and identical consecutive requests remain distinguishable.
+ """
+
+ def __init__(self):
+ self.previous: TokenUsage | None = None
+ self.owner: str | None = None
+ self.total = TokenUsage()
+
+ def feed(self, message: dict) -> TurnUsage | None:
+ method = message.get("method")
+ params = message.get("params")
+ if not isinstance(params, dict):
+ return None
+ turn = params.get("turn")
+ owner = params.get("turnId") or (turn.get("id") if isinstance(turn, dict) else None)
+ if owner is not None and (not isinstance(owner, str)
+ or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}", owner)):
+ return None
+ if method == "thread/compacted":
+ self.previous = None
+ return None
+ if method == "turn/started" and isinstance(owner, str) and owner != self.owner:
+ self.owner, self.total = owner, TokenUsage()
+ if method != "thread/tokenUsage/updated" or not isinstance(owner, str):
+ return None
+ if self.owner is not None and owner != self.owner:
+ return None # a delayed update must not reset the active baseline
+ raw = params.get("tokenUsage")
+ if not isinstance(raw, dict):
+ return None
+ current = native_usage(raw.get("total"), "codex")
+ last = native_usage(raw.get("last"), "codex")
+ if owner != self.owner:
+ self.owner, self.total = owner, TokenUsage()
+ self.previous = None # joining an already-active native turn
+ if current is None:
+ return None # without cumulative totals repeated requests are ambiguous
+ values = {}
+ for name in TokenUsage.model_fields:
+ new = getattr(current, name)
+ old = getattr(self.previous, name) if self.previous else None
+ delta = new - old if new is not None and old is not None and new >= old else (
+ getattr(last, name) if last else None)
+ values[name] = count((getattr(self.total, name) or 0) + delta) if delta is not None else getattr(self.total, name)
+ self.previous = current
+ total = TokenUsage(**values)
+ if total == self.total:
+ return None
+ self.total = total
+ return TurnUsage(turn_id=owner, usage=total)
diff --git a/cc_remote/wrapper/work_context.py b/cc_remote/wrapper/work_context.py
index 763213cd..9467189a 100644
--- a/cc_remote/wrapper/work_context.py
+++ b/cc_remote/wrapper/work_context.py
@@ -6,6 +6,7 @@
from typing import Any
from cc_remote.protocol import MAX_SAFE_WIRE_INTEGER
+from cc_remote.wrapper.claude_compaction import compact_context_usage
from cc_remote.wrapper.codex_sessions import codex_rollout_path
from cc_remote.wrapper.stream import _bounded_jsonl_lines, transcript_path
@@ -116,6 +117,15 @@ def recover_claude_context_usage(
record = json.loads(raw)
except (UnicodeError, ValueError):
continue
+ if (isinstance(record, dict)
+ and record.get("type") == "system"
+ and record.get("subtype") == "compact_boundary"
+ and record.get("isSidechain") is not True
+ and record.get("parentToolUseID") is None
+ and record.get("parent_tool_use_id") is None):
+ # Never resurrect a pre-compact assistant count when the latest
+ # boundary has no post count (older CLI versions can omit it).
+ return compact_context_usage(record)
if (not isinstance(record, dict)
or record.get("type") != "assistant"
or record.get("isSidechain") is True
diff --git a/deploy/README.md b/deploy/README.md
index 4104799b..bd77fb40 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -41,6 +41,14 @@ Before changing a live service:
outside immutable release trees. Never upload secrets as part of a source
snapshot.
+For Claude, follow [session-service installation and acceptance](../docs/claude-session-service.md)
+before planning a non-interrupting Wrapper upgrade. Keep an existing SDK service
+outside the Wrapper activation transaction. First migration, remaining
+in-process turns (including private `/btw` forks), and deferred queries must
+drain first; daemon readiness alone does not prove an old child was adopted.
+If the local service protocol or pinned SDK changes, stage first and defer the
+service's own restart until native work and pending callbacks have finished.
+
Activate a coordinated protocol change in the order documented by the current
protocol note below: stop incompatible old Wrappers, activate Relay + Web as one
transaction, then activate/start every Wrapper and hard-refresh clients. Use the
@@ -152,11 +160,11 @@ deployment.
migration transaction, restores matching pre-release data before an older
wrapper is restarted, and verifies both engines' Work ownership backfills.
-Protocol v67 is a coordinated upgrade: publish freshly built Relay/Web and
+Protocol v71 is a coordinated upgrade: publish freshly built Relay/Web and
Wrapper artifacts from the same tagged commit. The strict protocol gate is
intentional and mixed protocol versions will not communicate. `setup-vps.sh`
rejects a missing or mismatched web build manifest. Stop the wrapper first;
-activate the v67 relay/web release; then start the v67 wrapper.
+activate the v71 relay/web release; then start the v71 wrapper.
The wrapper installer treats local Work data and versioned private control state
as part of the release
@@ -169,8 +177,8 @@ the previous code. If data restoration fails, it leaves the
wrapper stopped instead of running old code against a new schema. A manual or
legacy-layout deployment must use the same order: stop the wrapper, run
`work_registry_snapshot.py snapshot` from the new staging tree, activate and
-verify v67, and retain that snapshot with the previous release. To roll back,
-stop v67, run `work_registry_snapshot.py restore`, then switch and start the old
+verify v71, and retain that snapshot with the previous release. To roll back,
+stop v71, run `work_registry_snapshot.py restore`, then switch and start the old
release. Never copy only `registry.sqlite3` while the wrapper is live because
committed state may still be in its WAL file. Restoring a pre-release snapshot
also restores pre-release Work metadata: sessions, projects, or schedule state
diff --git a/deploy/build_release.py b/deploy/build_release.py
index d7f9f001..01ec1f1c 100755
--- a/deploy/build_release.py
+++ b/deploy/build_release.py
@@ -50,6 +50,7 @@ class BuildError(ValueError):
"com.muggle.cc-remote.wrapper.plist.in",
"env.wrapper.example",
"install-wrapper.sh",
+ "install_claude_service.py",
"prepare_wrapper_stage.py",
"python-version.txt",
"release_manifest.py",
diff --git a/deploy/env.wrapper.example b/deploy/env.wrapper.example
index e91f93e6..fc1560df 100644
--- a/deploy/env.wrapper.example
+++ b/deploy/env.wrapper.example
@@ -15,6 +15,9 @@ CC_REMOTE_MACHINE_ID=default
# Use the service user's normal Claude Code install instead of the SDK bundle.
# Override only when that user's daily CLI lives at another absolute path.
CLAUDE_BIN=/home/youruser/.local/bin/claude
+# Optional persistent Claude SDK service; install it separately first.
+# See docs/claude-session-service.md for first-migration and drain checks.
+CC_REMOTE_CLAUDE_SERVICE_SOCKET=
# Optional per-wrapper Codex upstream proxy; does not change the user's shell.
CC_REMOTE_CODEX_PROXY=
# Code prefers the official shared Codex daemon. Set off only to troubleshoot a
diff --git a/deploy/install_claude_service.py b/deploy/install_claude_service.py
new file mode 100644
index 00000000..8024b38f
--- /dev/null
+++ b/deploy/install_claude_service.py
@@ -0,0 +1,132 @@
+"""Install the SDK service as the Wrapper's user; never restart a live service.
+
+Run with the staged release's Python, from its immutable source root. The unit
+uses that exact venv and source until an explicitly scheduled service upgrade.
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import os
+import plistlib
+import subprocess
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from cc_remote.claude_service.client import Connection
+from cc_remote.claude_service.wire import private_directory
+
+LABEL = "com.muggle.cc-remote.claude-service"
+UNIT = "cc-remote-claude-service.service"
+
+
+def commands(source: Path, state_dir: Path) -> list[str]:
+ executable = Path(sys.executable)
+ python = executable.parent.resolve() / executable.name
+ return [str(python), "-m", "cc_remote.claude_service", "--state-dir", str(state_dir)]
+
+
+def unit_text(source: Path, state_dir: Path) -> str:
+ def quote(value):
+ return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$") + '"'
+ argv = " ".join(quote(value) for value in commands(source, state_dir))
+ # Unlike ExecStart's argument list, WorkingDirectory is one unquoted path.
+ working_directory = str(source).replace("\\", "\\\\").replace("%", "%%").replace("\n", "\\n").replace("\r", "\\r")
+ return f"""[Unit]
+Description=cc-remote persistent Claude SDK sessions
+
+[Service]
+Type=simple
+WorkingDirectory={working_directory}
+ExecStart={argv}
+UMask=0077
+Restart=on-failure
+RestartSec=3
+KillMode=control-group
+
+[Install]
+WantedBy=default.target
+"""
+
+
+async def healthy(path: Path) -> bool:
+ connection = Connection(str(path))
+ try:
+ await asyncio.wait_for(connection.connect(), 2)
+ await connection.call("list", timeout=3)
+ return True
+ except (OSError, TimeoutError, RuntimeError):
+ return False
+ finally:
+ await connection.disconnect()
+
+
+def install(source: Path, state_dir: Path) -> None:
+ private_directory(state_dir)
+ socket_path = state_dir / "service.sock"
+ # This is a readiness probe, not a reason to terminate a process whose
+ # protocol/version cannot be read. Existing units are never replaced here.
+ if asyncio.run(healthy(socket_path)):
+ print(f"Claude service already running: {socket_path}")
+ return
+ if sys.platform == "darwin":
+ destination = Path.home() / "Library/LaunchAgents" / f"{LABEL}.plist"
+ if destination.exists():
+ raise RuntimeError("existing Claude service is not ready; inspect it before retrying")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ payload = {
+ "Label": LABEL, "ProgramArguments": commands(source, state_dir),
+ "WorkingDirectory": str(source), "RunAtLoad": True,
+ "KeepAlive": {"SuccessfulExit": False}, "Umask": 0o077,
+ "StandardOutPath": str(state_dir / "stdout.log"),
+ "StandardErrorPath": str(state_dir / "stderr.log"),
+ }
+ destination.write_bytes(plistlib.dumps(payload))
+ destination.chmod(0o600)
+ subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(destination)], check=True)
+ elif sys.platform == "linux":
+ destination = Path.home() / ".config/systemd/user" / UNIT
+ if destination.exists():
+ raise RuntimeError("existing Claude service is not ready; inspect it before retrying")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_text(unit_text(source, state_dir))
+ destination.chmod(0o600)
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
+ subprocess.run(["systemctl", "--user", "enable", "--now", UNIT], check=True)
+ else:
+ raise RuntimeError("Claude session service requires macOS or Linux")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--state-dir", type=Path, required=True)
+ parser.add_argument("--register-wrapper", type=Path,
+ help="write an explicit registration inside this Wrapper state directory")
+ args = parser.parse_args()
+ if os.getuid() == 0:
+ parser.error("run as the Wrapper user, not root")
+ source = Path(__file__).resolve().parents[1]
+ state_dir = args.state_dir.expanduser().absolute()
+ install(source, state_dir)
+ if args.register_wrapper is not None:
+ directory = args.register_wrapper.expanduser().absolute()
+ private_directory(directory)
+ destination = directory / "claude-service.json"
+ payload = {"socket": str(state_dir / "service.sock")}
+ if destination.exists() or destination.is_symlink():
+ if (destination.is_symlink() or destination.stat().st_uid != os.getuid()
+ or destination.stat().st_mode & 0o077
+ or json.loads(destination.read_text()) != payload):
+ raise RuntimeError("existing Claude registration differs; inspect it before changing it")
+ else:
+ fd = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
+ with os.fdopen(fd, "w") as out:
+ json.dump(payload, out)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/claude-session-service.md b/docs/claude-session-service.md
new file mode 100644
index 00000000..b5fad195
--- /dev/null
+++ b/docs/claude-session-service.md
@@ -0,0 +1,177 @@
+# Claude sessions across Wrapper deployments
+
+The optional Claude session service owns the pinned Agent SDK and the user's
+daily Claude Code CLI processes. The Wrapper remains the controller. Restarting
+the Wrapper detaches the controller; it does not interrupt the native SDK or
+resubmit an accepted prompt.
+
+The service continues reading native output while the Wrapper is offline. Its
+private SQLite journal retains each unacknowledged turn and its real terminal
+result. On return, the Wrapper reconstructs the translator and restores pending
+tool approvals and MCP questions. Replayed text replaces the existing message
+prefix. The original browser message ID remains the turn's owner. Human and
+autonomous results are separate; a background result cannot acknowledge a human
+turn. Answers are saved in the service before the browser receives acceptance,
+including answers to earlier pages of a multi-question tool call.
+
+If background projection or acknowledgement fails, the controller stops advancing
+acknowledgements and accepting new prompts. Human terminal commits also wait for
+pending background delivery, so they cannot prune a failed notification. The
+native reader keeps running; restart only the Wrapper to reattach and replay the
+retained output. This does not interrupt the task or resubmit an accepted prompt.
+
+Pending permission and MCP callbacks retry handler failures with a capped
+backoff while their controller remains connected. Once a handler returns,
+answer retries reuse its result and request identity without executing the
+handler again. Closing the native callback or detaching the controller cancels
+these retries; detaching still leaves pending native requests in the service.
+
+Startup lists each configured service independently. An unreachable service is
+logged without blocking recovery from reachable services. Duplicate native
+identities across the returned listings are rejected before any attachment;
+each recovered session stays bound to its original socket and worker ID. After
+that check, a failed session attachment does not stop the remaining recoveries.
+If the previous controller's socket is still being cleaned up, attachment to
+that exact worker retries lease conflicts for at most five seconds. This does
+not replace a live controller, resubmit a prompt or retry an unknown response.
+Older services' coarse conflict errors are checked against the listed worker's
+full identity before retry. When strict leases are negotiated, a missing explicit
+worker is rejected; only confirmed native close clears the Wrapper's worker
+identity for a deliberate reconnect. Older controllers keep their existing
+reconnect behavior without opting into strict leases.
+
+Accepted steering uploads survive reader/control failures and ordinary service
+detach because their native turn may still need them. A confirmed native close
+(including eviction and drain-timeout reconnect) or the exact human terminal
+releases them. The service also retains attachment ownership across controller
+replacement, so closing before replaying a steering echo still removes its
+files. An unconfirmed close does not authorize deleting live-task attachments.
+
+This is a cc-remote SDK service, not Claude Code's terminal background mode or
+the experimental PTY broker. The daily native Claude TUI keeps its existing
+external-ownership rules; this service does not give it shared input ownership.
+
+## Install before enabling
+
+Use the tested immutable Wrapper source and venv selected by the normal
+[deployment procedure](../deploy/README.md). Run as the actual Wrapper user:
+
+```bash
+cd ""
+.venv/bin/python deploy/install_claude_service.py \
+ --state-dir "/claude-service"
+```
+
+On macOS this creates a separate LaunchAgent. On Linux it creates the separate
+systemd user unit `cc-remote-claude-service.service`. That user's systemd manager
+must be available and configured to survive logout for unattended operation.
+This works alongside either a system or user Wrapper unit. Do not launch the
+SDK service as an ordinary child of the Wrapper: `setsid` does not protect
+descendants from systemd's control-group shutdown.
+
+The installer does not replace or restart an existing service. An unreadable
+existing endpoint requires investigation, not a second daemon or an in-process
+fallback. Inspect `service.json`, service-manager state and the socket before
+deciding how to recover it.
+
+After service readiness is verified, add this to the Wrapper's existing external
+configuration, preserving all other values:
+
+```dotenv
+CC_REMOTE_CLAUDE_SERVICE_SOCKET=/claude-service/service.sock
+```
+
+An empty setting retains the previous in-process SDK behavior. The service
+can also be registered without editing a root-owned environment file: add
+`--register-wrapper ""` to the installer command. This
+writes a private `claude-service.json` inside the Wrapper's `CC_REMOTE_STATE_DIR`
+(normally `~/.cc-remote`). The socket environment variable takes precedence;
+an explicitly empty value disables the registration. Merely starting the service
+without either configuration does not enable it for the Wrapper. Registration
+is read at Wrapper startup, so the first-migration drain still applies.
+
+The service
+directory must belong to the Wrapper user and be mode 0700; its socket and
+journals are private. Both ends check the local protocol and exact SDK version.
+Profile config roots are part of session identity, so equal native UUIDs in
+different accounts cannot attach to the same worker. Native environment and
+SDK options travel through the local socket only, not through relay state,
+release artifacts, service descriptors or unit files.
+
+## First migration and subsequent deployments
+
+An existing in-process SDK child cannot be adopted in flight. Before first
+activation, wait for those Claude turns to finish. Starting the independent
+service early is safe; restarting the old Wrapper early is not.
+
+For ordinary deployments after migration, keep the SDK service running and
+restart only the Wrapper through its immutable activation transaction. Restore
+service-owned sessions before creating a bootstrap session. Do not classify
+their separate SDK process tree as an unrelated terminal owner.
+
+The Wrapper requests readable thinking summaries at SDK child launch with
+`--thinking-display summarized`. Claude Code's `showThinkingSummaries` setting
+applies to interactive terminals and does not enable this in SDK mode. When
+reattaching to an existing service-owned child, the Wrapper also tries a bounded
+native display control; it does not restart the child or replay the prompt to
+apply this preference. Native thinking mode, token budget and effort remain in
+effect. Claude Code 2.1.269 acknowledges a display update during a running turn,
+but the active agent loop keeps its original configuration, including across
+tool continuations and steering. The updated display applies to the next
+top-level query after that turn ends naturally. A successful control response
+does not prove that the current turn will return summaries; never interrupt it
+to force this preference to apply.
+
+An unsupported or timed-out display update does not fail attachment;
+the launch option applies on the next ordinary child start. Only subsequently
+returned summaries can be displayed, and provider support still determines
+whether the CLI receives readable thinking content.
+
+The current integration covers regular Claude Code and Work sessions. Private
+`/btw` forks retain their existing lifetime. They, any other in-process Claude
+instance, and Wrapper-owned deferred queries must drain before restarting the
+Wrapper. Deployment automation must check these separately: a healthy service
+socket alone does not prove that every active operation has migrated.
+
+The SDK service stays on its original immutable source and venv while its
+sessions are alive. Keep that release; `service.json` records the source and
+process identity. If an upgrade changes the pinned SDK or local service
+protocol, stage it first and wait for native turns, pending callbacks and
+autonomous work to finish before replacing the service itself. Do not bypass a
+version mismatch or restart a busy service to satisfy an acceptance check.
+
+An OS reboot, explicit interrupt, service crash or explicit service stop is
+outside the Wrapper-only deployment guarantee. The journal is disk-backed;
+acknowledged ranges are reclaimed. A long unacknowledged turn needs storage
+proportional to its native transcript. Stopping the sole reader at a byte cap
+would prevent the terminal result that releases that turn from arriving.
+Journals contain private task data and belong with private runtime state.
+
+## Acceptance
+
+For an urgent service fix while native work is still running, start the new
+immutable release in a separate user-managed service and state directory. Keep
+the previous service alive. The private Wrapper registration can specify
+`socket` (new sessions) and `drain_socket` (existing sessions); the equivalent
+explicit override is `CC_REMOTE_CLAUDE_SERVICE_DRAIN_SOCKET`. Wrapper recovery
+reattaches each session to its original service and rejects duplicate native
+identities across the two services. SDK versions must still match.
+
+While the Wrapper is paused, only close old sessions proven idle, with no
+pending callbacks, background work or unacknowledged output. Never discard an
+unknown query delivery. A diagnosed pre-send failure may be removed only after
+preserving its original private payload and proving the native query was never
+written. Existing active sessions remain on the draining service until their
+work finishes. Retain the old service registration for rollback; remove its
+drain registration only after all sessions have safely migrated.
+
+- Keep the same SDK-service and native CLI PIDs across a Wrapper restart.
+- Restore the original session and message ID without another query.
+- Verify output, offline completion, permission/MCP-question recovery and
+ interrupted-turn drain using zero-model tests.
+- Confirm replay replaces existing text and settles the actual native result.
+- Complete normal account, build, protocol, health and stability checks. This
+ does not replace Codex sharing acceptance or authorize a live model prompt.
+
+`tests/test_claude_service.py` includes an actual controller-process termination
+test with a stub model and exercises the pinned SDK's real MCP bridge.
diff --git a/docs/configuration.md b/docs/configuration.md
index d2fd039c..e7ac3d9e 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -13,7 +13,7 @@
### Claude
-Wrapper 使用日常 Claude Code,默认 `~/.local/bin/claude`,最低版本 `2.1.258`。
+Wrapper 使用日常 Claude Code,默认 `~/.local/bin/claude`,最低版本 `2.1.263`。
`CLAUDE_BIN` 留空仍使用该路径;显式覆盖必须是绝对路径。Agent SDK 固定为
`0.2.151`,不会使用 SDK 自带 CLI 替代你的日常安装。
@@ -151,6 +151,7 @@ hook 和 Wrapper 应使用相同的 `CC_REMOTE_STATE_DIR`;日志默认为
| `CC_REMOTE_MACHINE_ID` | `default` | 多机器 relay 中的稳定路由 id;使用 `WRAPPER_TOKENS_JSON` 时必须匹配对应键。 |
| `CC_REMOTE_DEVICE_CONFIG` | `~/.cc-remote/device.json` | 交互配对凭据路径;文件必须仅当前用户可读。显式的 `RELAY_URL` / `WRAPPER_TOKEN` / `CC_REMOTE_MACHINE_ID` 优先。 |
| `CLAUDE_BIN` | `~/.local/bin/claude` | wrapper 实际启动的日常 Claude Code;空值仍使用该默认路径。只有 CLI 安装在别处时才设为另一个绝对路径。 |
+| `CC_REMOTE_CLAUDE_SERVICE_SOCKET` | 空 | 可选独立 Claude SDK 服务的 Unix socket。先按 [会话服务指南](claude-session-service.md) 安装;常规 Code/Work 任务可跨 Wrapper 重启继续运行。首次迁移和尚未迁移的任务须等待空闲。 |
| `CC_REMOTE_CLAUDE_PROFILES_JSON` | 空 | 可选 Claude 多账号注册表;格式为 `{profile_id:{"label":"…","config_dir":"/绝对/CLAUDE_CONFIG_DIR","default":true}}`。最多 32 项、目录必须唯一,且必须且只能有一个默认项。Code、Work 与定时任务均可选择账号;空值保持当前单账号行为。显式 JSON 优先于文件。 |
| `CC_REMOTE_CLAUDE_PROFILES_FILE` | 空(macOS LaunchAgent 为 `~/.cc-remote/claude-profiles.json`) | 可选注册表 JSON 文件;必须是有上限的普通文件。文件不存在等同单账号,便于先安装再配置。 |
| `CC_REMOTE_CODEX_PROXY` | 空 | 仅注入 wrapper 启动的 Codex 子进程的 HTTP(S)/SOCKS5 代理;不改 wrapper 到 relay 的连接,也不影响用户终端里的 `codex`。例如 `http://127.0.0.1:8080`。 |
diff --git a/docs/configuration_en.md b/docs/configuration_en.md
index 38efb796..c82cf7eb 100644
--- a/docs/configuration_en.md
+++ b/docs/configuration_en.md
@@ -14,7 +14,7 @@
### Claude
Wrapper uses daily Claude Code, normally `~/.local/bin/claude`, with a minimum
-version of `2.1.258`. Empty `CLAUDE_BIN` still selects this path; an explicit
+version of `2.1.263`. Empty `CLAUDE_BIN` still selects this path; an explicit
override must be absolute. Agent SDK is pinned to `0.2.151`; its bundled CLI does
not replace your daily installation.
@@ -169,6 +169,7 @@ Common settings below; [config.py](../cc_remote/config.py) and the deployment en
| `CC_REMOTE_MACHINE_ID` | `default` | Stable route id on a multi-machine relay; must match its `WRAPPER_TOKENS_JSON` key when that policy is enabled. |
| `CC_REMOTE_DEVICE_CONFIG` | `~/.cc-remote/device.json` | Interactive pairing credential; the file must be private to the current user. Explicit `RELAY_URL` / `WRAPPER_TOKEN` / `CC_REMOTE_MACHINE_ID` values take precedence. |
| `CLAUDE_BIN` | `~/.local/bin/claude` | Daily Claude Code executable launched by the wrapper. Empty still selects this default; use another absolute path only when the CLI is installed elsewhere. |
+| `CC_REMOTE_CLAUDE_SERVICE_SOCKET` | empty | Optional independent Claude SDK service socket. Install using the [session-service guide](claude-session-service.md) first. Regular Code/Work tasks survive Wrapper restarts; first migration and remaining in-process tasks must drain. |
| `CC_REMOTE_CLAUDE_PROFILES_JSON` | empty | Optional Claude multi-account registry in the form `{profile_id:{"label":"…","config_dir":"/absolute/CLAUDE_CONFIG_DIR","default":true}}`. At most 32 unique directories are allowed and exactly one entry must be the default. Code, Work, and schedules may select any entry. Empty preserves the current single-account behavior; inline JSON takes precedence over the file. |
| `CC_REMOTE_CLAUDE_PROFILES_FILE` | empty (macOS LaunchAgent: `~/.cc-remote/claude-profiles.json`) | Optional bounded regular JSON file. A missing file means single-account mode, allowing installation before configuration. |
| `CC_REMOTE_CODEX_PROXY` | empty | Optional HTTP(S)/SOCKS5 proxy injected only into Codex subprocesses launched by the wrapper. It does not change the wrapper-to-relay connection or the user's terminal `codex`. |
diff --git a/docs/installation.md b/docs/installation.md
index f7578f0a..1970cdc9 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -166,7 +166,7 @@ npm --prefix web run build # 产出 web/dist/
网页构建不需要任何登录密钥。
**所有目标先 staging,再改动线上服务。** 下文分别描述 Relay 和 Wrapper,
-不能在 Wrapper staging 未验证时先激活 Relay。协议 v67 不允许混用旧客户端:
+不能在 Wrapper staging 未验证时先激活 Relay。协议 v71 不允许混用旧客户端:
停止不兼容的旧 Wrapper,激活 Relay + Web,再激活 Wrapper 并硬刷新网页。
Wrapper 激活须通过 `deploy/work_registry_snapshot.py` 保存 Work SQLite 与私有账号
控制状态,不再按“是否来自某个旧协议”决定是否保护。回滚先恢复匹配状态,再启动
@@ -224,7 +224,7 @@ sudo bash ~/cc-remote-upload/deploy/setup-vps.sh \
脚本会:装 `python3-venv` + Caddy、建 `ccremote` 系统用户、创建不可变 release
和 release-local venv、合并 Caddy 配置、原子切换 `current`,再重启 relay。若新
relay 重启或健康检查失败,`current`、Caddyfile、systemd unit 会作为一个事务全部
-恢复,并验证旧 release 的 `/healthz`。成功后再启动 v67 wrapper。
+恢复,并验证旧 release 的 `/healthz`。成功后再启动 v71 wrapper。
验证:
diff --git a/docs/installation_en.md b/docs/installation_en.md
index 69b1ecb7..e433703e 100644
--- a/docs/installation_en.md
+++ b/docs/installation_en.md
@@ -188,7 +188,7 @@ as described in the deployment contract. No browser secret is needed for a build
**Stage every target before changing live services.** The commands below describe
the Relay and Wrapper separately; do not activate Relay until every Wrapper stage
-has passed validation. Protocol v67 cannot be mixed with older clients. Stop old
+has passed validation. Protocol v71 cannot be mixed with older clients. Stop old
incompatible Wrappers, activate Relay + Web, then activate Wrappers and hard-refresh
browser tabs. Wrapper activation must snapshot Work SQLite and private profile
control state with `deploy/work_registry_snapshot.py`; this is not limited to
@@ -250,7 +250,7 @@ The script installs `python3-venv` + Caddy, creates the `ccremote` service user,
builds an immutable release and its venv, merges Caddy configuration, atomically
switches `current`, and restarts the relay. If restart/readiness fails, `current`,
the Caddyfile, and the systemd unit roll back as one transaction and the previous
-release's `/healthz` is verified. Start the v67 wrapper after success.
+release's `/healthz` is verified. Start the v71 wrapper after success.
Verify:
diff --git a/docs/timed-messages.md b/docs/timed-messages.md
new file mode 100644
index 00000000..54171990
--- /dev/null
+++ b/docs/timed-messages.md
@@ -0,0 +1,53 @@
+# Timed messages
+
+Codex's ordinary queue messages do not include a schedule or a timer-origin
+flag. cc-remote therefore uses explicit local task receipts, never prompt
+wording, to render the **定时任务** tag and sidebar countdown.
+
+The scheduled-message helper uses the account's existing official app-server.
+It queues into the specified native thread; it does not create a new session
+or a Goal. The helper process is independent of the Wrapper process.
+
+From the cc-remote checkout or installed release directory, using its Python
+environment:
+
+```bash
+.venv/bin/python -m cc_remote.timed_tasks start \
+ --codex-home /absolute/path/to/the/accounts/codex-home \
+ --thread NATIVE_THREAD_UUID \
+ --title '每分钟测试' --message '测试' --after 60 --every 60 --count 3
+```
+
+Use the actual account home and native thread ID of the destination. A sidebar
+routing ID such as `primary@UUID` is not a native thread ID. For an agent-created
+reminder, obtain that identity from the current native session; do not guess the
+most recently modified session. The destination must already exist, and its
+shared daemon must be running. This helper does not launch or take over a daemon.
+
+The command returns a task ID and the first scheduled send time. A one-shot
+reminder uses `--after 900 --count 1`. The finite count is required by the task
+contract; do not turn an ordinary reminder into a persistent Goal.
+
+```bash
+.venv/bin/python -m cc_remote.timed_tasks status TASK_UUID
+.venv/bin/python -m cc_remote.timed_tasks cancel TASK_UUID
+```
+
+For a non-default Wrapper state location, put
+`--state-dir /absolute/path/to/wrapper-state` before the subcommand.
+The helper and Wrapper must read the same state directory. Prompts and account
+paths stay in its private SQLite store; public task metadata contains only a
+title, schedule, counters and message receipt identity.
+
+The sidebar remains idle while waiting. A task's outline stops after the final
+message is accepted by the native queue, on cancellation/failure, or when its
+worker heartbeat expires. Native queue acceptance is not model completion;
+ordinary session state continues to describe the model's work. Cancelling
+prevents subsequent sends and does not withdraw a message already submitted.
+An uncertain submission is never automatically resent. After machine sleep,
+missed intervals are not sent in a burst.
+
+Existing ad-hoc scripts that invoke `codex queue` directly have no reliable
+next-send metadata. Their messages remain ordinary messages until explicit
+receipts are provided; cc-remote does not infer schedules from text or label all
+cross-session messages as timers.
diff --git a/docs/tui.md b/docs/tui.md
index 4dc09175..067d1282 100644
--- a/docs/tui.md
+++ b/docs/tui.md
@@ -586,7 +586,7 @@ All these keys are configurable under `[queue]` and listed in Help.
The wrapper owns the order and broadcasts changes to Web and other TUIs.
Reordering compares the displayed queue against the server queue atomically;
if another client changes it or a message is starting, refresh and retry.
-This requires coordinated protocol v67 Relay/Web/Wrapper/TUI deployment.
+This requires coordinated protocol v71 Relay/Web/Wrapper/TUI deployment.
### Markdown and image previews
@@ -672,4 +672,7 @@ Install `requirements-dev.txt`, which includes the optional TUI dependency.
`python -m pytest tests/test_tui*.py` runs model-free state, transport and
headless keyboard/resize regressions, including public-event parity and an
inventory test for all protocol commands. No live model is needed.
-The repository's complete local gate still applies before a PR.
+Maintainer-authored PRs follow the complete local gate in
+[AGENTS.md](../AGENTS.md#commit-and-pr-gate). Other contributors can submit the
+checks they performed. PR CI automatically builds Web and runs pytest;
+Playwright is not part of CI or the required local PR gate.
diff --git a/docs/tui_zh.md b/docs/tui_zh.md
index 6f8ced66..b962086f 100644
--- a/docs/tui_zh.md
+++ b/docs/tui_zh.md
@@ -641,7 +641,7 @@ cc-remote 会话(包含设备、引擎和 Code/Work 路由),不会上传
队列顺序由服务端维护并同步到网页及其他 TUI,不通过取消再发送来排序。
如果其他客户端改变队列,或消息正在启动,服务端拒绝旧列表的排序请求,
-请按最新列表重试。需要将 Relay、Web、Wrapper、TUI 一起部署为协议 v67。
+请按最新列表重试。需要将 Relay、Web、Wrapper、TUI 一起部署为协议 v71。
## Markdown 与图片预览
@@ -711,4 +711,7 @@ python -m pytest tests/test_tui*.py
```
这些测试不调用模型,覆盖状态、传输、无界面键盘操作、终端尺寸变化、
-公开事件对齐以及协议命令清单。提交 PR 前仍须通过仓库要求的完整本地检查。
+公开事件对齐以及协议命令清单。维护者提交 PR 前按
+[AGENTS.md](../AGENTS.md#commit-and-pr-gate) 执行完整本地检查;其他贡献者说明
+已做的验证即可。PR 自动 CI 运行 Web 编译与 pytest,Playwright 不进入 CI 或必跑的
+本地 PR 检查。
diff --git a/tests/test_child_env.py b/tests/test_child_env.py
index 6a77fb3e..0204d732 100644
--- a/tests/test_child_env.py
+++ b/tests/test_child_env.py
@@ -351,6 +351,7 @@ def test_claude_work_uses_minimal_isolated_runtime():
assert options.sandbox is None
assert options.extra_args == {
"replay-user-messages": None,
+ "thinking-display": "summarized",
"safe-mode": None,
}
assert options.system_prompt == WORK_SYSTEM_PROMPT
@@ -701,6 +702,7 @@ def test_claude_code_keeps_official_prompt_preset_and_runtime_surface():
assert options.hooks is None
assert options.extra_args == {
"replay-user-messages": None,
+ "thinking-display": "summarized",
}
diff --git a/tests/test_claude_account_isolation.py b/tests/test_claude_account_isolation.py
index 35607012..74531bbd 100644
--- a/tests/test_claude_account_isolation.py
+++ b/tests/test_claude_account_isolation.py
@@ -91,6 +91,10 @@ async def run():
try:
assert len(clients) == 3
assert btw.claude_profile_id == ctx.claude_profile_id
+ assert clients[-1].options.session_id == btw.btw_reserved_id
+ private_sid = machine._claude_wire_sid(
+ machine._claude_profile_for_ctx(btw), btw.btw_reserved_id)
+ assert private_sid in machine._load_private_btw_sessions()
for client in clients:
assert isinstance(client.transport, AccountIsolatedSubprocessCLITransport) is explicit
assert client.options.setting_sources == (["user"] if explicit else None)
diff --git a/tests/test_claude_agents.py b/tests/test_claude_agents.py
index da5136ae..a817d985 100644
--- a/tests/test_claude_agents.py
+++ b/tests/test_claude_agents.py
@@ -9,6 +9,7 @@
from claude_agent_sdk.types import (
AssistantMessage,
+ StreamEvent,
TaskStartedMessage,
TaskUpdatedMessage,
TextBlock,
@@ -39,6 +40,42 @@ def _assistant(content, *, parent=None):
)
+def test_agent_partial_output_is_live_coalesced_and_not_duplicated(monkeypatch):
+ registry = ClaudeAgentRegistry(64 * 1024)
+ tick = [10.0]
+ monkeypatch.setattr(agent_module.time, "monotonic", lambda: tick[0])
+
+ def delta(text):
+ return registry.route(StreamEvent(
+ uuid="child-message", session_id="session",
+ parent_tool_use_id="root-agent", event={
+ "type": "content_block_delta", "index": 0,
+ "delta": {"type": "text_delta", "text": text},
+ },
+ ))
+
+ first = delta("First ")
+ assert any(event.get("text") == "First " for event in first.events)
+ tick[0] += 0.01
+ assert delta("second ").events == ()
+ tick[0] += 0.01
+ assert delta("third ").events == ()
+ tick[0] += 0.2
+ spaced = delta("fourth ")
+ assert [event["text"] for event in spaced.events
+ if event["type"] == "delta"] == ["second third fourth "]
+ tick[0] += 0.01
+ assert delta("last").events == ()
+ assembled = registry.route(_assistant(
+ [TextBlock(text="First second third fourth last")], parent="root-agent"))
+ assert [event["text"] for event in assembled.events
+ if event["type"] == "delta"] == ["last"]
+ run = registry.snapshot(public_agent_run_id("root-agent"))
+ assert "".join(event["text"] for event in run.events
+ if event["type"] == "delta") == "First second third fourth last"
+ assert run.pending_stream_events == []
+
+
def test_registry_separates_root_and_nested_agent_messages():
registry = ClaudeAgentRegistry(64 * 1024)
root = registry.route(_assistant([ToolUseBlock(
diff --git a/tests/test_claude_autocompact.py b/tests/test_claude_autocompact.py
index 0594743f..5eed34bc 100644
--- a/tests/test_claude_autocompact.py
+++ b/tests/test_claude_autocompact.py
@@ -90,7 +90,7 @@ def __init__(
self,
*,
fail_first_reconnect: bool = False,
- context_total: int | None = 0,
+ context_total: int | None = None,
):
self.auto_compact_mode = "inherit"
self.auto_compact_threshold_tokens = None
@@ -584,7 +584,7 @@ async def run():
asyncio.run(run())
-def test_unknown_auto_target_compacts_nontrivial_context_before_reconnect():
+def test_unknown_auto_target_defers_compaction_to_native_cli():
async def run():
sdk = _CompactingAutoCompactSdk(context_total=200_000)
machine, _transport, _ctx = _machine_with_sdk(sdk)
@@ -594,7 +594,7 @@ async def run():
mode="auto",
))
- assert sdk.queries == ["/compact"]
+ assert sdk.queries == []
assert sdk.reconnects[0][0:2] == ("auto", None)
assert event.pending is False
assert event.applied_mode == "auto"
@@ -602,7 +602,7 @@ async def run():
asyncio.run(run())
-def test_unknown_legacy_window_compacts_once_before_adopting_default():
+def test_unknown_legacy_usage_does_not_force_compaction():
async def run():
sdk = _CompactingAutoCompactSdk(context_total=None)
sdk.auto_compact_mode = "custom"
@@ -615,7 +615,7 @@ async def run():
_ctx, reason="legacy default migration",
)
- assert sdk.queries == ["/compact"]
+ assert sdk.queries == []
assert sdk.reconnects[0][0:2] == ("custom", 500_000)
assert applied is True
assert event.pending is False
@@ -645,7 +645,7 @@ async def run():
]
assert sdk.reconnects[0][2]["preserve_model"] is True
assert sdk.reconnects[1][2]["apply_pending_auto_compact"] is True
- assert sdk.queries == ["/compact"]
+ assert sdk.queries == []
assert event.pending is False
assert ctx.needs_reload is False
@@ -834,7 +834,7 @@ async def run():
asyncio.run(run())
-def test_context_control_timeout_preserves_cache_but_returns_error():
+def test_context_control_timeout_publishes_last_valid_sample():
class ContextSdk(_AutoCompactSdk):
control_plane_failed = False
context_probe_suppressed = False
@@ -875,14 +875,14 @@ async def run():
client_id="browser-one",
))
- assert isinstance(report, Error)
- assert report.code == "internal"
+ assert isinstance(report, ContextReport)
+ assert report.source == "recent_turn"
+ assert report.total_tokens == 88_259
+ assert report.max_tokens == 500_000
+ assert report.categories == []
assert report.request_id == "context-command"
- assert report.to == "browser-one"
assert transport.sent[-1] == report
- assert not any(
- isinstance(item, ContextReport) for item in transport.sent
- )
+ assert not any(isinstance(item, Error) for item in transport.sent)
assert sdk.context_calls == 1
assert sdk.cached_context_usage()["totalTokens"] == 80_000
assert sdk.cached_recent_context_usage()["totalTokens"] == 88_259
@@ -890,7 +890,7 @@ async def run():
asyncio.run(run())
-def test_context_control_timeout_without_cache_reports_error():
+def test_context_control_timeout_without_cache_reports_unavailable():
class ContextSdk(_AutoCompactSdk):
control_plane_failed = False
context_probe_suppressed = False
@@ -914,13 +914,11 @@ async def run():
client_id="browser-one",
))
- assert isinstance(report, Error)
- assert report.code == "internal"
+ assert isinstance(report, ContextReport)
+ assert report.available is False
assert report.request_id == "context-command"
- assert report.to == "browser-one"
assert transport.sent[-1] == report
- assert not any(
- isinstance(item, ContextReport) for item in transport.sent)
+ assert not any(isinstance(item, Error) for item in transport.sent)
asyncio.run(run())
@@ -949,17 +947,16 @@ async def run():
client_id="browser-one",
))
- assert isinstance(report, Error)
- assert report.code == "internal"
+ assert isinstance(report, ContextReport)
+ assert report.source == "recent_turn"
+ assert report.total_tokens == 123
assert report.request_id == "context-malformed"
- assert report.to == "browser-one"
- assert not any(
- isinstance(item, ContextReport) for item in transport.sent)
+ assert not any(isinstance(item, Error) for item in transport.sent)
asyncio.run(run())
-def test_poisoned_context_generation_reconnects_once_then_can_refresh():
+def test_poisoned_context_recovery_waits_for_normal_traffic_before_refresh():
class ContextSdk(_AutoCompactSdk):
control_plane_failed = False
context_probe_suppressed = False
@@ -1003,9 +1000,10 @@ async def run():
client_id="browser-one",
))
- assert isinstance(failed, Error)
+ assert isinstance(failed, ContextReport)
+ assert failed.available is False
assert failed.request_id == "context-timeout"
- assert "安全恢复" in failed.message
+ assert sdk.context_probe_suppressed is True
assert len(sdk.reconnects) == 1
assert sdk.reconnects[0][2] == {
"resume_id": SESSION_ID,
@@ -1014,6 +1012,13 @@ async def run():
"fork": False,
}
+ deferred = await machine._handle_get_context(GetContext(
+ sid=SESSION_ID, refresh=True, cmd_id="context-reopen"))
+ assert deferred.available is False
+ assert sdk.context_calls == 1
+ assert len(sdk.reconnects) == 1
+ # SdkHandle clears suppression on the next successful ResultMessage.
+ sdk.context_probe_suppressed = False
report = await machine._handle_get_context(GetContext(
sid=SESSION_ID,
refresh=True,
@@ -1027,7 +1032,7 @@ async def run():
assert report.max_tokens == 500_000
assert report.request_id == "context-retry"
assert len(sdk.reconnects) == 1
- assert transport.sent[-2:] == [failed, report]
+ assert transport.sent[-3:] == [failed, deferred, report]
asyncio.run(run())
@@ -2108,11 +2113,16 @@ def result(origin: dict) -> ResultMessage:
asyncio.run(run())
-def test_autonomous_followup_streams_text_and_tools_without_duplicate_turn_end():
+@pytest.mark.parametrize("stale_owner", [None, "previous-human-turn"])
+def test_autonomous_followup_streams_text_and_tools_without_duplicate_turn_end(stale_owner):
async def run():
machine, transport, ctx = _machine_with_sdk(_AutoCompactSdk())
origin = {"kind": "task-notification"}
assistant_id = "77777777-7777-4777-8777-777777777777"
+ # Idle task/status messages may have constructed this translator long
+ # before the current human prompt. Its text owner must be rebound.
+ ctx.claude_background_translator = StreamTranslator(
+ 1024, turn_id=stale_owner)
await machine._on_claude_background_message(
ctx,
@@ -2164,7 +2174,7 @@ async def run():
content="contents",
is_error=False,
)],
- parent_tool_use_id="background-read",
+ origin=origin,
),
"origin-turn",
)
@@ -2211,6 +2221,113 @@ async def run():
asyncio.run(run())
+@pytest.mark.parametrize("child_command", [False, True])
+def test_managed_turn_tracks_only_its_own_background_commands(child_command):
+ from cc_remote.wrapper.claude_agents import ClaudeAgentRegistry
+
+ async def run():
+ class TaskSdk(_AutoCompactSdk):
+ async def query(self, _prompt):
+ return None
+
+ async def refresh_goal(self, _session_id):
+ return None
+
+ async def receive_response(self):
+ if child_command:
+ yield AssistantMessage(
+ content=[ToolUseBlock(id="review", name="Agent", input={})],
+ model="claude-test")
+ yield AssistantMessage(
+ content=[ToolUseBlock(id="check", name="Bash", input={
+ "command": "make check", "run_in_background": True})],
+ model="claude-test",
+ parent_tool_use_id="review" if child_command else None)
+ yield TaskStartedMessage(
+ subtype="task_started", data={}, task_id="check-task",
+ tool_use_id="check", task_type="local_bash",
+ description="checks", uuid="check-start", session_id=SESSION_ID)
+ yield ResultMessage(
+ subtype="success", duration_ms=1, duration_api_ms=1,
+ is_error=False, num_turns=1, session_id=SESSION_ID)
+
+ machine, transport, ctx = _machine_with_sdk(TaskSdk())
+ ctx.claude_agents = ClaudeAgentRegistry(1024)
+ ctx.state = "running"
+ ctx.active_msg_id = "managed-review"
+ await asyncio.wait_for(machine._run_turn(ctx, "review"), timeout=1)
+ assert not [event for event in transport.sent if isinstance(event, Error)]
+ assert ctx.state == "idle"
+ assert ctx.claude_active_tasks == (set() if child_command else {"check-task"})
+
+ asyncio.run(run())
+
+
+def test_child_background_command_does_not_claim_a_main_followup():
+ from cc_remote.wrapper.claude_agents import ClaudeAgentRegistry
+ from cc_remote.wrapper.stream import public_agent_run_id
+
+ async def run():
+ machine, transport, ctx = _machine_with_sdk(_AutoCompactSdk())
+ registry = ctx.claude_agents = ClaudeAgentRegistry(1024)
+ registry.route(AssistantMessage(
+ content=[ToolUseBlock(id="review", name="Agent", input={})],
+ model="claude-test"))
+ registry.route(AssistantMessage(
+ content=[ToolUseBlock(id="child-check", name="Bash", input={
+ "command": "make check", "run_in_background": True})],
+ model="claude-test", parent_tool_use_id="review"))
+ # Preserve the real parent task while isolating all of the child's
+ # background lifecycle, including an update without a tool-use id.
+ ctx.claude_active_tasks.add("review-task")
+ messages = [
+ TaskStartedMessage(
+ subtype="task_started", data={}, task_id="child-task",
+ tool_use_id="child-check", task_type="local_bash",
+ description="checks", uuid="child-start", session_id=SESSION_ID),
+ TaskUpdatedMessage(
+ subtype="task_updated", data={}, task_id="child-task",
+ patch={"status": "running"}, status="running"),
+ TaskNotificationMessage(
+ subtype="task_notification", data={}, task_id="child-task",
+ tool_use_id="child-check", status="completed", output_file="",
+ summary="checks passed", uuid="child-end", session_id=SESSION_ID),
+ ]
+ for message in messages:
+ await machine._on_claude_background_message(ctx, message, "human-turn")
+ assert ctx.claude_background_followups == {}
+ assert ctx.claude_active_tasks == {"review-task"}
+ assert ctx.state == "idle"
+ assert not any(isinstance(event, ProcessEvent) for event in transport.sent)
+ child = registry.snapshot(public_agent_run_id("review"))
+ assert child is not None
+ assert any(event.get("item_id") == "child-task" for event in child.events)
+
+ asyncio.run(run())
+
+
+def test_completed_service_seed_does_not_reopen_an_idle_session():
+ async def run():
+ machine, _transport, ctx = _machine_with_sdk(_AutoCompactSdk())
+ message = TaskNotificationMessage(
+ subtype="task_notification", data={}, task_id="old-task",
+ status="completed", output_file="", summary="already done",
+ uuid="seed", session_id=SESSION_ID, tool_use_id="old-tool")
+ message._cc_service_seed = True
+ await machine._on_claude_background_message(ctx, message, "old-turn")
+ assert ctx.claude_background_followups == {}
+ assert ctx.state == "idle"
+ # An actual unacknowledged continuation is still authoritative even
+ # when its preceding notification was only a reattachment seed.
+ await machine._on_claude_background_message(ctx, UserMessage(
+ content="task completed", origin={
+ "kind": "task-notification", "taskId": "old-task"}), "old-turn")
+ assert ctx.claude_background_followup_pending is True
+ assert ctx.state == "running"
+
+ asyncio.run(run())
+
+
def test_background_result_projection_failure_still_settles_lifecycle():
class BrokenProjectionSdk(_AutoCompactSdk):
def observe_goal_message(self, message, _thread_id):
diff --git a/tests/test_claude_btw_identity.py b/tests/test_claude_btw_identity.py
new file mode 100644
index 00000000..741521dc
--- /dev/null
+++ b/tests/test_claude_btw_identity.py
@@ -0,0 +1,165 @@
+"""Private Claude forks must not block catalogs before their first prompt."""
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+from uuid import UUID
+
+import pytest
+
+from cc_remote.protocol import CloseBtw, ListSessions, SessionList
+from cc_remote.wrapper import machine as machine_module
+from cc_remote.wrapper.sdk import SdkHandle
+from tests.test_multisession import _mk_ctx, _mk_machine
+
+
+@pytest.fixture
+def setup(monkeypatch):
+ machine, transport = _mk_machine()
+ handles = []
+ catalog = []
+ deleted = []
+
+ class Handle(SdkHandle):
+ @staticmethod
+ def preflight(_path):
+ pass
+
+ async def connect(self, resume_id=None, cwd=None, fork=False):
+ self.launch = self._options(resume_id, cwd, fork=fork)
+ handles.append(self)
+ # Model a native transcript visible as soon as connect starts,
+ # before _spawn_btw has inserted the context into the pool.
+ catalog.append(SimpleNamespace(
+ session_id=self.launch.session_id, summary="private", cwd=cwd,
+ first_prompt="private prompt", last_modified=1,
+ git_branch=None, tag=None,
+ ))
+ assert self.launch.session_id in machine._load_private_btw_sessions()
+ listed = await machine._handle_list_sessions(ListSessions(
+ client_id="other-client", cmd_id="during-connect"))
+ assert isinstance(listed, SessionList)
+ assert listed.sessions == []
+
+ async def disconnect(self):
+ pass
+
+ monkeypatch.setattr(machine_module, "SdkHandle", Handle)
+ monkeypatch.setattr(machine, "_claude_catalog_list_sessions",
+ lambda *_args, **_kwargs: list(catalog))
+ monkeypatch.setattr(machine, "_bg_blocked_session_ids", lambda *_args: set())
+ monkeypatch.setattr(machine, "_claude_catalog_delete_session",
+ lambda _profile, sid, **_kwargs: deleted.append(sid))
+ parent = _mk_ctx("parent", "parent")
+ parent.sdk = SimpleNamespace(permission_mode="plan")
+ machine.sessions[parent.key] = parent
+ machine.focused_sid = parent.key
+ return machine, transport, parent, handles, deleted
+
+
+def test_empty_btw_is_private_without_blocking_owner_or_other_client(setup):
+ async def run():
+ machine, transport, parent, handles, _deleted = setup
+ fork = await machine._spawn_btw(parent, owner_client_id="owner")
+ reserved = fork.btw_reserved_id
+ assert str(UUID(reserved)) == reserved
+ assert handles[0].launch.session_id == reserved
+ assert fork.btw_real_id is None
+ assert fork.session_id is None
+ assert machine.focused_sid == parent.key
+ for client in ("owner", "other-client"):
+ listed = await machine._handle_list_sessions(ListSessions(
+ client_id=client, cmd_id=client))
+ assert isinstance(listed, SessionList)
+ assert listed.sessions == []
+ assert all(event.type != "error" for event in transport.sent)
+ # Reserved is not yet resumable. A settings reconnect must repeat the
+ # fork with its original reserved UUID until a native init captures it.
+ assert machine._claude_reconnect_identity(fork) == (parent.session_id, True)
+ assert fork.sdk._options(parent.session_id, fork=True).session_id == reserved
+ await machine._capture_session_id(fork, reserved)
+ assert machine._claude_reconnect_identity(fork) == (reserved, False)
+ assert fork.sdk._options(reserved).session_id is None
+ assert fork.key.startswith("btw-")
+ assert parent.session_id == "parent"
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("captured", [False, True])
+def test_close_cleans_reserved_identity_even_without_a_first_turn(setup, captured):
+ async def run():
+ machine, _transport, parent, _handles, deleted = setup
+ fork = await machine._spawn_btw(parent, owner_client_id="owner")
+ reserved = fork.btw_reserved_id
+ if captured:
+ await machine._capture_session_id(fork, reserved)
+ await machine._handle_close_btw(CloseBtw(sid=fork.key, client_id="owner"))
+ assert deleted == [reserved]
+ assert reserved not in machine._load_private_btw_sessions()
+ assert parent.key in machine.sessions
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("cancelled", [False, True])
+@pytest.mark.parametrize("disconnect_failed", [False, True])
+def test_failed_fork_retains_privacy_until_native_writer_is_stopped(
+ setup, monkeypatch, cancelled, disconnect_failed,
+):
+ async def run():
+ machine, _transport, parent, handles, deleted = setup
+ handle_type = machine_module.SdkHandle
+ connect = handle_type.connect
+
+ async def fail_connect(self, **kwargs):
+ await connect(self, **kwargs)
+ if cancelled:
+ raise asyncio.CancelledError()
+ raise RuntimeError("fixture connect failure")
+
+ async def fail_disconnect(self):
+ raise RuntimeError("fixture disconnect failure")
+
+ monkeypatch.setattr(handle_type, "connect", fail_connect)
+ if disconnect_failed:
+ monkeypatch.setattr(handle_type, "disconnect", fail_disconnect)
+ error = asyncio.CancelledError if cancelled else machine_module._BtwSpawnFailure
+ with pytest.raises(error):
+ await machine._spawn_btw(parent, owner_client_id="owner")
+ reserved = handles[0].launch.session_id
+ assert deleted == [reserved]
+ assert (reserved in machine._load_private_btw_sessions()) is disconnect_failed
+ assert list(machine.sessions) == [parent.key]
+
+ asyncio.run(run())
+
+
+def test_failed_private_registration_never_starts_a_native_fork(setup, monkeypatch):
+ async def run():
+ machine, _transport, parent, handles, deleted = setup
+
+ def fail(*_args):
+ raise RuntimeError("fixture storage failure")
+
+ monkeypatch.setattr(machine, "_remember_private_btw", fail)
+ with pytest.raises(machine_module._BtwSpawnFailure):
+ await machine._spawn_btw(parent, owner_client_id="owner")
+ assert handles == []
+ assert deleted == []
+ assert list(machine.sessions) == [parent.key]
+
+ asyncio.run(run())
+
+
+def test_reservation_is_not_counted_twice_against_private_fork_cap(setup, monkeypatch):
+ async def run():
+ machine, _transport, parent, _handles, _deleted = setup
+ monkeypatch.setattr(machine, "PRIVATE_BTW_CAP", 2)
+ first = await machine._spawn_btw(parent, owner_client_id="owner")
+ second = await machine._spawn_btw(parent, owner_client_id="owner")
+ assert first.btw_reserved_id != second.btw_reserved_id
+ with pytest.raises(machine_module._BtwSpawnFailure):
+ await machine._spawn_btw(parent, owner_client_id="owner")
+
+ asyncio.run(run())
diff --git a/tests/test_claude_compaction_flow.py b/tests/test_claude_compaction_flow.py
new file mode 100644
index 00000000..df33f07c
--- /dev/null
+++ b/tests/test_claude_compaction_flow.py
@@ -0,0 +1,182 @@
+"""Exercise native compact ordering through the real SDK pump and projection."""
+from __future__ import annotations
+
+import asyncio
+import json
+from types import SimpleNamespace
+
+import pytest
+from claude_agent_sdk.types import ResultMessage, SystemMessage
+
+from cc_remote.protocol import GetContext, ProcessEvent
+from cc_remote.wrapper.claude_compaction import compact_metadata
+from cc_remote.wrapper.sdk import SdkHandle
+from cc_remote.wrapper.stream import StreamTranslator
+from cc_remote.wrapper.work_context import recover_claude_context_usage
+from tests.test_claude_autocompact import SESSION_ID, _machine_with_sdk
+
+
+def boundary():
+ return {
+ "type": "system", "subtype": "compact_boundary", "uuid": "native-boundary",
+ "compact_metadata": {
+ "trigger": "manual", "pre_tokens": 600_000,
+ "post_tokens": 8_000, "duration_ms": 20_000,
+ },
+ }
+
+
+def result():
+ return {
+ "type": "result", "subtype": "success", "duration_ms": 20_000,
+ "duration_api_ms": 19_000, "is_error": False, "num_turns": 1,
+ "session_id": SESSION_ID,
+ }
+
+
+def status():
+ return {"type": "system", "subtype": "status", "status": "compacting",
+ "uuid": "compact-start"}
+
+
+class CompactClient:
+ def __init__(self, rows):
+ self._query = self
+ self.queue = asyncio.Queue()
+ self.rows = rows
+ self.prompts = []
+
+ async def query(self, prompt):
+ self.prompts.append(prompt)
+ for row in self.rows:
+ await self.queue.put(row)
+
+ async def receive_messages(self):
+ while True:
+ yield await self.queue.get()
+
+
+@pytest.mark.parametrize("replay_user", [False, True])
+@pytest.mark.parametrize("post_tokens", [8_000, 0])
+def test_native_compact_boundary_before_user_replay_reaches_control(replay_user, post_tokens):
+ async def run():
+ compact = boundary()
+ compact["compact_metadata"]["post_tokens"] = post_tokens
+ rows = [status(), status(), compact]
+ if replay_user:
+ rows.append({"type": "user", "uuid": "native-user",
+ "message": {"role": "user", "content": "/compact"}})
+ rows.append(result())
+ handle = SdkHandle(SimpleNamespace(turn_reader_queue_cap=4))
+ handle.client = CompactClient(rows)
+ handle._record_context_usage({
+ "totalTokens": 600_000, "maxTokens": 800_000,
+ "rawMaxTokens": 1_000_000, "autoCompactThreshold": 800_000,
+ })
+ background = []
+
+ async def on_background(message, _turn):
+ background.append(message)
+
+ handle.background_message_callback = on_background
+ handle._start_message_pump()
+ machine, transport, ctx = _machine_with_sdk(handle)
+ try:
+ event = await asyncio.wait_for(machine._compact_managed_claude_context(
+ ctx, reason="test native order"), timeout=1)
+ assert event.item_id == "native-boundary"
+ assert event.summary == f"手动压缩 · 600,000 → {post_tokens:,} tokens"
+ assert event.duration_ms == 20_000
+ assert event.input == {"compaction_started_id": "compact-start"}
+ assert handle.client.prompts == ["/compact"]
+ assert background == []
+ processes = [e for e in transport.sent if isinstance(e, ProcessEvent)]
+ assert [e.phase for e in processes] == ["start", "end"]
+ assert ctx.claude_compaction_revision == 1
+ report = await machine._handle_get_context(GetContext(sid=SESSION_ID))
+ assert report.available is not False
+ assert report.total_tokens == post_tokens
+ assert report.max_tokens == 800_000
+ assert report.auto_compact_threshold_tokens == 800_000
+ assert report.categories == []
+ finally:
+ await handle._stop_message_pump()
+
+ asyncio.run(run())
+
+
+def test_malformed_context_read_preserves_count_and_capacity():
+ handle = SdkHandle(SimpleNamespace())
+ valid = {"totalTokens": 8_000, "maxTokens": 800_000,
+ "rawMaxTokens": 1_000_000, "autoCompactThreshold": 800_000}
+ handle._record_context_usage(valid)
+ handle._record_context_usage({"model": "claude-sonnet-4-6"})
+ assert handle.cached_context_usage() == valid
+ assert handle.raw_context_max_tokens == 1_000_000
+ assert handle.effective_auto_compact_threshold_tokens == 800_000
+
+
+def test_regular_prompt_does_not_claim_an_earlier_compact_boundary():
+ async def run():
+ rows = [status(), boundary(),
+ {"type": "user", "uuid": "new-user", "origin": {"kind": "human"},
+ "message": {"role": "user", "content": "continue"}}, result()]
+ handle = SdkHandle(SimpleNamespace(turn_reader_queue_cap=4))
+ handle.client = CompactClient(rows)
+ background = []
+
+ async def on_background(message, _turn):
+ background.append(message)
+
+ handle.background_message_callback = on_background
+ handle._start_message_pump()
+ try:
+ await handle.query("continue")
+ messages = [message async for message in handle.receive_response()]
+ handle.release_background_messages()
+ await asyncio.wait_for(handle._background_callbacks_drained.wait(), 1)
+ assert not any(isinstance(m, SystemMessage) for m in messages)
+ assert [m.subtype for m in background] == ["status", "compact_boundary"]
+ assert isinstance(messages[-1], ResultMessage)
+ finally:
+ await handle._stop_message_pump()
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("is_error", [False, True])
+def test_terminal_without_boundary_stops_animation_without_success(is_error):
+ translator = StreamTranslator(1024, turn_id="turn")
+ translator.feed(SystemMessage(subtype="status", data=status()))
+ terminal = result()
+ terminal.pop("type")
+ terminal["is_error"] = is_error
+ events = translator.feed(ResultMessage(**terminal))
+ process = next(e for e in events if isinstance(e, ProcessEvent))
+ assert process.item_id == "compact-start"
+ assert process.phase == "end"
+ assert process.status == ("interrupted" if is_error else "failed")
+ assert process.summary is None
+
+
+@pytest.mark.parametrize("post", [8_000, 0, None])
+def test_cold_context_read_stops_at_latest_compact_boundary(tmp_path, post):
+ path = tmp_path / "session.jsonl"
+ end = boundary()
+ end["compact_metadata"]["post_tokens"] = post
+ old = {"type": "assistant", "message": {"usage": {"input_tokens": 600_000}}}
+ path.write_text("\n".join(map(json.dumps, [old, end])))
+ expected = {"totalTokens": post} if post is not None else None
+ assert recover_claude_context_usage(SESSION_ID, path=str(path)) == expected
+ with path.open("a") as stream:
+ stream.write("\n" + json.dumps({"type": "assistant", "message": {
+ "usage": {"input_tokens": 8_000, "output_tokens": 500}}}))
+ assert recover_claude_context_usage(SESSION_ID, path=str(path)) == {
+ "totalTokens": 8_500}
+
+
+@pytest.mark.parametrize("value", [True, -1, 2**54, [], "8000"])
+def test_compact_metadata_does_not_publish_invalid_counters(value):
+ assert compact_metadata({"compact_metadata": {
+ "trigger": [], "pre_tokens": value, "post_tokens": value,
+ "duration_ms": value}}) == {}
diff --git a/tests/test_claude_compaction_history.py b/tests/test_claude_compaction_history.py
new file mode 100644
index 00000000..4fc01ae5
--- /dev/null
+++ b/tests/test_claude_compaction_history.py
@@ -0,0 +1,221 @@
+"""Native manual compaction must not manufacture a human conversation turn."""
+import json
+import sqlite3
+
+import pytest
+
+from cc_remote.protocol import Delta, ProcessEvent, TurnEnd, UserMsg
+from cc_remote.wrapper.history_store import HistoryIndexStore, HistorySourceFingerprint
+from tests.test_history_store import _page
+from cc_remote.wrapper.stream import (
+ transcript_compact_history_page,
+ transcript_compact_snapshot,
+ translate_history,
+)
+
+SID = "11111111-1111-4111-8111-111111111111"
+
+
+@pytest.mark.parametrize("blocks", [False, True])
+def test_internal_recovery_stays_in_human_turn_across_compact_pages(tmp_path, blocks):
+ recovery = (
+ "Your response above was cut off mid-stream. Resume directly from where "
+ "it stops — no apology, no recap. If none of it survived, answer the "
+ "request from the start."
+ )
+
+ def row(uid, role, content, parent, second, **extra):
+ return {"uuid": uid, "type": role, "parentUuid": parent,
+ "timestamp": f"2026-09-16T11:20:{second:02d}Z",
+ "message": {"role": role, "content": content}, **extra}
+
+ records = [
+ row("older", "user", "Earlier question", None, 0),
+ row("old-answer", "assistant", [{"type": "text", "text": "Earlier answer"}],
+ "older", 1),
+ {"uuid": "boundary", "type": "system", "subtype": "compact_boundary",
+ "parentUuid": None, "logicalParentUuid": "old-answer",
+ "timestamp": "2026-09-16T11:20:02Z",
+ "compactMetadata": {"trigger": "auto", "preTokens": 500_000}},
+ row("summary", "user", "This session is being continued from a previous conversation.",
+ "boundary", 2, isCompactSummary=True),
+ row("human", "user", "Analyze the wake acknowledgement", "summary", 3),
+ row("recovery", "user", [{"type": "text", "text": recovery}] if blocks else recovery,
+ "human", 4, isMeta=True),
+ row("answer", "assistant", [{"type": "text", "text": "The complete analysis"}],
+ "recovery", 5),
+ # Identical text really sent by a human must remain visible.
+ row("literal-human", "user", recovery, "answer", 6),
+ row("literal-answer", "assistant", [{"type": "text", "text": "Literal reply"}],
+ "literal-human", 7),
+ ]
+ source = tmp_path / f"{SID}.jsonl"
+ source.write_text("".join(json.dumps(r) + "\n" for r in records))
+ store = HistoryIndexStore(tmp_path / "index")
+ for iteration in range(3): # Cold read, cached graph, then a v40 migration.
+ messages, timestamps, internal = transcript_compact_snapshot(
+ SID, path=str(source), index_store=store)
+ events = translate_history(messages, 4096, timestamps, internal)
+ assert [e.msg_id for e in events if isinstance(e, UserMsg)] == [
+ "older", "human", "literal-human"]
+ assert [e.checkpoint_id for e in events if isinstance(e, TurnEnd)] == [
+ "older", "human", "literal-human"]
+ assert [e.text for e in events if isinstance(e, Delta)].count(
+ "The complete analysis") == 1
+ page = transcript_compact_history_page(
+ SID, path=str(source), index_store=store, before="literal-human", limit=1)
+ assert page is not None
+ assert page.oldest_cursor == "human"
+ projected = translate_history(page.messages, 4096, page.timestamps, page.internal_events)
+ assert [e.prompt for e in projected if isinstance(e, UserMsg)] == [
+ "Analyze the wake acknowledgement"]
+ assert any(isinstance(e, Delta) and e.text == "The complete analysis" for e in projected)
+ if iteration == 1:
+ fingerprint = HistorySourceFingerprint.capture(source)
+ for engine in ("claude", "codex"):
+ store.put_page(SID, engine, fingerprint, before=None, limit=4, page=_page(engine))
+ store.put_image_asset(SID, engine, fingerprint, engine, "image",
+ "thumbnail", "image/png", 1, 1, b"image")
+ with sqlite3.connect(store.path) as db:
+ db.execute("UPDATE claude_compact_records SET visible_user=1 WHERE uuid='recovery'")
+ db.execute("PRAGMA user_version=40")
+ store = HistoryIndexStore(tmp_path / "index")
+ assert store.get_page(SID, "claude", fingerprint, before=None, limit=4) is None
+ # v42 also rebuilds bounded Codex summaries while retaining assets.
+ assert store.get_page(SID, "codex", fingerprint, before=None, limit=4) is None
+ with sqlite3.connect(store.path) as db:
+ assert db.execute("SELECT count(*) FROM claude_compact_records").fetchone()[0] == 0
+ assert db.execute("SELECT count(*) FROM history_image_assets").fetchone()[0] == 2
+
+
+@pytest.mark.parametrize("blocks", [False, True])
+def test_manual_compact_replay_preserves_answer_and_pagination(tmp_path, blocks):
+ def row(uid, role, content, timestamp, parent=None, **extra):
+ return {"uuid": uid, "type": role, "parentUuid": parent,
+ "timestamp": timestamp, "message": {"role": role, "content": content},
+ **extra}
+
+ caveat = "Internal command disclaimer"
+ if blocks:
+ caveat = [{"type": "text", "text": caveat}]
+ records = [
+ row("human", "user", "inspect the code", "2026-09-15T02:38:00Z"),
+ row("answer", "assistant", [{"type": "text", "text": "Inspection complete."}],
+ "2026-09-15T02:38:05Z", "human"),
+ {"uuid": "boundary", "type": "system", "subtype": "compact_boundary",
+ "parentUuid": None, "logicalParentUuid": "answer",
+ "timestamp": "2026-09-15T02:46:05Z",
+ "compactMetadata": {"trigger": "manual", "preTokens": 600_000}},
+ row("summary", "user", "This session is being continued from a previous conversation.",
+ "2026-09-15T02:46:05Z", "boundary", isCompactSummary=True),
+ row("caveat", "user", caveat, "2026-09-15T02:43:34Z", "summary", isMeta=True),
+ row("command", "user", "/compact",
+ "2026-09-15T02:43:34Z", "caveat"),
+ row("stdout", "user", "Compacted",
+ "2026-09-15T02:46:07Z", "command"),
+ ]
+ source = tmp_path / f"{SID}.jsonl"
+ source.write_text("".join(json.dumps(r) + "\n" for r in records))
+ store = HistoryIndexStore(tmp_path / "index")
+ for iteration in range(3): # Initial graph, unchanged cache, and v38 upgrade.
+ messages, timestamps, internal = transcript_compact_snapshot(
+ SID, path=str(source), index_store=store)
+ events = translate_history(messages, 4096, timestamps, internal)
+ assert [e.prompt for e in events if isinstance(e, UserMsg)] == ["inspect the code"]
+ ends = [e for e in events if isinstance(e, TurnEnd)]
+ assert len(ends) == 1
+ assert ends[0].ts == timestamps["answer"]
+ assert ends[0].result.duration_ms == 5_000
+ compact = [e for e in events if isinstance(e, ProcessEvent) and e.kind == "compaction"]
+ assert len(compact) == 1
+ assert compact[0].ts == timestamps["boundary"]
+ page = transcript_compact_history_page(SID, path=str(source), index_store=store, limit=1)
+ assert page is not None
+ assert not page.has_more
+ assert page.oldest_cursor == "human"
+ if iteration == 1:
+ fingerprint = HistorySourceFingerprint.capture(source)
+ for engine in ("claude", "codex"):
+ store.put_page(SID, engine, fingerprint, before=None, limit=4, page=_page(engine))
+ store.put_image_asset(SID, engine, fingerprint, engine, "image",
+ "thumbnail", "image/png", 1, 1, b"image")
+ with sqlite3.connect(store.path) as db:
+ db.execute("UPDATE claude_compact_records SET visible_user=1 WHERE uuid='caveat'")
+ db.execute("PRAGMA user_version=38")
+ store = HistoryIndexStore(tmp_path / "index")
+ assert store.get_page(SID, "claude", fingerprint, before=None, limit=4) is None
+ # v42 also rebuilds bounded Codex summaries while retaining assets.
+ assert store.get_page(SID, "codex", fingerprint, before=None, limit=4) is None
+ with sqlite3.connect(store.path) as db:
+ assert db.execute("SELECT count(*) FROM claude_compact_records").fetchone()[0] == 0
+ assert db.execute("SELECT count(*) FROM history_image_assets").fetchone()[0] == 2
+
+
+def test_compact_controls_survive_reload_in_the_same_account(tmp_path):
+ import asyncio
+ from copy import copy
+
+ from cc_remote.wrapper.claude_controls import ClaudeControlStore
+ from tests.test_claude_autocompact import _AutoCompactSdk, _machine_with_sdk
+
+ async def run():
+ sdk = _AutoCompactSdk()
+ sdk.set_auto_compact("custom", 500_000)
+ machine, _, ctx = _machine_with_sdk(sdk)
+ machine._claude_controls = ClaudeControlStore(tmp_path)
+ machine.sessions.clear()
+ ctx.key = f"primary@{ctx.session_id}"
+ machine.sessions[ctx.key] = ctx
+ sibling = copy(ctx)
+ sibling.key = f"stack@{ctx.session_id}"
+ sibling.sdk = _AutoCompactSdk()
+ sibling.sdk.set_auto_compact("custom", 700_000)
+ machine.sessions[sibling.key] = sibling
+ await machine._persist_claude_session_controls(ctx)
+ await machine._persist_claude_session_controls(sibling)
+ machine._claude_controls = ClaudeControlStore(tmp_path)
+ saved = await machine._load_claude_session_controls(ctx.key)
+ assert saved.auto_compact_threshold_tokens == 500_000
+ assert saved.applied_auto_compact_mode == "inherit"
+ assert (await machine._load_claude_session_controls(
+ sibling.key)).auto_compact_threshold_tokens == 700_000
+ assert machine._claude_controls.get(ctx.session_id).auto_compact_mode == "inherit"
+
+ asyncio.run(run())
+
+
+def test_manual_compact_resyncs_profile_scoped_watch(tmp_path):
+ import asyncio
+ from types import SimpleNamespace
+
+ from cc_remote.wrapper.sdk import SdkHandle
+ from tests.test_claude_autocompact import _machine_with_sdk
+ from tests.test_claude_compaction_flow import CompactClient, boundary, result, status
+
+ async def run():
+ path = tmp_path / "native.jsonl"
+ path.write_text("before")
+ handle = SdkHandle(SimpleNamespace(turn_reader_queue_cap=4))
+ handle.client = CompactClient([status(), boundary(), result()])
+ original = handle.client.query
+
+ async def compact(prompt):
+ path.write_text("before plus native compact records")
+ await original(prompt)
+
+ handle.client.query = compact
+ handle._start_message_pump()
+ machine, _, ctx = _machine_with_sdk(handle)
+ machine.sessions.clear()
+ ctx.key = f"primary@{ctx.session_id}"
+ machine.sessions[ctx.key] = ctx
+ watch = {"path": str(path), "size": len("before")}
+ machine._watch[ctx.key] = watch
+ try:
+ await machine._compact_managed_claude_context(ctx, reason="test")
+ assert watch["size"] == path.stat().st_size
+ assert not ctx.claude_write_active
+ finally:
+ await handle._stop_message_pump()
+
+ asyncio.run(run())
diff --git a/tests/test_claude_live_context.py b/tests/test_claude_live_context.py
new file mode 100644
index 00000000..1b6fec12
--- /dev/null
+++ b/tests/test_claude_live_context.py
@@ -0,0 +1,279 @@
+"""Live context reads must not compete with or restart the active Claude turn."""
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+from claude_agent_sdk.types import AssistantMessage, SystemMessage, TextBlock
+
+from cc_remote.config import WrapperConfig
+from cc_remote.protocol import ContextReport, Error, GetContext
+from cc_remote.wrapper.sdk import SdkHandle
+from tests.test_claude_autocompact import SESSION_ID, _machine_with_sdk
+
+
+SUMMARY = {
+ "totalTokens": 242_701, "maxTokens": 500_000,
+ "rawMaxTokens": 1_000_000, "autoCompactThreshold": 467_000,
+ "percentage": 48.5402, "model": "claude-fable-5-1",
+ "isAutoCompactEnabled": True, "categories": [],
+}
+
+
+def assistant(total):
+ return AssistantMessage(content=[TextBlock(text="working")],
+ model="claude-fable-5-1", usage={"input_tokens": total})
+
+
+class SummaryClient:
+ def __init__(self, read=None):
+ self._query = self
+ self.requests = []
+ self.read = read
+
+ async def _send_control_request(self, request, timeout):
+ self.requests.append((request, timeout))
+ if self.read is not None:
+ return self.read()
+ return dict(SUMMARY)
+
+
+def test_live_summary_yields_to_a_pending_sdk_control_operation():
+ async def go():
+ sdk = SdkHandle(WrapperConfig())
+ sdk._record_context_usage(dict(SUMMARY))
+ client = sdk.client = SummaryClient()
+ machine, _, ctx = _machine_with_sdk(sdk)
+ ctx.state = "running"
+ async with sdk._control_request_lock:
+ report = await asyncio.wait_for(machine._handle_get_context(GetContext(
+ sid=SESSION_ID, refresh=True)), 0.5)
+ assert report.total_tokens == 242_701
+ assert client.requests == []
+ assert not sdk.control_plane_failed
+ asyncio.run(go())
+
+
+def test_live_summary_releases_machine_lock_for_the_stream_terminal():
+ async def go():
+ sdk = SdkHandle(WrapperConfig())
+ machine, _, ctx = _machine_with_sdk(sdk)
+ ctx.state = "running"
+
+ class StreamDrainingClient(SummaryClient):
+ async def _send_control_request(self, request, timeout):
+ # A terminal callback must be able to finish draining while the
+ # same SDK reader delivers the native control response.
+ async with ctx.query_lock:
+ ctx.state = "idle"
+ return dict(SUMMARY)
+
+ client = sdk.client = StreamDrainingClient()
+ report = await asyncio.wait_for(machine._handle_get_context(GetContext(
+ sid=SESSION_ID, refresh=True)), 0.5)
+ assert report.max_tokens == 500_000
+ assert ctx.state == "idle"
+ assert sdk.client is client
+ asyncio.run(go())
+
+
+def test_model_change_while_reacquiring_machine_lock_retires_the_summary():
+ async def go():
+ sdk = SdkHandle(WrapperConfig())
+ machine, _, ctx = _machine_with_sdk(sdk)
+ ctx.state = "running"
+ changes = []
+
+ class SwitchingClient(SummaryClient):
+ async def _send_control_request(self, request, timeout):
+ await ctx.query_lock.acquire()
+
+ async def switch():
+ sdk.invalidate_context_usage_cache()
+ sdk.model = "claude-opus-4-6"
+ sdk._observe_recent_context_usage(assistant(60_000))
+ ctx.query_lock.release()
+
+ # The SDK receives its response first, then the machine waits
+ # behind a model change before it can publish the report.
+ changes.append(asyncio.create_task(switch()))
+ return dict(SUMMARY)
+
+ sdk.client = SwitchingClient()
+ report = await asyncio.wait_for(machine._handle_get_context(GetContext(
+ sid=SESSION_ID, refresh=True)), 0.5)
+ await asyncio.gather(*changes)
+ assert report.model == "claude-opus-4-6"
+ assert report.total_tokens == 60_000
+ assert report.max_tokens == 0
+ asyncio.run(go())
+
+
+def test_running_claude_summary_restores_capacity_then_polls_live_usage():
+ async def go():
+ sdk = SdkHandle(WrapperConfig())
+ client = sdk.client = SummaryClient()
+ machine, transport, ctx = _machine_with_sdk(sdk)
+ ctx.state = "running"
+ ctx.turn_task = asyncio.create_task(asyncio.Event().wait())
+ try:
+ report = await machine._handle_get_context(GetContext(
+ sid=SESSION_ID, refresh=True, cmd_id="open"))
+ assert isinstance(report, ContextReport)
+ assert report.source == "control"
+ assert report.request_id == "open"
+ assert (report.total_tokens, report.max_tokens) == (242_701, 500_000)
+ assert report.raw_max_tokens == 1_000_000
+ assert report.auto_compact_threshold_tokens == 467_000
+ assert client.requests == [(
+ {"subtype": "get_context_usage", "detail": "summary"}, 5.0)]
+
+ sdk._observe_recent_context_usage(assistant(339_685))
+ report = await machine._handle_get_context(GetContext(
+ sid=SESSION_ID, refresh=False, cmd_id="poll"))
+ assert report.source == "recent_turn"
+ assert report.total_tokens == 339_685
+ assert report.max_tokens == 500_000
+ assert report.percentage == pytest.approx(67.937)
+ assert len(client.requests) == 1
+ assert ctx.state == "running"
+ assert sdk.client is client
+ assert not ctx.turn_task.done()
+ assert all(isinstance(event, ContextReport) for event in transport.sent)
+ finally:
+ ctx.turn_task.cancel()
+ await asyncio.gather(ctx.turn_task, return_exceptions=True)
+ asyncio.run(go())
+
+
+@pytest.mark.parametrize("failure", ["timeout", "unsupported", "malformed"])
+def test_running_summary_failure_retains_reading_and_never_restarts(failure):
+ async def go():
+ sdk = SdkHandle(WrapperConfig())
+ sdk._record_context_usage(dict(SUMMARY))
+ sdk._observe_recent_context_usage(assistant(339_685))
+
+ def fail():
+ if failure == "malformed":
+ return {"totalTokens": 0, "maxTokens": 0}
+ if failure == "timeout":
+ raise Exception("Control request timeout: get_context_usage")
+ raise RuntimeError("summary unavailable")
+
+ client = sdk.client = SummaryClient(fail)
+ machine, transport, ctx = _machine_with_sdk(sdk)
+ ctx.state = "running"
+ report = await machine._handle_get_context(GetContext(
+ sid=SESSION_ID, refresh=True, cmd_id="open"))
+ assert isinstance(report, ContextReport)
+ assert (report.total_tokens, report.max_tokens) == (339_685, 500_000)
+ assert report.source == "recent_turn"
+ assert sdk.client is client
+ assert ctx.state == "running"
+ assert sdk.control_plane_failed == (failure == "timeout")
+ assert all(isinstance(event, ContextReport) for event in transport.sent)
+ cached = await machine._handle_get_context(GetContext(
+ sid=SESSION_ID, refresh=False, cmd_id="poll"))
+ assert cached.total_tokens == report.total_tokens
+ assert len(client.requests) == 1
+ asyncio.run(go())
+
+
+@pytest.mark.parametrize("boundary", ["new_assistant", "compact", "model"])
+def test_live_summary_does_not_overwrite_a_newer_stream_observation(boundary):
+ async def go():
+ sdk = SdkHandle(WrapperConfig())
+ sdk._record_context_usage(dict(SUMMARY))
+
+ def advance():
+ if boundary == "compact":
+ sdk._observe_context_boundary(SystemMessage(
+ subtype="compact_boundary", data={
+ "type": "system", "subtype": "compact_boundary",
+ "compact_metadata": {"trigger": "auto", "post_tokens": 50_000},
+ }))
+ elif boundary == "model":
+ sdk.invalidate_context_usage_cache()
+ sdk.model = "claude-opus-4-6"
+ sdk._observe_recent_context_usage(assistant(60_000))
+ return dict(SUMMARY)
+
+ sdk.client = SummaryClient(advance)
+ machine, _, ctx = _machine_with_sdk(sdk)
+ ctx.state = "running"
+ report = await machine._handle_get_context(GetContext(
+ sid=SESSION_ID, refresh=True))
+ assert report.total_tokens == 60_000
+ assert sdk.cached_recent_context_usage()["totalTokens"] == 60_000
+ assert report.categories == []
+ if boundary == "model":
+ assert report.model == "claude-opus-4-6"
+ assert report.max_tokens == 0
+ else:
+ assert report.max_tokens == 500_000
+ sdk._observe_recent_context_usage(assistant(61_000))
+ later = await machine._handle_get_context(GetContext(
+ sid=SESSION_ID, refresh=False))
+ assert (later.total_tokens, later.max_tokens) == (61_000, 500_000)
+ asyncio.run(go())
+
+
+def test_replacement_wrapper_recovers_running_service_context_without_restart():
+ from tests.test_claude_service import environment, released
+
+ async def go():
+ async with environment() as (service, attach):
+ first = await attach()
+ first.next_turn = {"id": "existing-turn"}
+ await first.query("existing task")
+ worker = service.sessions[first.id]
+ native = worker.client
+ requests = []
+
+ async def read(request, timeout):
+ requests.append((request, timeout))
+ return dict(SUMMARY)
+
+ native._send_control_request = read
+ await first.detach()
+ await released(worker)
+ replacement = await attach()
+ assert replacement.id == first.id
+ sdk = SdkHandle(WrapperConfig())
+ sdk.client = replacement
+ machine, _, ctx = _machine_with_sdk(sdk)
+ ctx.state = "running"
+ report = await machine._handle_get_context(GetContext(
+ sid=SESSION_ID, refresh=True))
+ assert report.max_tokens == 500_000
+ assert report.total_tokens == 242_701
+ assert len(requests) == 1
+ assert not native.closed
+ assert native.interrupts == 0
+ assert native.prompts == ["existing task"]
+ assert worker.turn["id"] == "existing-turn"
+ asyncio.run(go())
+
+
+@pytest.mark.parametrize("boundary", ["external", "stale", "poisoned", "broker"])
+def test_running_context_cannot_bypass_an_unavailable_writer(boundary):
+ async def go():
+ sdk = SdkHandle(WrapperConfig())
+ client = sdk.client = SummaryClient()
+ machine, _, ctx = _machine_with_sdk(sdk)
+ ctx.state = "running"
+ if boundary == "external":
+ ctx.write_state = "read_only"
+ elif boundary == "stale":
+ ctx.needs_reload = True
+ elif boundary == "poisoned":
+ sdk.control_plane_failed = True
+ else:
+ sdk.is_claude_broker = True
+ report = await machine._handle_get_context(GetContext(
+ sid=SESSION_ID, refresh=True))
+ assert isinstance(report, Error)
+ assert report.code == "busy"
+ assert client.requests == []
+ assert sdk.client is client
+ asyncio.run(go())
diff --git a/tests/test_claude_permission_state.py b/tests/test_claude_permission_state.py
index 2b73a157..2147175e 100644
--- a/tests/test_claude_permission_state.py
+++ b/tests/test_claude_permission_state.py
@@ -50,7 +50,7 @@ async def get_context_usage(self):
return {"model": self.options.model or "claude-mythos-5"}
async def _send_control_request(self, request, timeout):
- assert request == {"subtype": "get_context_usage"}
+ assert request == {"subtype": "get_context_usage", "detail": "summary"}
assert timeout == 5.0
return await self.get_context_usage()
@@ -404,9 +404,9 @@ class BlockingContext(_FakeClaudeClient):
release_context: asyncio.Event
async def _send_control_request(self, request, timeout):
- assert request == {"subtype": "get_context_usage"}
+ assert request == {"subtype": "get_context_usage", "detail": "summary"}
if self.block_context:
- assert timeout == 15.0
+ assert timeout == 60.0
self.context_started.set()
await self.release_context.wait()
else:
@@ -668,7 +668,7 @@ class ProbeTimeout(_FakeClaudeClient):
probes = 0
async def _send_control_request(self, request, timeout):
- assert request == {"subtype": "get_context_usage"}
+ assert request == {"subtype": "get_context_usage", "detail": "summary"}
assert timeout == 5.0
type(self).probes += 1
raise Exception("Control request timeout: get_context_usage")
@@ -703,7 +703,7 @@ class ProbeTimeout(_FakeClaudeClient):
probes = 0
async def _send_control_request(self, request, timeout):
- assert request == {"subtype": "get_context_usage"}
+ assert request == {"subtype": "get_context_usage", "detail": "summary"}
assert timeout == 5.0
type(self).probes += 1
raise Exception("Control request timeout: get_context_usage")
@@ -721,7 +721,7 @@ async def go():
assert ProbeTimeout.created[0].disconnected is True
assert handle.client is ProbeTimeout.created[1]
assert handle.control_plane_failed is False
- assert handle.context_probe_suppressed is False
+ assert handle.context_probe_suppressed is True
await handle.query("fresh child is ready")
assert ProbeTimeout.created[1].prompt == "fresh child is ready"
@@ -735,11 +735,11 @@ class ContextTimeout(_FakeClaudeClient):
fail_context = False
async def _send_control_request(self, request, timeout):
- assert request == {"subtype": "get_context_usage"}
+ assert request == {"subtype": "get_context_usage", "detail": "summary"}
if self.fail_context:
- assert timeout == 15.0
+ assert timeout == 60.0
raise Exception("Control request timeout: get_context_usage")
- assert timeout in {5.0, 15.0}
+ assert timeout in {5.0, 60.0}
return {"model": "claude-mythos-5", "totalTokens": 123}
async def go():
@@ -807,9 +807,9 @@ class ContextTimeout(_FakeClaudeClient):
fail_context = False
async def _send_control_request(self, request, timeout):
- assert request == {"subtype": "get_context_usage"}
+ assert request == {"subtype": "get_context_usage", "detail": "summary"}
if self.fail_context:
- assert timeout == 15.0
+ assert timeout == 60.0
raise Exception("Control request timeout: get_context_usage")
assert timeout == 5.0
return {
@@ -861,9 +861,9 @@ class BlockingContext(_FakeClaudeClient):
release_context: asyncio.Event
async def _send_control_request(self, request, timeout):
- assert request == {"subtype": "get_context_usage"}
+ assert request == {"subtype": "get_context_usage", "detail": "summary"}
if self.block_context:
- assert timeout == 15.0
+ assert timeout == 60.0
self.context_started.set()
await self.release_context.wait()
else:
diff --git a/tests/test_claude_rewind.py b/tests/test_claude_rewind.py
index 9618fc23..25cf8874 100644
--- a/tests/test_claude_rewind.py
+++ b/tests/test_claude_rewind.py
@@ -33,6 +33,7 @@ def test_claude_options_enable_file_checkpoints_and_user_message_replay():
assert options.enable_file_checkpointing is True
assert options.extra_args == {
"replay-user-messages": None,
+ "thinking-display": "summarized",
}
diff --git a/tests/test_claude_rich_stream.py b/tests/test_claude_rich_stream.py
index 2fc3dc2b..6d0228c6 100644
--- a/tests/test_claude_rich_stream.py
+++ b/tests/test_claude_rich_stream.py
@@ -727,8 +727,8 @@ def test_history_marks_task_completion_followup_as_later_background_segment():
)
assert notification.ts == 20.0 and notification.background is True
terminal = next(event for event in events if isinstance(event, TurnEnd))
- assert terminal.ts == 2.0
- assert terminal.result.duration_ms == 1_000
+ assert terminal.ts == 21.0
+ assert terminal.result.duration_ms == 20_000
assert terminal.turn_id == answer_after
diff --git a/tests/test_claude_service.py b/tests/test_claude_service.py
new file mode 100644
index 00000000..b94445c9
--- /dev/null
+++ b/tests/test_claude_service.py
@@ -0,0 +1,733 @@
+"""Zero-model regression for SDK lifetime across controller replacement."""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import tempfile
+import sys
+from pathlib import Path
+
+import pytest
+from claude_agent_sdk import ClaudeAgentOptions, PermissionResultAllow, ToolPermissionContext
+
+from cc_remote.claude_service.client import RemoteClient
+from cc_remote.claude_service.server import Service
+
+
+def test_private_socket_registration_and_explicit_override(tmp_path, monkeypatch):
+ from cc_remote.config import WrapperConfig, _claude_service_socket, wrapper_config
+
+ monkeypatch.setenv("CC_REMOTE_STATE_DIR", str(tmp_path))
+ monkeypatch.delenv("CC_REMOTE_CLAUDE_SERVICE_SOCKET", raising=False)
+ assert _claude_service_socket() == ""
+ registration = tmp_path / "claude-service.json"
+ registration.write_text('{"socket": "/private/service.sock"}')
+ registration.chmod(0o600)
+ assert _claude_service_socket() == "/private/service.sock"
+ assert wrapper_config().claude_service_socket == "/private/service.sock"
+ assert WrapperConfig().claude_service_socket == ""
+ monkeypatch.setenv("CC_REMOTE_CLAUDE_SERVICE_SOCKET", "/override.sock")
+ assert _claude_service_socket() == "/override.sock"
+ monkeypatch.setenv("CC_REMOTE_CLAUDE_SERVICE_SOCKET", "")
+ assert _claude_service_socket() == ""
+ monkeypatch.delenv("CC_REMOTE_CLAUDE_SERVICE_SOCKET")
+ registration.chmod(0o644)
+ with pytest.raises(ValueError):
+ _claude_service_socket()
+ registration.unlink()
+ registration.symlink_to(tmp_path / "missing.json")
+ with pytest.raises(OSError):
+ _claude_service_socket()
+
+
+class FakeClient:
+ def __init__(self, *, options, **kwargs):
+ self.options = options
+ self._query = self
+ self.queue = asyncio.Queue()
+ self.prompts = []
+ self.closed = False
+ self.interrupts = 0
+
+ async def connect(self):
+ pass
+
+ async def disconnect(self):
+ self.closed = True
+
+ async def query(self, prompt):
+ self.prompts.append(prompt)
+
+ async def interrupt(self):
+ self.interrupts += 1
+ await self.queue.put({"type": "result", "subtype": "error_during_execution"})
+
+ async def receive_messages(self):
+ while True:
+ yield await self.queue.get()
+
+
+@contextlib.asynccontextmanager
+async def environment():
+ # Darwin sockaddr_un is short; pytest's normal temp hierarchy exceeds it.
+ with tempfile.TemporaryDirectory(prefix="cc-sdk-", dir="/tmp") as root:
+ directory = Path(root)
+ service = Service(directory, factory=FakeClient)
+ path = directory / "service.sock"
+ server = await asyncio.start_unix_server(service.connection, path)
+ clients = []
+
+ async def attach(*, profile="primary", permission=None, mcp_server=None,
+ session_id="native-session"):
+ options = ClaudeAgentOptions(can_use_tool=permission, mcp_servers=(
+ {"ask": {"type": "sdk", "name": "ask", "instance": mcp_server}}
+ if mcp_server is not None else {}))
+ client = RemoteClient(str(path), options=options, metadata={
+ "profile_root": profile, "session_id": session_id, "space": "code",
+ "applied_auto_compact": ["inherit", None], "applied_effort": "max",
+ })
+ clients.append(client)
+ await client.connect()
+ client.ready.set()
+ return client
+
+ try:
+ yield service, attach
+ finally:
+ for client in clients:
+ await client.detach()
+ server.close()
+ await server.wait_closed()
+ for session in service.sessions.values():
+ await session.close()
+
+
+async def released(session):
+ async with asyncio.timeout(2):
+ while session.controller is not None:
+ await asyncio.sleep(0.001)
+
+
+def test_materialized_image_prompt_reaches_real_sdk_transport(tmp_path):
+ import json
+ from types import SimpleNamespace
+ from claude_agent_sdk import ClaudeSDKClient
+ from cc_remote.claude_service.server import Session
+
+ async def run():
+ writes = []
+
+ class Transport:
+ async def write(self, payload):
+ writes.append(json.loads(payload))
+
+ session = Session(tmp_path, {})
+ client = ClaudeSDKClient()
+ client._query = SimpleNamespace()
+ client._transport = Transport()
+ session.client = client
+ message = {"type": "user", "message": {"role": "user", "content": [
+ {"type": "text", "text": "inspect attachment"},
+ {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AA=="}},
+ ]}}
+ try:
+ await session.mutate("request", "query", {"turn": {"id": "image"}, "prompt": [message]})
+ assert len(writes) == 1
+ assert writes[0]["message"] == message["message"]
+ assert session.turn["id"] == "image"
+ session.terminal_seq = session.journal.append({"type": "result"})
+ await session.mutate("commit", "commit", {"turn_id": "image", "seq": session.terminal_seq})
+ await session.mutate("next", "query", {"turn": {"id": "text"}, "prompt": "next"})
+ assert len(writes) == 2
+ finally:
+ session.journal.close()
+
+ asyncio.run(run())
+
+
+def test_invalid_prompt_does_not_claim_turn_but_unknown_delivery_does(tmp_path):
+ from types import SimpleNamespace
+ from cc_remote.claude_service.server import Session
+
+ async def run():
+ async def uncertain_delivery(prompt):
+ raise ConnectionError("write may have reached native CLI")
+
+ session = Session(tmp_path, {})
+ session.client = SimpleNamespace(query=uncertain_delivery)
+ try:
+ with pytest.raises(ValueError):
+ await session.mutate("bad", "query", {"turn": {"id": "bad"}, "prompt": [42]})
+ assert session.turn is None
+ assert not session.submitted_turns
+ with pytest.raises(ConnectionError):
+ await session.mutate("unknown", "query", {"turn": {"id": "unknown"}, "prompt": "hi"})
+ assert session.turn["id"] == "unknown"
+ with pytest.raises(RuntimeError):
+ await session.mutate("new", "query", {"turn": {"id": "new"}, "prompt": "next"})
+ finally:
+ session.journal.close()
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("control_error", [None, "unsupported", "timeout"])
+def test_attached_summary_opt_in_keeps_running_turn_and_native_child(
+ monkeypatch, control_error,
+):
+ from cc_remote.config import WrapperConfig
+ from cc_remote.protocol import Delta, TurnEnd
+ from cc_remote.wrapper.sdk import SdkHandle
+ from tests.test_claude_autocompact import SESSION_ID, _machine_with_sdk
+
+ calls = []
+
+ async def control(client, request, timeout):
+ calls.append((request, timeout))
+ if control_error == "unsupported":
+ raise RuntimeError("unknown control subtype")
+ if control_error == "timeout":
+ raise TimeoutError("control request timeout")
+ return {}
+
+ monkeypatch.setattr(FakeClient, "_send_control_request", control, raising=False)
+
+ async def go():
+ async with environment() as (service, attach):
+ first = await attach(session_id=SESSION_ID)
+ first.next_turn = {"id": "browser-msg", "prompt": "hello"}
+ await first.query("hello")
+ worker = service.sessions[first.id]
+ native = worker.client
+ original_controls = worker.controls.copy()
+ await first.detach()
+ await released(worker)
+
+ handle = SdkHandle(WrapperConfig(claude_service_socket=first.connection.socket_path))
+ handle.service_metadata = worker.metadata.copy()
+ handle.service_defer_events = True
+ try:
+ await handle.connect(resume_id=SESSION_ID, cwd="/tmp")
+ assert handle.client.description["attached"] is True
+ assert calls == [({
+ "subtype": "set_max_thinking_tokens",
+ "thinking_display": "summarized",
+ }, 2.0)]
+ assert worker.client is native
+ assert native.closed is False and native.interrupts == 0
+ assert native.prompts == ["hello"]
+ assert worker.controls == original_controls
+ assert handle.service_recovery["id"] == "browser-msg"
+
+ # A display rejection/timeout must still let the original
+ # stream and terminal cross the service and Wrapper boundary.
+ # This injected delta verifies transport, not native timing:
+ # an active CLI loop may defer summaries to the next query.
+ channel = "text" if control_error else "thinking"
+ await native.queue.put({
+ "type": "stream_event", "uuid": "summary-1", "session_id": SESSION_ID,
+ "event": {"type": "content_block_delta", "index": 0, "delta": {
+ "type": f"{channel}_delta", channel: "Inspecting the build configuration.",
+ }},
+ })
+ await native.queue.put({
+ "type": "result", "subtype": "success", "duration_ms": 20,
+ "duration_api_ms": 19, "is_error": False, "num_turns": 1,
+ "session_id": SESSION_ID,
+ })
+ machine, transport, ctx = _machine_with_sdk(handle)
+ ctx.active_msg_id = "browser-msg"
+ ctx.state = "running"
+ handle.start_service_events()
+ await asyncio.wait_for(machine._run_turn(ctx, "hello", _recover_service=True), 3)
+ deltas = [item for item in transport.sent if isinstance(item, Delta)]
+ assert any(item.text == "Inspecting the build configuration." for item in deltas)
+ if not control_error:
+ assert any(item.channel == "thinking" for item in deltas)
+ assert len([item for item in transport.sent if isinstance(item, TurnEnd)]) == 1
+ assert native.prompts == ["hello"]
+ assert native.interrupts == 0 and native.closed is False
+ assert worker.turn is None
+ finally:
+ await handle.detach_for_shutdown()
+
+ asyncio.run(go())
+
+
+def test_running_query_and_offline_terminal_survive_wrapper_disconnect():
+ async def go():
+ async with environment() as (service, attach):
+ first = await attach()
+ first.next_turn = {"id": "browser-msg", "prompt": "hello"}
+ await first.query("hello")
+ worker = service.sessions[first.id]
+ native = worker.client
+ await native.queue.put({"type": "assistant", "message": {"content": "part one"}})
+ stream = first.receive_messages()
+ assert (await anext(stream))["message"]["content"] == "part one"
+ await first.detach()
+ await released(worker)
+ assert not native.closed
+ assert native.interrupts == 0
+ await native.queue.put({"type": "assistant", "message": {"content": "part two"}})
+ await native.queue.put({"type": "result", "subtype": "success"})
+ second = await attach()
+ assert second.id == first.id
+ assert second.recovery["id"] == "browser-msg"
+ replay = second.receive_messages()
+ assert (await anext(replay))["message"]["content"] == "part one"
+ assert (await anext(replay))["message"]["content"] == "part two"
+ terminal = await anext(replay)
+ await second.call("commit", {"turn_id": "browser-msg", "seq": terminal["__cc_service_seq"]})
+ assert native.prompts == ["hello"]
+ assert worker.turn is None
+ await second.disconnect()
+ assert native.closed
+ asyncio.run(go())
+
+
+def test_mcp_tool_call_survives_controller_and_server_replacement():
+ from claude_agent_sdk._internal.sdk_mcp_bridge import SdkMcpBridge
+ from mcp import types
+ from mcp.server import Server
+
+ async def go():
+ asked = asyncio.Event()
+
+ def ask_server(answer=None):
+ server = Server("ask")
+
+ @server.call_tool()
+ async def call_tool(name, arguments):
+ asked.set()
+ if answer is None:
+ await asyncio.Event().wait()
+ return [types.TextContent(type="text", text=answer)]
+
+ return server
+
+ async with environment() as (service, attach):
+ first = await attach(mcp_server=ask_server())
+ worker = service.sessions[first.id]
+ native = SdkMcpBridge("ask", worker.client.options.mcp_servers["ask"]["instance"])
+ try:
+ initialize = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
+ "protocolVersion": "2024-11-05", "capabilities": {},
+ "clientInfo": {"name": "native-test", "version": "1"},
+ }}
+ await asyncio.wait_for(native.handle(initialize), 3)
+ await native.handle({"jsonrpc": "2.0", "method": "notifications/initialized"})
+ request = asyncio.create_task(native.handle({
+ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
+ "params": {"name": "ask_user", "arguments": {}},
+ }))
+ await asyncio.wait_for(asked.wait(), 3)
+ await first.detach()
+ await released(worker)
+ assert not request.done()
+ await attach(mcp_server=ask_server("answered after deployment"))
+ response = await asyncio.wait_for(request, 3)
+ assert response["result"]["content"][0]["text"] == "answered after deployment"
+ assert not worker.client.closed
+ finally:
+ await native.aclose()
+ asyncio.run(go())
+
+
+def test_pending_permission_keeps_its_future_until_replacement_answers():
+ async def go():
+ first_asked = asyncio.Event()
+
+ async def unavailable(*args):
+ first_asked.set()
+ await asyncio.Event().wait()
+
+ async def allow(name, arguments, context):
+ assert name == "Bash"
+ assert context.tool_use_id == "tool-1"
+ return PermissionResultAllow()
+
+ async with environment() as (service, attach):
+ first = await attach(permission=unavailable)
+ worker = service.sessions[first.id]
+ permission = asyncio.create_task(worker.client.options.can_use_tool(
+ "Bash", {"command": "true"}, ToolPermissionContext(tool_use_id="tool-1")))
+ await asyncio.wait_for(first_asked.wait(), 2)
+ keys = list(worker.callbacks)
+ await first.detach()
+ await released(worker)
+ assert not permission.done()
+ assert list(worker.callbacks) == keys
+ await attach(permission=allow)
+ result = await asyncio.wait_for(permission, 2)
+ assert isinstance(result, PermissionResultAllow)
+ assert not worker.client.closed
+ asyncio.run(go())
+
+
+def test_duplicate_query_request_and_competing_controller_do_not_write_twice():
+ async def go():
+ async with environment() as (service, attach):
+ first = await attach()
+ params = {"prompt": "one", "turn": {"id": "turn-1"}}
+ await first.call("query", params, request_id="stable-request")
+ await first.call("query", params, request_id="stable-request")
+ with pytest.raises(RuntimeError):
+ await first.call("query", {**params, "prompt": "changed"}, request_id="stable-request")
+ with pytest.raises(RuntimeError):
+ await attach()
+ assert service.sessions[first.id].client.prompts == ["one"]
+ other = await attach(profile="another-account")
+ assert other.id != first.id
+ assert len(service.sessions) == 2
+ asyncio.run(go())
+
+
+def test_answered_question_page_is_not_asked_again_after_restart():
+ from types import SimpleNamespace
+
+ from cc_remote.claude_service.client import callback_identity
+ from cc_remote.protocol import AnswerQuestion, AskUser
+ from tests.test_claude_autocompact import _machine_with_sdk
+
+ async def question(machine, ctx, text):
+ token = callback_identity.set("same-native-tool-call")
+ try:
+ return await machine._on_ask(ctx, text, [
+ {"label": "Yes"}, {"label": "No"},
+ ])
+ finally:
+ callback_identity.reset(token)
+
+ async def go():
+ async with environment() as (service, attach):
+ first = await attach()
+ machine, transport, ctx = _machine_with_sdk(SimpleNamespace(client=first, ask_server=None))
+ task = asyncio.create_task(question(machine, ctx, "First page?"))
+ async with asyncio.timeout(2):
+ while not any(isinstance(event, AskUser) for event in transport.sent):
+ await asyncio.sleep(0.001)
+ event = next(event for event in transport.sent if isinstance(event, AskUser))
+ result = await machine._handle_answer_question(AnswerQuestion(
+ sid=ctx.key, ask_id=event.ask_id, answer="Yes"))
+ assert result is None
+ assert await task == "Yes"
+ assert service.sessions[first.id].question_answers[event.ask_id] == "Yes"
+ await first.detach()
+ await released(service.sessions[first.id])
+ second = await attach()
+ machine2, transport2, ctx2 = _machine_with_sdk(SimpleNamespace(client=second, ask_server=None))
+ assert await asyncio.wait_for(question(machine2, ctx2, "First page?"), 2) == "Yes"
+ assert not any(isinstance(event, AskUser) for event in transport2.sent)
+ asyncio.run(go())
+
+
+def test_interrupt_requires_terminal_ack_before_next_query():
+ async def go():
+ async with environment() as (service, attach):
+ first = await attach()
+ first.next_turn = {"id": "one"}
+ await first.query("one")
+ await first.interrupt()
+ with pytest.raises(RuntimeError):
+ await first.call("query", {"prompt": "too early", "turn": {"id": "two"}})
+ terminal = await anext(first.receive_messages())
+ await first.call("commit", {"turn_id": "one", "seq": terminal["__cc_service_seq"]})
+ first.next_turn = {"id": "two"}
+ await first.query("two")
+ assert service.sessions[first.id].client.prompts == ["one", "two"]
+ asyncio.run(go())
+
+
+def test_machine_reconstructs_offline_completion_without_resubmitting_or_duplicating_text():
+ from cc_remote.config import WrapperConfig
+ from cc_remote.protocol import Delta, TurnEnd
+ from cc_remote.wrapper.sdk import SdkHandle
+ from tests.test_claude_autocompact import SESSION_ID, _machine_with_sdk
+
+ async def go():
+ async with environment() as (service, attach):
+ first = await attach()
+ first.next_turn = {"id": "browser-msg", "prompt": "hello"}
+ await first.query("hello")
+ worker = service.sessions[first.id]
+ await first.detach()
+ await released(worker)
+ native = worker.client
+ rows = [
+ {"type": "stream_event", "uuid": "e1", "session_id": SESSION_ID, "event": {
+ "type": "message_start", "message": {"id": "native-assistant"},
+ }},
+ {"type": "stream_event", "uuid": "e2", "session_id": SESSION_ID, "event": {
+ "type": "content_block_delta", "index": 0,
+ "delta": {"type": "text_delta", "text": "Hello "},
+ }},
+ {"type": "stream_event", "uuid": "e3", "session_id": SESSION_ID, "event": {
+ "type": "content_block_delta", "index": 0,
+ "delta": {"type": "text_delta", "text": "world"},
+ }},
+ {"type": "result", "subtype": "success", "duration_ms": 20,
+ "duration_api_ms": 19, "is_error": False, "num_turns": 1, "session_id": SESSION_ID},
+ ]
+ for row in rows:
+ await native.queue.put(row)
+ async with asyncio.timeout(2):
+ while worker.journal.seq < len(rows):
+ await asyncio.sleep(0.001)
+ worker.journal.db.execute("UPDATE events SET ts = 1700000000.0")
+ worker.journal.db.commit()
+ handle = SdkHandle(WrapperConfig(claude_service_socket=first.connection.socket_path))
+ handle.service_metadata = worker.metadata.copy()
+ handle.service_defer_events = True
+ await handle.connect(resume_id=SESSION_ID, cwd="/tmp")
+ machine, transport, ctx = _machine_with_sdk(handle)
+ ctx.active_msg_id = "browser-msg"
+ ctx.state = "running"
+ try:
+ handle.start_service_events()
+ await asyncio.wait_for(machine._run_turn(ctx, "hello", _recover_service=True), 3)
+ deltas = [item for item in transport.sent if isinstance(item, Delta)]
+ assert len(deltas) == 1
+ assert deltas[0].text == "Hello world"
+ assert deltas[0].replace
+ assert deltas[0].ts == 1700000000.0
+ assert len([item for item in transport.sent if isinstance(item, TurnEnd)]) == 1
+ assert native.prompts == ["hello"]
+ assert ctx.state == "idle"
+ assert worker.turn is None
+ finally:
+ await handle.detach_for_shutdown()
+ asyncio.run(go())
+
+
+@pytest.mark.parametrize("field", ["profile_root", "session_id", "cwd", "space", "work_id", "btw"])
+def test_explicit_worker_attach_checks_complete_identity(field):
+ async def go():
+ async with environment() as (service, attach):
+ first = await attach()
+ worker = service.sessions[first.id]
+ with pytest.raises(RuntimeError):
+ await first.connection.call("open", {
+ "session": first.id, "options": {},
+ "metadata": {**worker.metadata, field: "another-value"},
+ })
+ assert len(service.sessions) == 1
+ assert worker.client.prompts == []
+ asyncio.run(go())
+
+
+def test_overlapping_buffered_background_turns_keep_each_unacknowledged_prefix():
+ async def go():
+ async with environment() as (service, attach):
+ first = await attach()
+ worker = service.sessions[first.id]
+ rows = [
+ {"type": "user", "origin": {"kind": "task-notification"}, "uuid": "task-a"},
+ {"type": "assistant", "message": {"content": "first task"}},
+ {"type": "result", "origin": {"kind": "task-notification"}},
+ {"type": "user", "origin": {"kind": "task-notification"}, "uuid": "task-b"},
+ {"type": "assistant", "message": {"content": "second task"}},
+ {"type": "result", "origin": {"kind": "task-notification"}},
+ ]
+ for row in rows:
+ await worker.client.queue.put(row)
+ async with asyncio.timeout(2):
+ while worker.journal.seq < len(rows):
+ await asyncio.sleep(0.001)
+ await first.call("ack", {"seq": 2})
+ assert worker.description()["after"] == 0
+ assert len(worker.journal.after(0)) == 6
+ await first.call("ack", {"seq": 3})
+ assert worker.description()["after"] == 3
+ await first.call("ack", {"seq": 5})
+ assert worker.description()["after"] == 3
+ await first.detach()
+ await released(worker)
+ second = await attach()
+ stream = second.receive_messages()
+ assert (await anext(stream))["uuid"] == "task-b"
+ assert (await anext(stream))["message"]["content"] == "second task"
+ terminal = await anext(stream)
+ await second.call("ack", {"seq": terminal["__cc_service_seq"]})
+ assert worker.background_start is None
+ second.next_turn = {"id": "next-human"}
+ await second.query("continue")
+ assert worker.client.prompts == ["continue"]
+ asyncio.run(go())
+
+
+def test_machine_recovers_background_followup_without_a_new_human_completion():
+ from cc_remote.config import WrapperConfig
+ from cc_remote.protocol import Delta, TurnEnd
+ from cc_remote.wrapper import claude_service
+ from cc_remote.wrapper.sdk import SdkHandle
+ from tests.test_claude_autocompact import SESSION_ID, _machine_with_sdk
+
+ async def go():
+ async with environment() as (service, attach):
+ first = await attach()
+ first.next_turn = {"id": "origin-human", "prompt": "hello"}
+ await first.query("hello")
+ worker = service.sessions[first.id]
+ native = worker.client
+ result = {"type": "result", "subtype": "success", "duration_ms": 20,
+ "duration_api_ms": 19, "is_error": False, "num_turns": 1,
+ "session_id": SESSION_ID}
+ await native.queue.put({
+ "type": "system", "subtype": "task_started", "task_id": "task-1",
+ "description": "Background review", "uuid": "start", "session_id": SESSION_ID,
+ "tool_use_id": "agent-tool", "task_type": "agent",
+ })
+ await native.queue.put(result)
+ stream = first.receive_messages()
+ await anext(stream)
+ terminal = await anext(stream)
+ await first.call("commit", {"turn_id": "origin-human", "seq": terminal["__cc_service_seq"]})
+ await first.detach()
+ await released(worker)
+ rows = [
+ {"type": "system", "subtype": "task_notification", "task_id": "task-1",
+ "status": "completed", "output_file": "/tmp/test-output", "summary": "done",
+ "uuid": "notification", "session_id": SESSION_ID, "tool_use_id": "agent-tool"},
+ {"type": "user", "message": {"role": "user", "content": "done"},
+ "parent_tool_use_id": None, "uuid": "autonomous-user-1",
+ "origin": {"kind": "task-notification"}},
+ {"type": "assistant", "message": {"id": "followup", "role": "assistant",
+ "model": "test", "content": [{"type": "text", "text": "Background findings"}]},
+ "parent_tool_use_id": None},
+ {**result, "origin": {"kind": "task-notification"}},
+ ]
+ for row in rows:
+ await native.queue.put(row)
+ async with asyncio.timeout(2):
+ while worker.journal.seq < 6:
+ await asyncio.sleep(0.001)
+ handle = SdkHandle(WrapperConfig(claude_service_socket=first.connection.socket_path))
+ handle.service_metadata = worker.metadata.copy()
+ handle.service_defer_events = True
+ await handle.connect(resume_id=SESSION_ID, cwd="/tmp")
+ machine, transport, ctx = _machine_with_sdk(handle)
+ ctx.state = "idle"
+ handle.background_message_callback = lambda message, turn: machine._on_claude_background_message(ctx, message, turn)
+ try:
+ await claude_service.activate(machine, ctx)
+ async with asyncio.timeout(3):
+ while worker.ack < 6:
+ await asyncio.sleep(0.001)
+ deltas = [item for item in transport.sent if isinstance(item, Delta)]
+ assert len(deltas) == 1
+ assert deltas[0].text == "Background findings"
+ assert deltas[0].background
+ assert deltas[0].turn_id == "origin-human"
+ assert deltas[0].replace
+ assert not any(isinstance(item, TurnEnd) for item in transport.sent)
+ assert native.prompts == ["hello"]
+ assert ctx.state == "idle"
+ finally:
+ await handle.detach_for_shutdown()
+ await released(worker)
+ # A later deployment replays only the completed task seed. It must
+ # not reserve another autonomous Result which will never arrive.
+ # Older persistent services retain these acknowledged seeds even
+ # after a newer controller has committed the autonomous turn.
+ worker.task_seeds["task-1"] = {
+ "data": rows[0], "origin_id": "origin-human", "seq": 3,
+ }
+ restored = SdkHandle(WrapperConfig(claude_service_socket=first.connection.socket_path))
+ restored.service_metadata = worker.metadata.copy()
+ restored.service_defer_events = True
+ await restored.connect(resume_id=SESSION_ID, cwd="/tmp")
+ machine2, _transport2, ctx2 = _machine_with_sdk(restored)
+ observed_seed = asyncio.Event()
+
+ async def background(message, turn):
+ await machine2._on_claude_background_message(ctx2, message, turn)
+ if getattr(message, "_cc_service_seed", False):
+ observed_seed.set()
+
+ restored.background_message_callback = background
+ try:
+ await claude_service.activate(machine2, ctx2)
+ await asyncio.wait_for(observed_seed.wait(), timeout=2)
+ assert ctx2.claude_background_followups == {}
+ assert ctx2.state == "idle"
+ assert native.prompts == ["hello"]
+ finally:
+ await restored.detach_for_shutdown()
+ asyncio.run(go())
+
+
+def test_real_controller_process_exit_leaves_service_and_accepted_work_alive():
+ from cc_remote.claude_service.client import Connection
+
+ service_code = """
+import asyncio, sys
+from pathlib import Path
+from cc_remote.claude_service.server import Service
+from tests.test_claude_service import FakeClient
+class RunningClient(FakeClient):
+ async def query(self, prompt):
+ await super().query(prompt)
+ async def finish():
+ await asyncio.sleep(0.2)
+ await self.queue.put({'type': 'result', 'subtype': 'success', 'result': 'finished'})
+ asyncio.create_task(finish())
+async def run():
+ service = Service(Path(sys.argv[1]), factory=RunningClient)
+ server = await asyncio.start_unix_server(service.connection, sys.argv[2])
+ print('ready', flush=True)
+ async with server:
+ await server.serve_forever()
+asyncio.run(run())
+"""
+ controller_code = """
+import asyncio, sys
+from claude_agent_sdk import ClaudeAgentOptions
+from cc_remote.claude_service.client import RemoteClient
+async def run():
+ client = RemoteClient(sys.argv[1], options=ClaudeAgentOptions(), metadata={
+ 'profile_root': 'test', 'session_id': 'test-session', 'space': 'code'})
+ await client.connect()
+ client.next_turn = {'id': 'only-query', 'prompt': 'one'}
+ await client.query('one')
+ print('accepted', flush=True)
+ await asyncio.Event().wait()
+asyncio.run(run())
+"""
+
+ async def go():
+ with tempfile.TemporaryDirectory(prefix="cc-sdk-process-", dir="/tmp") as root:
+ path = str(Path(root) / "service.sock")
+ service = await asyncio.create_subprocess_exec(
+ sys.executable, "-c", service_code, root, path, stdout=asyncio.subprocess.PIPE)
+ controller = None
+ connection = Connection(path)
+ try:
+ assert await asyncio.wait_for(service.stdout.readline(), 3) == b"ready\n"
+ controller = await asyncio.create_subprocess_exec(
+ sys.executable, "-c", controller_code, path, stdout=asyncio.subprocess.PIPE)
+ assert await asyncio.wait_for(controller.stdout.readline(), 3) == b"accepted\n"
+ controller.kill() # Only the process created by this test.
+ await controller.wait()
+ await connection.connect()
+ descriptions = await connection.call("list")
+ assert len(descriptions) == 1
+ description = descriptions[0]
+ assert description["pid"] == service.pid
+ assert service.returncode is None
+ assert description["turn"]["id"] == "only-query"
+ await connection.call("open", {
+ "session": description["id"], "metadata": description["metadata"], "options": {},
+ })
+ events = await connection.call("events", {"session": description["id"], "after": 0})
+ assert events["events"][-1]["data"]["result"] == "finished"
+ finally:
+ await connection.disconnect()
+ if controller is not None and controller.returncode is None:
+ controller.kill()
+ await controller.wait()
+ if service.returncode is None:
+ service.terminate()
+ await service.wait()
+ asyncio.run(go())
diff --git a/tests/test_claude_service_callbacks.py b/tests/test_claude_service_callbacks.py
new file mode 100644
index 00000000..22c2a268
--- /dev/null
+++ b/tests/test_claude_service_callbacks.py
@@ -0,0 +1,179 @@
+"""Callback recovery over the real private socket, without a live model."""
+
+import asyncio
+
+import pytest
+from claude_agent_sdk import PermissionResultAllow, ToolPermissionContext
+
+from cc_remote.claude_service.client import callback_identity
+from cc_remote.claude_service.wire import decode_sdk, encode_sdk
+from tests.test_claude_service import environment, released
+
+
+def callback_payload(kind):
+ if kind == "permission":
+ return {"name": "Bash", "input": {"command": "true"}, "context": encode_sdk(
+ ToolPermissionContext(tool_use_id="tool-1"))}
+ message = {"jsonrpc": "2.0", "method": "notifications/initialized"}
+ if kind == "mcp":
+ message = {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
+ return {"name": "ask", "message": message}
+
+
+def callback_answer(kind):
+ if kind == "permission":
+ return PermissionResultAllow()
+ if kind == "mcp":
+ return {"jsonrpc": "2.0", "id": 2, "result": {"tools": []}}
+ return None
+
+
+@pytest.mark.parametrize("kind", ["permission", "mcp"])
+def test_callback_handler_failure_retries_without_reconnecting(kind, monkeypatch):
+ async def go():
+ identities = []
+
+ async def flaky(*args):
+ identities.append(callback_identity.get())
+ if len(identities) == 1:
+ raise RuntimeError("temporary callback failure")
+ return callback_answer(kind)
+
+ async with environment() as (service, attach):
+ client = await attach(permission=flaky)
+ monkeypatch.setattr(client, "_mcp", flaky)
+ worker = service.sessions[client.id]
+ controller = worker.controller
+ result = await asyncio.wait_for(worker.callback(kind, callback_payload(kind)), 2)
+ assert decode_sdk(result) == callback_answer(kind)
+ assert len(identities) == 2
+ assert identities[0] and identities[0] == identities[1]
+ assert worker.controller is controller
+ assert not client.connection.task.done()
+ assert not worker.client.closed
+ assert not worker.callbacks
+ async with asyncio.timeout(2):
+ while client.callback_tasks:
+ await asyncio.sleep(0.001)
+
+ asyncio.run(go())
+
+
+@pytest.mark.parametrize("kind", ["permission", "mcp", "mcp_notification"])
+def test_callback_answer_timeout_resends_same_result_without_rerunning_handler(kind, monkeypatch):
+ async def go():
+ executions = []
+ answers = []
+ release_first_answer = asyncio.Event()
+
+ async def handler(*args):
+ executions.append(callback_identity.get())
+ return callback_answer(kind)
+
+ async with environment() as (service, attach):
+ client = await attach(permission=handler)
+ monkeypatch.setattr(client, "_mcp", handler)
+ worker = service.sessions[client.id]
+ original_call = client.connection.call
+ original_dispatch = service.dispatch
+
+ async def short_answer_timeout(method, params=None, **kwargs):
+ if method == "answer":
+ kwargs["timeout"] = 0.05
+ return await original_call(method, params, **kwargs)
+
+ async def delay_first_answer(owner, request_id, method, params):
+ if method == "answer":
+ answers.append((request_id, params))
+ if len(answers) == 1:
+ await release_first_answer.wait()
+ return await original_dispatch(owner, request_id, method, params)
+
+ monkeypatch.setattr(client.connection, "call", short_answer_timeout)
+ monkeypatch.setattr(service, "dispatch", delay_first_answer)
+ try:
+ result = await asyncio.wait_for(worker.callback(
+ "permission" if kind == "permission" else "mcp", callback_payload(kind)), 2)
+ assert decode_sdk(result) == callback_answer(kind)
+ assert len(executions) == 1
+ assert len(answers) == 2
+ assert answers[0] == answers[1]
+ assert answers[0][0] == "answer-" + executions[0]
+ assert not client.connection.task.done()
+ assert not worker.callbacks
+ finally:
+ release_first_answer.set()
+
+ asyncio.run(go())
+
+
+def test_detaching_during_callback_retry_preserves_pending_native_request():
+ async def go():
+ attempted = asyncio.Event()
+ attempts = 0
+
+ async def unavailable(*args):
+ nonlocal attempts
+ attempts += 1
+ attempted.set()
+ raise RuntimeError("temporary permission handler failure")
+
+ async def allow(*args):
+ return PermissionResultAllow()
+
+ async with environment() as (service, attach):
+ first = await attach(permission=unavailable)
+ worker = service.sessions[first.id]
+ permission = asyncio.create_task(worker.client.options.can_use_tool(
+ "Bash", {"command": "true"}, ToolPermissionContext(tool_use_id="tool-1")))
+ try:
+ await asyncio.wait_for(attempted.wait(), 2)
+ keys = list(worker.callbacks)
+ await first.detach()
+ await released(worker)
+ assert not permission.done()
+ assert list(worker.callbacks) == keys
+ assert all(task.done() for task in first.callback_tasks.values())
+ await attach(permission=allow)
+ assert isinstance(await asyncio.wait_for(permission, 2), PermissionResultAllow)
+ assert attempts == 1
+ assert not worker.client.closed
+ finally:
+ permission.cancel()
+ await asyncio.gather(permission, return_exceptions=True)
+
+ asyncio.run(go())
+
+
+def test_callback_retry_does_not_block_other_callbacks_and_stops_when_closed():
+ async def go():
+ attempted = asyncio.Event()
+
+ async def permission(name, arguments, context):
+ if arguments["command"] == "retry":
+ attempted.set()
+ raise RuntimeError("temporary permission handler failure")
+ return PermissionResultAllow()
+
+ async with environment() as (service, attach):
+ client = await attach(permission=permission)
+ worker = service.sessions[client.id]
+ native = asyncio.create_task(worker.client.options.can_use_tool(
+ "Bash", {"command": "retry"}, ToolPermissionContext(tool_use_id="tool-1")))
+ try:
+ await asyncio.wait_for(attempted.wait(), 2)
+ retrying = next(iter(client.callback_tasks.values()))
+ result = await asyncio.wait_for(worker.client.options.can_use_tool(
+ "Bash", {"command": "true"}, ToolPermissionContext(tool_use_id="tool-2")), 2)
+ assert isinstance(result, PermissionResultAllow)
+ assert not native.done()
+ native.cancel()
+ await asyncio.gather(native, return_exceptions=True)
+ await asyncio.wait_for(asyncio.gather(retrying, return_exceptions=True), 2)
+ assert retrying.cancelled()
+ assert not worker.callbacks
+ finally:
+ native.cancel()
+ await asyncio.gather(native, return_exceptions=True)
+
+ asyncio.run(go())
diff --git a/tests/test_claude_service_delivery.py b/tests/test_claude_service_delivery.py
new file mode 100644
index 00000000..20d778b8
--- /dev/null
+++ b/tests/test_claude_service_delivery.py
@@ -0,0 +1,338 @@
+"""Journal acknowledgements must not overtake a failed controller projection."""
+
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+from claude_agent_sdk import ResultMessage
+
+from cc_remote.config import WrapperConfig
+from cc_remote.protocol import Error
+from cc_remote.wrapper.sdk import ClaudeServiceReplayRequired, SdkHandle
+from tests.test_claude_autocompact import _machine_with_sdk
+from tests.test_claude_service import environment, released
+
+
+def notification(number):
+ return {
+ "type": "system", "subtype": "task_notification", "task_id": f"task-{number}",
+ "status": "completed", "output_file": "/tmp/output", "summary": "done",
+ "uuid": f"notification-{number}", "session_id": "native-session",
+ }
+
+
+def result():
+ return {
+ "type": "result", "subtype": "success", "duration_ms": 20,
+ "duration_api_ms": 19, "is_error": False, "num_turns": 1,
+ "session_id": "native-session", "origin": {"kind": "human"},
+ }
+
+
+async def wait_until(predicate):
+ async with asyncio.timeout(2):
+ while not predicate():
+ await asyncio.sleep(0.001)
+
+
+def start_handle(client, callback):
+ handle = SdkHandle(WrapperConfig())
+ handle.client = client
+ handle.background_message_callback = callback
+ handle._start_message_pump()
+ return handle
+
+
+@pytest.mark.parametrize("failure", ["projection", "ack", "ack_response"])
+def test_background_delivery_failure_retains_later_rows_for_reattachment(failure, monkeypatch):
+ async def go():
+ async with environment() as (service, attach):
+ client = await attach()
+ worker = service.sessions[client.id]
+ delivered = []
+
+ async def project(message, _turn):
+ delivered.append(message._cc_service_seq)
+ if failure == "projection" and message._cc_service_seq == 1:
+ raise ValueError("temporary projection failure")
+
+ original_call = client.call
+
+ async def flaky_ack(method, params=None, **kwargs):
+ if failure == "ack" and method == "ack" and params["seq"] == 1:
+ raise ConnectionError("temporary ACK failure")
+ value = await original_call(method, params, **kwargs)
+ if failure == "ack_response" and method == "ack" and params["seq"] == 1:
+ raise ConnectionError("accepted ACK response lost")
+ return value
+
+ monkeypatch.setattr(client, "call", flaky_ack)
+ handle = start_handle(client, project)
+ try:
+ for number in range(1, 4):
+ await worker.client.queue.put(notification(number))
+ await wait_until(lambda: client.last_seq == 3 and handle._background_callbacks_pending == 0)
+ acknowledged = 1 if failure == "ack_response" else 0
+ assert worker.ack == acknowledged
+ expected = list(range(acknowledged + 1, 4))
+ assert [row["seq"] for row in worker.journal.after(0)] == expected
+ assert delivered == [1]
+ assert not handle.message_pump_failed
+ assert not worker.client.closed
+ assert worker.client.interrupts == 0
+ assert worker.client.prompts == []
+ finally:
+ await handle.detach_for_shutdown()
+ await released(worker)
+
+ recovered = []
+
+ async def project_replay(message, _turn):
+ if (getattr(message, "subtype", None) == "task_notification"
+ and not getattr(message, "_cc_service_seed", False)):
+ recovered.append(message._cc_service_seq)
+
+ handle.client = await attach()
+ handle.background_message_callback = project_replay
+ handle._start_message_pump()
+ try:
+ await wait_until(lambda: worker.ack == 3)
+ assert recovered == expected
+ handle.next_turn_id = "new-human"
+ await handle.query("next")
+ assert worker.client.prompts == ["next"]
+ finally:
+ await handle.detach_for_shutdown()
+
+ asyncio.run(go())
+
+
+def test_failed_background_terminal_retains_its_full_prefix():
+ async def go():
+ async with environment() as (service, attach):
+ client = await attach()
+ worker = service.sessions[client.id]
+
+ async def project(message, _turn):
+ if isinstance(message, ResultMessage):
+ raise ValueError("terminal projection failed")
+
+ handle = start_handle(client, project)
+ rows = [
+ {"type": "user", "message": {"role": "user", "content": "task done"},
+ "uuid": "background-user", "origin": {"kind": "task-notification"}},
+ {"type": "assistant", "message": {"id": "background", "model": "test",
+ "content": [{"type": "text", "text": "findings"}]}},
+ {**result(), "origin": {"kind": "task-notification"}},
+ notification(2),
+ ]
+ try:
+ for row in rows:
+ await worker.client.queue.put(row)
+ await wait_until(lambda: client.last_seq == 4 and handle._background_callbacks_pending == 0)
+ assert worker.ack == 2
+ assert worker.description()["after"] == 0
+ assert [row["seq"] for row in worker.journal.after(0)] == [1, 2, 3, 4]
+ with pytest.raises(ClaudeServiceReplayRequired):
+ await handle.steer("continue", native_id="steer", metadata={})
+ assert worker.client.prompts == []
+ finally:
+ await handle.detach_for_shutdown()
+
+ asyncio.run(go())
+
+
+def test_waiting_query_rechecks_failed_delivery_without_hanging():
+ async def go():
+ async with environment() as (service, attach):
+ client = await attach()
+ worker = service.sessions[client.id]
+ started, release = asyncio.Event(), asyncio.Event()
+
+ async def project(_message, _turn):
+ started.set()
+ await release.wait()
+ raise ValueError("projection failed")
+
+ handle = start_handle(client, project)
+ pending = None
+ try:
+ await worker.client.queue.put(notification(1))
+ await asyncio.wait_for(started.wait(), 2)
+ handle.next_turn_id = "must-not-run"
+ pending = asyncio.create_task(handle.query("must not run"))
+ await asyncio.sleep(0)
+ assert not pending.done()
+ release.set()
+ with pytest.raises(ClaudeServiceReplayRequired):
+ await asyncio.wait_for(pending, 2)
+ assert worker.client.prompts == []
+ assert worker.ack == 0
+ finally:
+ release.set()
+ if pending is not None and not pending.done():
+ pending.cancel()
+ await asyncio.gather(pending, return_exceptions=True)
+ await handle.detach_for_shutdown()
+
+ asyncio.run(go())
+
+
+def test_in_process_background_callback_failure_keeps_existing_tolerance():
+ async def go():
+ queue, delivered = asyncio.Queue(), []
+
+ async def receive_messages():
+ while True:
+ yield await queue.get()
+
+ async def project(message, _turn):
+ delivered.append(message.task_id)
+ if len(delivered) == 1:
+ raise ValueError("malformed notification")
+
+ handle = start_handle(SimpleNamespace(_query=SimpleNamespace(
+ receive_messages=receive_messages)), project)
+ try:
+ await queue.put(notification(1))
+ await queue.put(notification(2))
+ await wait_until(lambda: len(delivered) == 2 and handle._background_callbacks_pending == 0)
+ handle.check_service_delivery()
+ assert delivered == ["task-1", "task-2"]
+ assert not handle.message_pump_failed
+ finally:
+ await handle._stop_message_pump()
+
+ asyncio.run(go())
+
+
+def test_machine_reports_replay_requirement_without_restarting_native_work():
+ async def go():
+ async with environment() as (service, attach):
+ client = await attach()
+ worker = service.sessions[client.id]
+
+ async def project(_message, _turn):
+ raise ValueError("projection failed")
+
+ handle = start_handle(client, project)
+ machine, transport, ctx = _machine_with_sdk(handle)
+ ctx.state = "running"
+ ctx.claude_background_followup_pending = True
+ handle.message_pump_failure_callback = lambda error: machine._on_claude_message_pump_failure(ctx, error)
+ try:
+ await worker.client.queue.put(notification(1))
+ await wait_until(lambda: client.last_seq == 1 and handle._background_callbacks_pending == 0)
+ errors = [item for item in transport.sent if isinstance(item, Error)]
+ assert len(errors) == 1 and "Wrapper" in errors[0].message
+ assert not ctx.claude_background_followup_pending
+ assert ctx.state == "idle"
+ # A later query must not convert a UI failure into a native
+ # reconnect, even if transcript growth suggests a reload.
+ ctx.needs_reload = True
+ ctx.active_msg_id = "new-query"
+ await machine._run_turn(ctx, "must not run")
+ assert worker.client.prompts == []
+ assert not worker.client.closed
+ assert not handle.message_pump_failed
+ assert worker.journal.after(0)
+ finally:
+ await handle.detach_for_shutdown()
+
+ asyncio.run(go())
+
+
+@pytest.mark.parametrize("fails", [False, True])
+def test_human_commit_waits_for_an_earlier_background_projection(fails):
+ async def go():
+ async with environment() as (service, attach):
+ client = await attach()
+ worker = service.sessions[client.id]
+ started, release = asyncio.Event(), asyncio.Event()
+
+ async def project(_message, _turn):
+ started.set()
+ await release.wait()
+ if fails:
+ raise ValueError("temporary projection failure")
+
+ handle = start_handle(client, project)
+ try:
+ handle.next_turn_id = "human"
+ await handle.query("hello")
+ await worker.client.queue.put(notification(1))
+ await asyncio.wait_for(started.wait(), 2)
+ await worker.client.queue.put(result())
+ stream = handle.receive_response()
+ terminal = await asyncio.wait_for(anext(stream), 2)
+ assert isinstance(terminal, ResultMessage)
+ await stream.aclose()
+ # The managed Result may release its barrier, but must not
+ # prune the earlier callback which is still executing.
+ await asyncio.wait_for(handle.ack_service_message(terminal, turn_id="human"), 2)
+ assert worker.turn["id"] == "human"
+ assert worker.ack == 0
+ handle.release_background_messages()
+ release.set()
+ await asyncio.wait_for(handle._background_callbacks_drained.wait(), 2)
+ if fails:
+ assert worker.ack == 0
+ assert [row["seq"] for row in worker.journal.after(0)] == [1, 2]
+ handle.next_turn_id = "must-not-run"
+ with pytest.raises(RuntimeError, match="Wrapper"):
+ await asyncio.wait_for(handle.query("must not run"), 2)
+ with pytest.raises(RuntimeError, match="Wrapper"):
+ await handle.ack_service_message(terminal, turn_id="human")
+ assert worker.client.prompts == ["hello"]
+ else:
+ assert worker.turn is None
+ assert worker.ack == 2
+ handle.next_turn_id = "next-human"
+ await handle.query("next")
+ assert worker.client.prompts == ["hello", "next"]
+ assert not handle.message_pump_failed
+ assert not worker.client.closed
+ finally:
+ release.set()
+ await handle.detach_for_shutdown()
+
+ asyncio.run(go())
+
+
+def test_deferred_commit_does_not_deadlock_on_the_managed_result_barrier():
+ async def go():
+ async with environment() as (service, attach):
+ client = await attach()
+ worker = service.sessions[client.id]
+ projected = []
+
+ async def project(message, _turn):
+ projected.append(message._cc_service_seq)
+
+ handle = start_handle(client, project)
+ try:
+ handle.next_turn_id = "human"
+ await handle.query("hello")
+ for row in [
+ {"type": "user", "message": {"role": "user", "content": "hello"},
+ "uuid": "human", "origin": {"kind": "human"}},
+ result(), notification(1),
+ ]:
+ await worker.client.queue.put(row)
+ messages = [message async for message in handle.receive_response()]
+ await wait_until(lambda: client.last_seq == 3 and handle._background_callbacks_pending == 1)
+ await asyncio.wait_for(handle.ack_service_message(messages[-1], turn_id="human"), 2)
+ assert projected == []
+ assert worker.turn["id"] == "human"
+ handle.release_background_messages()
+ await asyncio.wait_for(handle._background_callbacks_drained.wait(), 2)
+ assert projected == [3]
+ assert worker.turn is None
+ assert worker.ack == 3
+ handle.next_turn_id = "next-human"
+ await handle.query("next")
+ assert worker.client.prompts == ["hello", "next"]
+ finally:
+ await handle.detach_for_shutdown()
+
+ asyncio.run(go())
diff --git a/tests/test_claude_service_install.py b/tests/test_claude_service_install.py
new file mode 100644
index 00000000..b6c8ec23
--- /dev/null
+++ b/tests/test_claude_service_install.py
@@ -0,0 +1,23 @@
+"""Validate service syntax with the system manager that consumes it."""
+
+import shutil
+import subprocess
+import sys
+
+import pytest
+
+from deploy.install_claude_service import unit_text
+
+
+@pytest.mark.skipif(sys.platform != "linux" or not shutil.which("systemd-analyze"),
+ reason="systemd unit verification requires Linux")
+def test_service_unit_with_spaces_is_accepted_by_systemd(tmp_path):
+ source = tmp_path / "release with spaces"
+ source.mkdir()
+ state = tmp_path / "state with spaces"
+ state.mkdir()
+ unit = tmp_path / "claude-test.service"
+ unit.write_text(unit_text(source, state))
+ result = subprocess.run(["systemd-analyze", "verify", str(unit)],
+ capture_output=True, text=True, timeout=15)
+ assert result.returncode == 0, result.stderr
diff --git a/tests/test_claude_service_lease.py b/tests/test_claude_service_lease.py
new file mode 100644
index 00000000..6837afd4
--- /dev/null
+++ b/tests/test_claude_service_lease.py
@@ -0,0 +1,312 @@
+"""Recover through a closing controller lease without taking over live work."""
+
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+from claude_agent_sdk import PermissionResultAllow, ToolPermissionContext
+
+from cc_remote.claude_service import client as client_module
+from cc_remote.claude_service.client import Connection, RemoteClient, options_payload
+from cc_remote.wrapper import claude_service
+from cc_remote.wrapper.sdk import SdkHandle
+from tests.test_claude_service import environment, released
+from tests.test_claude_service_recovery import recovery_machine
+
+
+def replacement(first, worker, **metadata):
+ return RemoteClient(first.connection.socket_path, options=first.options,
+ metadata={**worker.metadata, "service_id": worker.id, **metadata})
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("legacy_service", [False, True])
+async def test_restore_retries_until_old_connection_finishes_cleanup(monkeypatch, legacy_service):
+ async def allow(*args):
+ return PermissionResultAllow()
+
+ async with environment() as (service, attach):
+ first = await attach(permission=allow)
+ profile = SimpleNamespace(id="primary", config_dir=service.directory / "profile")
+ await first.call("metadata", {"value": {
+ "profile_id": profile.id, "profile_root": str(profile.config_dir),
+ "cwd": str(service.directory),
+ }})
+ first.next_turn = {"id": "original-turn"}
+ await first.query("accepted once")
+ worker = service.sessions[first.id]
+ old_owner = worker.controller
+ waiting = asyncio.Event()
+ closing = asyncio.Event()
+ release_close = asyncio.Event()
+ conflict = asyncio.Event()
+ opens = []
+ original_dispatch = service.dispatch
+
+ async def dispatch(owner, request_id, method, params):
+ if method == "hold_connection_cleanup":
+ waiting.set()
+ try:
+ await asyncio.Future()
+ finally:
+ closing.set()
+ await release_close.wait()
+ if method == "open":
+ opens.append(params["session"])
+ if worker.controller is old_owner:
+ conflict.set()
+ try:
+ value = await original_dispatch(owner, request_id, method, params)
+ except RuntimeError as exc:
+ if legacy_service and "already has a controller" in str(exc):
+ # Older immutable services expose only RuntimeError's name.
+ raise RuntimeError("legacy controller conflict") from None
+ raise
+ if legacy_service and method == "hello":
+ value.pop("strict_controller_leases", None)
+ return value
+
+ monkeypatch.setattr(service, "dispatch", dispatch)
+ waiter = asyncio.create_task(first.call("hold_connection_cleanup"))
+ recovered = []
+ permission = None
+ restore = None
+
+ async def spawn(**kwargs):
+ assert kwargs["_service_recovering"] is True
+ assert kwargs["_service_worker_id"] == worker.id
+ client = replacement(first, worker)
+ recovered.append(client)
+ await client.connect()
+ client.ready.set()
+ return SimpleNamespace(sdk=SimpleNamespace(client=client))
+
+ try:
+ await asyncio.wait_for(waiting.wait(), 2)
+ await first.detach()
+ await asyncio.wait_for(closing.wait(), 2)
+ assert worker.controller is old_owner
+ permission = asyncio.create_task(worker.client.options.can_use_tool(
+ "Bash", {"command": "true"}, ToolPermissionContext(tool_use_id="tool")))
+ restore = asyncio.create_task(claude_service.restore(recovery_machine(
+ profile, spawn, socket=first.connection.socket_path)))
+ await asyncio.wait_for(conflict.wait(), 2)
+ release_close.set()
+ await asyncio.wait_for(restore, 2)
+ assert opens == [worker.id, worker.id]
+ assert len(recovered) == 1 and recovered[0].id == worker.id
+ assert worker.controller is not None and worker.controller is not old_owner
+ assert isinstance(await asyncio.wait_for(permission, 2), PermissionResultAllow)
+ assert worker.client.prompts == ["accepted once"]
+ assert worker.turn["id"] == "original-turn"
+ assert not worker.client.closed and worker.client.interrupts == 0
+ finally:
+ release_close.set()
+ if restore is not None:
+ restore.cancel()
+ await asyncio.gather(restore, return_exceptions=True)
+ for client in recovered:
+ await client.detach()
+ if permission is not None:
+ permission.cancel()
+ await asyncio.gather(permission, return_exceptions=True)
+ await asyncio.gather(waiter, return_exceptions=True)
+
+
+@pytest.mark.asyncio
+async def test_live_controller_is_not_replaced_when_retry_budget_expires(monkeypatch):
+ monkeypatch.setattr(client_module, "CONTROLLER_LEASE_WAIT_SECONDS", 0.05, raising=False)
+ monkeypatch.setattr(client_module, "CONTROLLER_LEASE_RETRY_DELAY", 0.01, raising=False)
+ async with environment() as (service, attach):
+ first = await attach()
+ first.next_turn = {"id": "original-turn"}
+ await first.query("accepted once")
+ worker = service.sessions[first.id]
+ old_owner = worker.controller
+ second = replacement(first, worker)
+ opens = []
+ original_dispatch = service.dispatch
+
+ async def dispatch(owner, request_id, method, params):
+ if method == "open":
+ opens.append(params["session"])
+ return await original_dispatch(owner, request_id, method, params)
+
+ monkeypatch.setattr(service, "dispatch", dispatch)
+ try:
+ started = asyncio.get_running_loop().time()
+ with pytest.raises(TimeoutError):
+ await asyncio.wait_for(second.connect(), 1)
+ assert asyncio.get_running_loop().time() - started < 0.5
+ assert len(opens) >= 2 and set(opens) == {worker.id}
+ assert second.connection.task.done() and second.callback_task is None
+ assert worker.controller is old_owner
+ assert len(service.sessions) == 1 and not worker.client.closed
+ assert worker.client.prompts == ["accepted once"]
+ await first.call("metadata", {"value": {"still_connected": True}})
+ assert worker.metadata["still_connected"]
+ finally:
+ await second.detach()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("failure", ["identity", "missing", "other_error", "legacy_missing"])
+async def test_recovery_does_not_retry_or_spawn_on_other_open_failures(monkeypatch, failure):
+ async with environment() as (service, attach):
+ first = await attach()
+ worker = service.sessions[first.id]
+ await first.detach()
+ await released(worker)
+ override = ({"cwd": "/different"} if failure == "identity" else
+ {"service_id": "missing-worker"} if failure == "missing" else {})
+ second = replacement(first, worker, **override)
+ opens = []
+ original_dispatch = service.dispatch
+
+ async def dispatch(owner, request_id, method, params):
+ if method == "open":
+ opens.append(params)
+ if failure in {"other_error", "legacy_missing"}:
+ raise RuntimeError("a non-lease service failure")
+ value = await original_dispatch(owner, request_id, method, params)
+ if failure == "legacy_missing":
+ if method == "hello":
+ value.pop("strict_controller_leases", None)
+ elif method == "list":
+ return []
+ return value
+
+ monkeypatch.setattr(service, "dispatch", dispatch)
+ try:
+ with pytest.raises(RuntimeError):
+ await second.connect()
+ assert len(opens) == 1
+ assert list(service.sessions) == [worker.id]
+ assert worker.controller is None and worker.client.prompts == []
+ assert second.connection.task.done()
+ finally:
+ await second.detach()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("close_fails", [False, True])
+async def test_explicit_reconnect_retires_worker_identity_only_after_confirmed_close(
+ monkeypatch, close_fails,
+):
+ async with environment() as (service, attach):
+ first = await attach()
+ worker = service.sessions[first.id]
+ sdk = SdkHandle(SimpleNamespace())
+ sdk.client = first
+ sdk.service_metadata = {**worker.metadata, "service_id": worker.id}
+ if close_fails:
+ original_dispatch = service.dispatch
+
+ async def dispatch(owner, request_id, method, params):
+ if method == "close":
+ raise RuntimeError("close failed")
+ return await original_dispatch(owner, request_id, method, params)
+
+ monkeypatch.setattr(service, "dispatch", dispatch)
+ with pytest.raises(RuntimeError):
+ await sdk.disconnect()
+ assert sdk.service_metadata["service_id"] == worker.id
+ assert not worker.client.closed
+ else:
+ await sdk.disconnect()
+ assert "service_id" not in sdk.service_metadata
+ assert worker.client.closed
+ second = RemoteClient(first.connection.socket_path, options=first.options,
+ metadata=sdk.service_metadata.copy())
+ try:
+ await second.connect()
+ assert second.id != worker.id
+ assert service.sessions[second.id].client.prompts == []
+ assert list(service.sessions) == [second.id]
+ finally:
+ await second.detach()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("failure", [ConnectionError, TimeoutError])
+async def test_unknown_open_response_is_not_retried(monkeypatch, failure):
+ async with environment() as (service, attach):
+ first = await attach()
+ first.next_turn = {"id": "original-turn"}
+ await first.query("accepted once")
+ worker = service.sessions[first.id]
+ await first.detach()
+ await released(worker)
+ second = replacement(first, worker)
+ original_call = second.connection.call
+ opens = []
+
+ async def lost_response(method, *args, **kwargs):
+ value = await original_call(method, *args, **kwargs)
+ if method == "open":
+ opens.append(value["id"])
+ raise failure("open acknowledgement lost")
+ return value
+
+ monkeypatch.setattr(second.connection, "call", lost_response)
+ try:
+ with pytest.raises(failure):
+ await second.connect()
+ await released(worker)
+ assert opens == [worker.id]
+ assert not worker.client.closed and worker.client.prompts == ["accepted once"]
+ assert second.connection.task.done() and second.callback_task is None
+ finally:
+ await second.detach()
+
+
+@pytest.mark.asyncio
+async def test_legacy_controller_can_reconnect_after_deliberate_close():
+ async with environment() as (service, attach):
+ first = await attach()
+ worker = service.sessions[first.id]
+ await first.disconnect()
+ connection = Connection(first.connection.socket_path)
+ await connection.connect()
+ try:
+ # Old controllers did not clear their stale worker hint after close
+ # or negotiate strict_session. Preserve that established contract.
+ description = await connection.call("open", {
+ "session": worker.id, "metadata": worker.metadata.copy(),
+ "options": options_payload(first.options),
+ })
+ assert description["id"] != worker.id
+ assert list(service.sessions) == [description["id"]]
+ assert service.sessions[description["id"]].client.prompts == []
+ finally:
+ await connection.disconnect()
+
+
+@pytest.mark.asyncio
+async def test_cancellation_during_lease_wait_closes_only_replacement_connection(monkeypatch):
+ async with environment() as (service, attach):
+ first = await attach()
+ worker = service.sessions[first.id]
+ old_owner = worker.controller
+ second = replacement(first, worker)
+ conflict = asyncio.Event()
+ original_dispatch = service.dispatch
+
+ async def dispatch(owner, request_id, method, params):
+ if method == "open":
+ conflict.set()
+ return await original_dispatch(owner, request_id, method, params)
+
+ monkeypatch.setattr(service, "dispatch", dispatch)
+ connecting = asyncio.create_task(second.connect())
+ try:
+ await asyncio.wait_for(conflict.wait(), 2)
+ connecting.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await connecting
+ assert second.connection.task.done() and second.callback_task is None
+ assert worker.controller is old_owner and not worker.client.closed
+ finally:
+ connecting.cancel()
+ await asyncio.gather(connecting, return_exceptions=True)
+ await second.detach()
diff --git a/tests/test_claude_service_recovery.py b/tests/test_claude_service_recovery.py
new file mode 100644
index 00000000..9404cc31
--- /dev/null
+++ b/tests/test_claude_service_recovery.py
@@ -0,0 +1,233 @@
+"""Isolate service-session recovery failures without weakening identity checks."""
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from claude_agent_sdk import PermissionResultAllow, ToolPermissionContext
+
+from cc_remote.claude_service.client import RemoteClient
+from cc_remote.wrapper import claude_service
+from tests.test_claude_service import environment, released
+
+
+def recovery_machine(profile, spawn, *, socket="current", drain_socket=""):
+ return SimpleNamespace(
+ cfg=SimpleNamespace(claude_service_socket=socket,
+ claude_service_drain_socket=drain_socket),
+ _claude_profile=lambda profile_id: profile,
+ _spawn=spawn,
+ )
+
+
+def session_item(profile, session_id):
+ return {"id": "worker-" + session_id, "metadata": {
+ "profile_id": profile.id, "profile_root": str(profile.config_dir),
+ "session_id": session_id, "cwd": str(profile.config_dir), "space": "code",
+ }}
+
+
+@pytest.mark.parametrize("failure", ["none", "exception"])
+def test_one_failed_session_does_not_strand_other_accepted_turns(failure, monkeypatch):
+ async def go():
+ warnings = []
+ monkeypatch.setattr(claude_service, "log", SimpleNamespace(
+ warning=lambda message, **fields: warnings.append((message, fields))), raising=False)
+
+ async def allow(*args):
+ return PermissionResultAllow()
+
+ async with environment() as (service, attach):
+ profile = SimpleNamespace(id="primary", config_dir=service.directory / "profile")
+ originals = {}
+ workers = {}
+ for name in ("healthy-before", "broken", "healthy-after"):
+ client = await attach(session_id=name, permission=allow)
+ await client.call("metadata", {"value": {
+ "profile_id": profile.id, "profile_root": str(profile.config_dir),
+ "cwd": str(service.directory),
+ }})
+ client.next_turn = {"id": "original-" + name}
+ await client.query("accepted once: " + name)
+ originals[name] = client
+ workers[name] = service.sessions[client.id]
+ await client.detach()
+ await released(workers[name])
+
+ attempted = []
+ recovered = []
+ permissions = [asyncio.create_task(workers[name].client.options.can_use_tool(
+ "Bash", {"command": "true"}, ToolPermissionContext(tool_use_id=name)))
+ for name in ("healthy-before", "healthy-after")]
+
+ async def spawn(**kwargs):
+ name = kwargs["resume_id"]
+ attempted.append(name)
+ assert kwargs["_service_recovering"] is True
+ assert kwargs["_service_worker_id"] == workers[name].id
+ if name == "broken":
+ if failure == "exception":
+ raise OSError("private provider details")
+ return None
+ client = RemoteClient(kwargs["_service_socket"],
+ options=originals[name].options,
+ metadata=workers[name].metadata.copy())
+ recovered.append(client)
+ await client.connect()
+ client.ready.set()
+ return SimpleNamespace(sdk=SimpleNamespace(client=client))
+
+ machine = recovery_machine(profile, spawn,
+ socket=originals["broken"].connection.socket_path)
+ try:
+ await claude_service.restore(machine)
+ assert attempted == ["healthy-before", "broken", "healthy-after"]
+ answers = await asyncio.wait_for(asyncio.gather(*permissions), 2)
+ assert all(isinstance(answer, PermissionResultAllow) for answer in answers)
+ assert workers["healthy-before"].controller is not None
+ assert workers["healthy-after"].controller is not None
+ assert workers["broken"].controller is None
+ for name, worker in workers.items():
+ assert worker.client.prompts == ["accepted once: " + name]
+ assert worker.turn["id"] == "original-" + name
+ assert not worker.client.closed
+ assert len(warnings) == 1
+ assert warnings[0][1]["service_id"] == workers["broken"].id
+ assert warnings[0][1]["error_type"] == (
+ "OSError" if failure == "exception" else "RuntimeError")
+ assert "private provider details" not in repr(warnings)
+ finally:
+ for client in recovered:
+ await client.detach()
+ for task in permissions:
+ task.cancel()
+ await asyncio.gather(*permissions, return_exceptions=True)
+
+ asyncio.run(go())
+
+
+def test_duplicate_scan_finishes_before_attaching_any_session(tmp_path, monkeypatch):
+ async def go():
+ profile = SimpleNamespace(id="primary", config_dir=tmp_path)
+ duplicate = session_item(profile, "duplicate")
+ listings = {
+ "drain": [session_item(profile, "healthy"), duplicate],
+ "current": [{**duplicate, "id": "other-worker"}],
+ }
+ listed = []
+
+ async def list_sessions(socket):
+ listed.append(socket)
+ return listings[socket]
+
+ monkeypatch.setattr(claude_service, "_list_sessions", list_sessions)
+ spawn = AsyncMock()
+ machine = recovery_machine(profile, spawn, drain_socket="drain")
+ with pytest.raises(RuntimeError, match="exists in both SDK services"):
+ await claude_service.restore(machine)
+ assert listed == ["drain", "current"]
+ spawn.assert_not_awaited()
+
+ asyncio.run(go())
+
+
+@pytest.mark.parametrize("unavailable", ["primary", "drain"])
+def test_unavailable_service_does_not_strand_other_services_turns(unavailable, monkeypatch):
+ async def go():
+ warnings = []
+ monkeypatch.setattr(claude_service, "log", SimpleNamespace(
+ warning=lambda message, **fields: warnings.append((message, fields))))
+
+ async def allow(*args):
+ return PermissionResultAllow()
+
+ async with environment() as (service, attach):
+ profile = SimpleNamespace(id="primary", config_dir=service.directory / "profile")
+ original = await attach(permission=allow)
+ await original.call("metadata", {"value": {
+ "profile_id": profile.id, "profile_root": str(profile.config_dir),
+ "cwd": str(service.directory),
+ }})
+ original.next_turn = {"id": "original-turn"}
+ await original.query("accepted once")
+ worker = service.sessions[original.id]
+ await original.detach()
+ await released(worker)
+ permission = asyncio.create_task(worker.client.options.can_use_tool(
+ "Bash", {"command": "true"}, ToolPermissionContext(tool_use_id="tool")))
+ recovered = []
+
+ async def spawn(**kwargs):
+ assert kwargs["_service_recovering"] is True
+ assert kwargs["_service_worker_id"] == worker.id
+ assert kwargs["_service_socket"] == original.connection.socket_path
+ client = RemoteClient(kwargs["_service_socket"], options=original.options,
+ metadata=worker.metadata.copy())
+ recovered.append(client)
+ await client.connect()
+ client.ready.set()
+ return SimpleNamespace(sdk=SimpleNamespace(client=client))
+
+ sockets = {"primary": original.connection.socket_path,
+ "drain": original.connection.socket_path}
+ sockets[unavailable] = str(service.directory / "missing.sock")
+ machine = recovery_machine(profile, spawn, socket=sockets["primary"],
+ drain_socket=sockets["drain"])
+ try:
+ await claude_service.restore(machine)
+ assert isinstance(await asyncio.wait_for(permission, 2), PermissionResultAllow)
+ assert len(recovered) == 1 and worker.controller is not None
+ assert worker.client.prompts == ["accepted once"]
+ assert worker.turn["id"] == "original-turn" and not worker.client.closed
+ assert len(warnings) == 1
+ assert warnings[0][1] == {
+ "service_role": unavailable, "error_type": "FileNotFoundError"}
+ finally:
+ for client in recovered:
+ await client.detach()
+ permission.cancel()
+ await asyncio.gather(permission, return_exceptions=True)
+
+ asyncio.run(go())
+
+
+def test_all_unavailable_services_do_not_spawn_a_replacement(tmp_path, monkeypatch):
+ async def go():
+ profile = SimpleNamespace(id="primary", config_dir=tmp_path)
+ listing = AsyncMock(side_effect=ConnectionError("private socket details"))
+ monkeypatch.setattr(claude_service, "_list_sessions", listing)
+ spawn = AsyncMock()
+ await claude_service.restore(recovery_machine(profile, spawn, drain_socket="drain"))
+ assert [call.args[0] for call in listing.await_args_list] == ["drain", "current"]
+ spawn.assert_not_awaited()
+
+ asyncio.run(go())
+
+
+def test_service_listing_cancellation_stops_recovery(tmp_path, monkeypatch):
+ async def go():
+ profile = SimpleNamespace(id="primary", config_dir=tmp_path)
+ listing = AsyncMock(side_effect=asyncio.CancelledError())
+ monkeypatch.setattr(claude_service, "_list_sessions", listing)
+ spawn = AsyncMock()
+ with pytest.raises(asyncio.CancelledError):
+ await claude_service.restore(recovery_machine(profile, spawn, drain_socket="drain"))
+ assert listing.await_count == 1
+ spawn.assert_not_awaited()
+
+ asyncio.run(go())
+
+
+def test_session_recovery_cancellation_stops_the_restore_loop(tmp_path, monkeypatch):
+ async def go():
+ profile = SimpleNamespace(id="primary", config_dir=tmp_path)
+ monkeypatch.setattr(claude_service, "_list_sessions", AsyncMock(return_value=[
+ session_item(profile, "first"), session_item(profile, "second"),
+ ]))
+ spawn = AsyncMock(side_effect=asyncio.CancelledError())
+ with pytest.raises(asyncio.CancelledError):
+ await claude_service.restore(recovery_machine(profile, spawn))
+ assert spawn.await_count == 1
+
+ asyncio.run(go())
diff --git a/tests/test_claude_steer_attachments.py b/tests/test_claude_steer_attachments.py
new file mode 100644
index 00000000..6ebec539
--- /dev/null
+++ b/tests/test_claude_steer_attachments.py
@@ -0,0 +1,142 @@
+"""Uploaded steering files follow the native owner's lifetime, not its socket."""
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from cc_remote.protocol import ERR_STEER_UNKNOWN, Steer
+from cc_remote.wrapper import claude_steer
+from cc_remote.wrapper.sdk import SdkHandle
+from cc_remote.wrapper.stream import StreamTranslator
+from tests.test_claude_service import environment, released
+from tests.test_claude_steering import NativeClient, result, user
+from tests.test_multisession import _mk_ctx, _mk_machine
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("uncertain_write", [False, True])
+@pytest.mark.parametrize("exit_path", ["disconnect", "drain_reconnect", "shutdown"])
+async def test_abnormal_turn_files_are_removed_after_native_close(
+ tmp_path, monkeypatch, uncertain_write, exit_path,
+):
+ machine, _ = _mk_machine()
+ ctx = _mk_ctx("sid", "sid")
+ ctx.sdk = sdk = SdkHandle(machine.cfg)
+ machine.sessions[ctx.key] = ctx
+ machine._configure_claude_sdk_callbacks(ctx, sdk)
+ sdk.client = native = NativeClient()
+ sdk._start_message_pump()
+ sdk.next_turn_id = "root"
+ await sdk.query("start")
+ ctx.state = "running"
+ ctx.active_msg_id = "root"
+ ctx.translator = StreamTranslator(4000, turn_id="root")
+ directory = tmp_path / "upload"
+ directory.mkdir()
+ monkeypatch.setattr(claude_steer.tempfile, "mkdtemp", lambda **kwargs: str(directory))
+ native.fail_write = uncertain_write
+
+ async def close_native():
+ assert directory.exists()
+
+ native.disconnect = AsyncMock(side_effect=close_native)
+ sdk.connect = AsyncMock()
+ try:
+ reply = await machine._handle_steer(Steer(
+ sid="sid", cmd_id="command", client_id="browser", msg_id="guide", prompt="read",
+ files=[{"filename": "note.txt", "data": "aGVsbG8="}],
+ ))
+ if uncertain_write:
+ assert reply.code == ERR_STEER_UNKNOWN
+ else:
+ assert reply is None
+ assert (directory / "00-note.txt").read_text() == "hello"
+ # Losing the reader does not establish that the native owner stopped.
+ await machine._on_claude_message_pump_failure(ctx, ConnectionError("reader lost"))
+ assert directory.exists()
+ assert ctx.claude_steer_attachment_dirs == [str(directory)]
+ if exit_path == "drain_reconnect":
+ await sdk.force_reconnect(ctx.session_id, ctx.cwd)
+ sdk.connect.assert_awaited_once()
+ elif exit_path == "shutdown":
+ await sdk.detach_for_shutdown()
+ else:
+ await sdk.disconnect()
+ native.disconnect.assert_awaited_once()
+ assert not directory.exists()
+ assert ctx.claude_steer_attachment_dirs == []
+ assert len(native.inputs) == 2 and native.interrupts == 0
+ finally:
+ await sdk._stop_message_pump()
+
+
+@pytest.mark.asyncio
+async def test_failed_service_close_keeps_files_for_the_live_native_owner(tmp_path):
+ machine, _ = _mk_machine()
+ ctx = _mk_ctx("sid", "sid")
+ ctx.sdk = sdk = SdkHandle(machine.cfg)
+ machine._configure_claude_sdk_callbacks(ctx, sdk)
+ directory = tmp_path / "upload"
+ directory.mkdir()
+ ctx.claude_steer_attachment_dirs.append(str(directory))
+ sdk.client = SimpleNamespace(disconnect=AsyncMock(side_effect=ConnectionError()))
+ with pytest.raises(ConnectionError):
+ await sdk.disconnect()
+ assert directory.exists()
+ assert ctx.claude_steer_attachment_dirs == [str(directory)]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("echoed", [False, True])
+@pytest.mark.parametrize("exit_path", ["close", "terminal"])
+async def test_service_retains_detached_files_then_retires_them(
+ tmp_path, echoed, exit_path,
+):
+ async with environment() as (service, attach):
+ first = await attach()
+ first.next_turn = {"id": "root"}
+ await first.query("start")
+ worker = service.sessions[first.id]
+ directory = tmp_path / "upload"
+ directory.mkdir()
+ (directory / "note.txt").write_text("hello")
+ await first.steer("read attachment", native_id="guide-native", turn_id="root",
+ metadata={"id": "guide", "prompt": "read attachment",
+ "attachment_dir": str(directory)})
+ machine, _ = _mk_machine()
+ ctx = _mk_ctx("sid", "sid")
+ ctx.sdk = sdk = SdkHandle(machine.cfg)
+ machine._configure_claude_sdk_callbacks(ctx, sdk)
+ sdk.client = first
+ ctx.claude_steer_attachment_dirs.append(str(directory))
+ if echoed:
+ await worker.client.queue.put(user("guide-native"))
+ async with asyncio.timeout(2):
+ while worker.steers.pending:
+ await asyncio.sleep(0.001)
+ await sdk.detach_for_shutdown()
+ await released(worker)
+ assert directory.exists() and not worker.client.closed
+ # A fresh Wrapper has not replayed the echo yet. The service must still
+ # own cleanup even if the controller closes it before reading anything.
+ second = await attach()
+ if exit_path == "close":
+ await second.disconnect()
+ assert worker.client.closed
+ else:
+ # Background and intermediate Results are not human terminals.
+ await worker.client.queue.put({**result(), "origin": {"kind": "task"}})
+ if not echoed:
+ await worker.client.queue.put(result())
+ await worker.client.queue.put(user("guide-native"))
+ await worker.client.queue.put(result())
+ async with asyncio.timeout(2):
+ while worker.terminal_seq is None:
+ await asyncio.sleep(0.001)
+ assert directory.exists()
+ await second.call("commit", {"turn_id": "root", "seq": worker.terminal_seq})
+ assert not worker.client.closed
+ assert not directory.exists()
+ assert worker.client.interrupts == 0 and len(worker.client.prompts) == 2
diff --git a/tests/test_claude_steering.py b/tests/test_claude_steering.py
new file mode 100644
index 00000000..2bb9ea12
--- /dev/null
+++ b/tests/test_claude_steering.py
@@ -0,0 +1,288 @@
+"""Non-interrupting Claude inputs through one native reader and durable owner."""
+
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+from claude_agent_sdk.types import ResultMessage
+
+from cc_remote.claude_steering import ClaudeSteerRejected
+from cc_remote.protocol import Steer
+from cc_remote.wrapper.sdk import SdkHandle
+from cc_remote.wrapper.stream import StreamTranslator, replayed_user_message_id
+from cc_remote.wrapper import claude_steer
+from tests.test_claude_service import environment, released
+from tests.test_multisession import _mk_ctx, _mk_machine
+
+
+def user(uid, text="guide"):
+ return {"type": "user", "uuid": uid, "parent_tool_use_id": None,
+ "message": {"role": "user", "content": text}}
+
+
+def result():
+ return {"type": "result", "subtype": "success", "duration_ms": 42,
+ "duration_api_ms": 12, "is_error": False, "num_turns": 1,
+ "session_id": "native-session"}
+
+
+def assistant(uid, content):
+ return {"type": "assistant", "uuid": uid,
+ "message": {"role": "assistant", "model": "claude-sonnet-4-6",
+ "content": content}}
+
+
+class NativeClient:
+ def __init__(self):
+ self._query = self
+ self.queue = asyncio.Queue()
+ self.inputs = []
+ self.consumers = 0
+ self.interrupts = 0
+ self.fail_write = False
+
+ async def query(self, prompt):
+ self.inputs.append(prompt if isinstance(prompt, str)
+ else [item async for item in prompt])
+ if self.fail_write:
+ raise ConnectionError("write acknowledgement lost")
+
+ async def receive_messages(self):
+ self.consumers += 1
+ while True:
+ yield await self.queue.get()
+
+ async def interrupt(self):
+ self.interrupts += 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("racing_terminal", [False, True])
+@pytest.mark.parametrize("uncertain_write", [False, True])
+async def test_steer_uses_next_without_interrupt_or_second_reader(racing_terminal, uncertain_write):
+ sdk = SdkHandle(SimpleNamespace(turn_reader_queue_cap=8))
+ sdk.client = native = NativeClient()
+ sdk._start_message_pump()
+ sdk.next_turn_id = "root"
+ await sdk.query("start")
+ received = []
+
+ async def collect():
+ async for message in sdk.receive_response():
+ received.append(message)
+
+ reader = asyncio.create_task(collect())
+ try:
+ await native.queue.put(user("root-native", "start"))
+ native.fail_write = uncertain_write
+ pending = sdk.steer("guide", native_id="guide-native", metadata={"id": "guide-ui", "prompt": "guide"})
+ if uncertain_write:
+ with pytest.raises(ConnectionError):
+ await pending
+ else:
+ await pending
+ payload = native.inputs[1][0]
+ assert payload["priority"] == "next"
+ assert payload["uuid"] == "guide-native"
+ assert payload["message"]["content"] == "guide"
+ with pytest.raises(RuntimeError, match="active response"):
+ await sdk.query("must remain busy")
+ if racing_terminal:
+ await native.queue.put(result())
+ await native.queue.put(user("guide-native"))
+ await native.queue.put(assistant("reply", [{"type": "text", "text": "after guidance"}]))
+ await native.queue.put(result())
+ await asyncio.wait_for(reader, 2)
+ assert native.consumers == 1 and native.interrupts == 0
+ assert len(native.inputs) == 2
+ assert sum(isinstance(m, ResultMessage) for m in received) == 1
+ echoes = [m for m in received if getattr(m, "_cc_steer", None)]
+ assert len(echoes) == 1 and echoes[0]._cc_steer["id"] == "guide-ui"
+ with pytest.raises(ClaudeSteerRejected):
+ await sdk.steer("late", native_id="late", metadata={"id": "late"})
+ assert len(native.inputs) == 2
+ finally:
+ reader.cancel()
+ await asyncio.gather(reader, return_exceptions=True)
+ sdk.release_background_messages()
+ await sdk._stop_message_pump()
+
+
+@pytest.mark.asyncio
+async def test_service_preserves_input_boundary_and_root_commit_across_detach():
+ async with environment() as (service, attach):
+ first = await attach()
+ first.next_turn = {"id": "root", "prompt": "start"}
+ await first.query("start")
+ worker = service.sessions[first.id]
+ native = worker.client
+ await first.steer("guide", native_id="guide-native",
+ metadata={"id": "guide-ui", "prompt": "guide"}, turn_id="root")
+ envelope = [item async for item in native.prompts[1]]
+ assert envelope[0]["priority"] == "next"
+ await first.detach()
+ await released(worker)
+ await native.queue.put(result())
+ await native.queue.put(user("guide-native"))
+ await native.queue.put(result())
+ second = await attach()
+ replay = second.receive_messages()
+ rows = []
+ async with asyncio.timeout(2):
+ while len(rows) < 3:
+ row = await anext(replay)
+ if row.get("type") != "system":
+ rows.append(row)
+ assert rows[0]["__cc_steer_intermediate"]
+ assert rows[1]["__cc_steer"]["id"] == "guide-ui"
+ assert "__cc_steer_intermediate" not in rows[2]
+ assert second.recovery["id"] == "root"
+ assert worker.terminal_seq == rows[2]["__cc_service_seq"]
+ with pytest.raises(ClaudeSteerRejected):
+ await second.steer("late", native_id="late", metadata={"id": "late"}, turn_id="root")
+ sdk = SdkHandle(SimpleNamespace())
+ sdk.client = second
+ sdk._turn_root_id = "root"
+ terminal = SimpleNamespace(_cc_service_seq=rows[2]["__cc_service_seq"])
+ await sdk.ack_service_message(terminal, turn_id="guide-ui")
+ assert worker.turn is None
+ assert native.interrupts == 0 and len(native.prompts) == 2
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("btw", [False, True])
+async def test_machine_waits_for_native_echo_and_retains_old_item_ownership(btw):
+ machine, transport = _mk_machine()
+ ctx = _mk_ctx("sid", "sid")
+ ctx.btw = btw
+ ctx.state = "running"
+ ctx.active_msg_id = "root"
+ ctx.translator = StreamTranslator(4000, turn_id="root")
+ ctx.sdk = sdk = SdkHandle(SimpleNamespace(turn_reader_queue_cap=8))
+ sdk.client = native = NativeClient()
+ sdk._start_message_pump()
+ machine.sessions[ctx.key] = ctx
+ sdk.next_turn_id = "root"
+ await sdk.query("start")
+ try:
+ tool = sdk._parse_compat_message(assistant("tool-msg", [{
+ "type": "tool_use", "id": "tool-1", "name": "Bash", "input": {"command": "pwd"}}]))
+ before = ctx.translator.feed(tool)
+ assert any(getattr(e, "turn_id", None) == "root" for e in before)
+ command = Steer(sid="sid", cmd_id="command", client_id="browser", msg_id="guide-ui", prompt="guide")
+ assert await machine._handle_steer(command) is None
+ assert ctx.active_msg_id == "root"
+ assert not any(e.type == "turn_steered" for e in transport.sent)
+ native_id = native.inputs[1][0]["uuid"]
+ raw = sdk._steers.annotate(user(native_id))
+ echo = sdk._parse_compat_message(raw)
+ echo._cc_steer = raw["__cc_steer"]
+ event = await claude_steer.apply_echo(machine, ctx, echo, replayed_user_message_id(echo))
+ assert event.msg_id == "guide-ui" and event.turn_id == native_id
+ assert ctx.active_msg_id == "guide-ui"
+ late_tool = sdk._parse_compat_message(user("tool-result", [{
+ "type": "tool_result", "tool_use_id": "tool-1", "content": "old tool finished"}]))
+ late = ctx.translator.feed(late_tool)
+ assert any(e.type == "tool_result" and e.turn_id == "root" for e in late)
+ new = ctx.translator.feed(sdk._parse_compat_message(assistant("reply", [{"type": "text", "text": "new"}])))
+ assert all(e.turn_id == "guide-ui" for e in new if e.type in {"delta", "assistant_msg_start"})
+ assert native.interrupts == 0
+ finally:
+ await sdk._stop_message_pump()
+
+
+@pytest.mark.asyncio
+async def test_service_without_native_steering_rejects_before_mutation():
+ async with environment() as (service, attach):
+ client = await attach()
+ client.description.pop("native_steering")
+ with pytest.raises(ClaudeSteerRejected):
+ await client.steer("guide", native_id="uid", metadata={"id": "id"}, turn_id="root")
+ assert service.sessions[client.id].client.prompts == []
+
+
+@pytest.mark.asyncio
+async def test_explicit_stop_cancels_pending_native_inputs_and_drains_actual_result():
+ sdk = SdkHandle(SimpleNamespace(turn_reader_queue_cap=8))
+ sdk.client = native = NativeClient()
+ sdk._start_message_pump()
+ sdk._steers.capabilities.add("interrupt_cancel_queued_v1")
+ sdk.next_turn_id = "root"
+ await sdk.query("start")
+ received = []
+
+ async def collect():
+ async for message in sdk.receive_response():
+ received.append(message)
+
+ async def control(request):
+ assert request == {"subtype": "interrupt", "cancel_queued": True}
+ await native.queue.put({"type": "command_lifecycle", "state": "cancelled",
+ "command_uuid": "guide-native"})
+ await native.queue.put({**result(), "subtype": "error_during_execution", "is_error": True})
+ return {"still_queued": [], "cancelled": ["guide-native"]}
+
+ native._send_control_request = control
+ reader = asyncio.create_task(collect())
+ try:
+ await sdk.steer("guide", native_id="guide-native", metadata={"id": "guide-ui"})
+ await sdk.interrupt()
+ await asyncio.wait_for(reader, 2)
+ assert received[0]._cc_steer_cancelled["id"] == "guide-ui"
+ assert isinstance(received[-1], ResultMessage) and received[-1].is_error
+ assert not sdk._steers.pending
+ assert len(native.inputs) == 2 and native.interrupts == 0
+ finally:
+ reader.cancel()
+ await asyncio.gather(reader, return_exceptions=True)
+ await sdk._stop_message_pump()
+
+
+@pytest.mark.asyncio
+async def test_service_retried_steer_after_detach_does_not_submit_again(tmp_path):
+ async with environment() as (service, attach):
+ first = await attach()
+ first.next_turn = {"id": "root", "prompt": "start"}
+ await first.query("start")
+ metadata = {"id": "guide-ui", "prompt": "read attachment", "fingerprint": "a" * 64,
+ "attachment_dir": str(tmp_path / "first")}
+ await first.steer("read /first/file", native_id="stable-guidance", metadata=metadata, turn_id="root")
+ worker = service.sessions[first.id]
+ await first.detach()
+ await released(worker)
+ second = await attach()
+ await second.steer("read /second/file", native_id="stable-guidance",
+ metadata={**metadata, "attachment_dir": str(tmp_path / "second")}, turn_id="root")
+ assert len(worker.client.prompts) == 2
+ assert worker.steers.pending["stable-guidance"] == metadata
+ with pytest.raises(RuntimeError, match="ValueError"):
+ await second.steer("changed input", native_id="stable-guidance",
+ metadata={**metadata, "fingerprint": "b" * 64}, turn_id="root")
+ assert len(worker.client.prompts) == 2
+
+
+@pytest.mark.asyncio
+async def test_service_cancelled_guide_replays_before_terminal():
+ async with environment() as (service, attach):
+ first = await attach()
+ first.next_turn = {"id": "root", "prompt": "start"}
+ await first.query("start")
+ worker = service.sessions[first.id]
+ worker.steers.capabilities.add("interrupt_cancel_queued_v1")
+ await first.steer("guide", native_id="guide-native", metadata={"id": "guide-ui"}, turn_id="root")
+
+ async def control(request):
+ assert request["cancel_queued"]
+ await worker.client.queue.put({"type": "command_lifecycle", "state": "cancelled",
+ "command_uuid": "guide-native"})
+ await worker.client.queue.put({**result(), "is_error": True})
+ return {"cancelled": ["guide-native"]}
+
+ worker.client._send_control_request = control
+ await first.interrupt()
+ replay = first.receive_messages()
+ cancelled, terminal = await anext(replay), await anext(replay)
+ assert cancelled["__cc_steer_cancelled"]["id"] == "guide-ui"
+ assert "__cc_steer_intermediate" not in terminal
+ assert worker.terminal_seq == terminal["__cc_service_seq"]
+ assert not worker.steers.pending
diff --git a/tests/test_claude_thinking.py b/tests/test_claude_thinking.py
new file mode 100644
index 00000000..720011b5
--- /dev/null
+++ b/tests/test_claude_thinking.py
@@ -0,0 +1,62 @@
+"""Request readable Claude summaries without changing native thinking controls."""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport
+
+from cc_remote.config import WrapperConfig
+from cc_remote.wrapper import sdk as sdk_module
+from cc_remote.wrapper.sdk import SdkHandle
+from tests.test_claude_permission_state import _FakeClaudeClient
+
+
+@pytest.mark.parametrize("profile", ["legacy", "isolated", "work"])
+@pytest.mark.parametrize("resume", [None, "12345678-1234-4234-8234-123456789abc"])
+def test_summary_display_reaches_cli_without_overriding_thinking(
+ tmp_path, monkeypatch, profile, resume,
+):
+ monkeypatch.setenv("MAX_THINKING_TOKENS", "0")
+ handle = SdkHandle(
+ WrapperConfig(claude_bin=str(tmp_path / "claude")),
+ claude_config_dir=str(tmp_path / "account") if profile == "isolated" else None,
+ isolate_account_env=profile == "isolated",
+ )
+ handle.work_mode = profile == "work"
+ handle.permission_mode = "plan"
+ options = handle._options(resume, str(tmp_path), effort_override="low")
+ # Inspect the real pinned SDK's argv without launching a CLI or model.
+ argv = SubprocessCLITransport(prompt="unused", options=options)._build_command()
+
+ assert argv.count("--thinking-display") == 1
+ assert argv[argv.index("--thinking-display") + 1] == "summarized"
+ assert "--thinking" not in argv
+ assert "--max-thinking-tokens" not in argv
+ assert options.thinking is None
+ assert options.max_thinking_tokens is None
+ assert options.effort == "low"
+ assert options.permission_mode == "plan"
+
+
+def test_summary_display_survives_connect_reconnect_and_private_fork(monkeypatch):
+ async def go():
+ _FakeClaudeClient.created = []
+ monkeypatch.setattr(sdk_module, "ClaudeSDKClient", _FakeClaudeClient)
+ handle = SdkHandle(WrapperConfig())
+ handle.effort = "low"
+ try:
+ await handle.connect(cwd="/tmp")
+ await handle.force_reconnect(None, "/tmp", reason="test")
+ for client in _FakeClaudeClient.created:
+ assert client.options.extra_args["thinking-display"] == "summarized"
+ assert client.options.thinking is None
+ assert client.options.effort == "low"
+ fork = handle._options("parent", "/tmp", fork=True)
+ assert fork.extra_args["thinking-display"] == "summarized"
+ assert fork.fork_session is True
+ finally:
+ await handle.disconnect()
+
+ asyncio.run(go())
diff --git a/tests/test_codex_profiles.py b/tests/test_codex_profiles.py
index 181c5884..ab1e5769 100644
--- a/tests/test_codex_profiles.py
+++ b/tests/test_codex_profiles.py
@@ -3574,3 +3574,23 @@ async def broadcast(*_args, **_kwargs):
assert result["last_run_status"] == "failed"
assert result["last_run_attempt"] == 1
assert "账号不可用" in result["last_error"]
+
+
+def test_native_start_hint_without_catalog_does_not_publish_empty_session(tmp_path, monkeypatch):
+ async def run():
+ machine, _ = _machine(tmp_path)
+ primary = _context("primary@current", "current", "primary")
+ machine.sessions[primary.key] = primary
+
+ async def no_catalog(_limit, *, codex_home=None):
+ return []
+
+ monkeypatch.setattr(machine_module, "list_codex_sessions", no_catalog)
+ monkeypatch.setattr(machine_module, "codex_exact_catalog_rows", lambda *a, **kw: [])
+ machine._on_codex_thread_started_hint(primary, "unmaterialized-helper")
+ await asyncio.gather(*tuple(machine._codex_catalog_hint_tasks))
+ rows = await machine._read_all_codex_profile_sessions()
+ assert all(row["native_session_id"] != "unmaterialized-helper" for row in rows)
+ assert any(row["native_session_id"] == "current" for row in rows)
+
+ asyncio.run(run())
diff --git a/tests/test_codex_session_migration.py b/tests/test_codex_session_migration.py
index af836422..6425cf81 100644
--- a/tests/test_codex_session_migration.py
+++ b/tests/test_codex_session_migration.py
@@ -91,7 +91,7 @@ async def list_sessions(_cmd):
def test_session_migration_protocol_roundtrips_as_control_frames():
- assert PROTOCOL_VERSION == 67
+ assert PROTOCOL_VERSION == 71
command = deserialize(serialize(_command("/tmp/new-cwd")))
assert command.type == "migrate_session"
assert command.session_id == "thread-1"
diff --git a/tests/test_engine_versions.py b/tests/test_engine_versions.py
index 09281ea1..b668abbc 100644
--- a/tests/test_engine_versions.py
+++ b/tests/test_engine_versions.py
@@ -64,7 +64,7 @@ def test_claude_runtime_rejects_failed_version_probe(monkeypatch, tmp_path):
claude_runtime.subprocess,
"run",
lambda *args, **kwargs: SimpleNamespace(
- stdout="2.1.258 (Claude Code)\n", stderr="", returncode=1,
+ stdout="2.1.263 (Claude Code)\n", stderr="", returncode=1,
),
)
@@ -72,12 +72,12 @@ def test_claude_runtime_rejects_failed_version_probe(monkeypatch, tmp_path):
claude_runtime.probe_claude_cli_version(str(cli))
-@pytest.mark.parametrize("version", ["2.1.258", "2.1.258+build.1", "2.2.0", "3.0.0"])
+@pytest.mark.parametrize("version", ["2.1.263", "2.1.263+build.1", "2.2.0", "3.0.0"])
def test_claude_runtime_accepts_supported_cli_versions(version):
assert claude_runtime.validate_cli_version(version) == version
-@pytest.mark.parametrize("version", ["2.1.257", "2.1.258-beta.1"])
+@pytest.mark.parametrize("version", ["2.1.257", "2.1.263-beta.1"])
def test_claude_runtime_rejects_unsupported_cli_versions(version):
with pytest.raises(RuntimeError, match="run `claude update`"):
claude_runtime.validate_cli_version(version)
@@ -95,7 +95,7 @@ def test_claude_runtime_inspection_enforces_cli_minimum(monkeypatch, tmp_path):
claude_runtime, "probe_claude_cli_version", lambda _path: "2.1.257",
)
- with pytest.raises(RuntimeError, match="older than required 2.1.258"):
+ with pytest.raises(RuntimeError, match="older than required 2.1.263"):
claude_runtime.inspect_claude_runtime(str(cli))
diff --git a/tests/test_history.py b/tests/test_history.py
index 9976841c..780e4c0f 100644
--- a/tests/test_history.py
+++ b/tests/test_history.py
@@ -8791,6 +8791,48 @@ def test_late_internal_task_notification_does_not_extend_completed_answer():
assert terminal.result.duration_ms == 10_000
+def test_background_replies_advance_answer_clock_but_late_bookkeeping_does_not():
+ ids = [f"{n:08d}-1111-4111-8111-111111111111" for n in range(1, 8)]
+ prompt, original, first_notice, first_reply, second_notice, final, late = ids
+ rows = [
+ {"uuid": prompt, "type": "user", "message": {
+ "role": "user", "content": "review"}},
+ ]
+ notices = {}
+ for uid, is_notice, text in (
+ (original, False, "two reviews are running"),
+ (first_notice, True, "first review completed"),
+ (first_reply, False, "waiting for the second review"),
+ (second_notice, True, "second review completed"),
+ (final, False, "consolidated review"),
+ (late, True, "old command stopped after resume"),
+ ):
+ rows.append({"uuid": uid, "type": "user" if is_notice else "assistant",
+ "message": {"role": "user" if is_notice else "assistant",
+ "content": text if is_notice else [{"type": "text", "text": text}],
+ "stop_reason": None if is_notice else "end_turn"}})
+ if is_notice:
+ notices[uid] = ProcessEvent(
+ item_id=uid, kind="task", phase="end", status="succeeded",
+ title=text, background=True)
+ events = translate_history(
+ [_session_message(row) for row in rows], 10_000,
+ timestamps=dict(zip(ids, (1_000, 1_010, 2_000, 2_010, 3_000, 3_010, 40_000))),
+ internal_user_events=notices)
+ terminals = [event for event in events if isinstance(event, TurnEnd)]
+ assert len(terminals) == 1
+ assert terminals[0].ts == 3_010
+ assert terminals[0].turn_id == final
+ assert terminals[0].result.duration_ms == 2_010_000
+ replies = [event for event in events if isinstance(event, Delta)]
+ assert [(event.text, event.ts) for event in replies] == [
+ ("two reviews are running", 1_010),
+ ("waiting for the second review", 2_010),
+ ("consolidated review", 3_010),
+ ]
+ assert [event.background for event in replies] == [None, True, True]
+
+
def test_history_hides_cancelled_command_placeholders_without_hiding_real_text():
user_id = "11111111-1111-4111-8111-111111111111"
answer_id = "22222222-2222-4222-8222-222222222222"
diff --git a/tests/test_history_store.py b/tests/test_history_store.py
index 7bacf24e..2941e162 100644
--- a/tests/test_history_store.py
+++ b/tests/test_history_store.py
@@ -461,14 +461,14 @@ def test_paged_file_migration_rebuilds_summaries_once_without_removing_assets(tm
connection.execute(f"PRAGMA user_version={old_version}")
migrated = HistoryIndexStore(tmp_path / "state")
with sqlite3.connect(migrated.path) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 38
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 42
for table in ("history_pages", "history_turn_details"):
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='codex'"
).fetchone()[0] == 0
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='claude'"
- ).fetchone()[0] == (1 if table == "history_turn_details" else 0)
+ ).fetchone()[0] == 0
assert connection.execute("SELECT COUNT(*) FROM history_image_assets").fetchone()[0] == 2
assert migrated.get_page("claude", "claude", source, before=None, limit=4) is None
assert migrated.put_page("codex", "codex", source, before=None, limit=4, page=_page("codex"))
@@ -476,8 +476,30 @@ def test_paged_file_migration_rebuilds_summaries_once_without_removing_assets(tm
assert reopened.get_page("codex", "codex", source, before=None, limit=4) is not None
+def test_v42_summary_migration_preserves_source_details_and_assets(tmp_path):
+ path = tmp_path / "source.jsonl"
+ path.write_text("{}\n")
+ source = HistorySourceFingerprint.capture(path)
+ store = HistoryIndexStore(tmp_path / "state")
+ for engine in ("claude", "codex"):
+ store.put_page(engine, engine, source, before=None, limit=4, page=_page(engine))
+ store.put_image_asset(engine, engine, source, engine, "image",
+ "thumbnail", "image/png", 1, 1, engine.encode())
+ with sqlite3.connect(store.path) as connection:
+ connection.execute("PRAGMA user_version=41")
+ migrated = HistoryIndexStore(tmp_path / "state")
+ with sqlite3.connect(migrated.path) as connection:
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 42
+ assert connection.execute("SELECT COUNT(*) FROM history_pages").fetchone()[0] == 0
+ assert connection.execute("SELECT COUNT(*) FROM history_turn_details").fetchone()[0] == 2
+ assert connection.execute("SELECT COUNT(*) FROM history_image_assets").fetchone()[0] == 2
+ assert migrated.put_page("codex", "codex", source, before=None, limit=4, page=_page("rebuilt"))
+ reopened = HistoryIndexStore(tmp_path / "state")
+ assert reopened.get_page("codex", "codex", source, before=None, limit=4) == _page("rebuilt")
+
+
@pytest.mark.parametrize("old_version", [34, 35, 36, 37])
-def test_codex_narrative_migration_preserves_other_engines_and_assets(tmp_path, old_version):
+def test_narrative_migration_preserves_assets(tmp_path, old_version):
path = tmp_path / "rollout.jsonl"
path.write_text("{}\n")
source = HistorySourceFingerprint.capture(path)
@@ -489,16 +511,38 @@ def test_codex_narrative_migration_preserves_other_engines_and_assets(tmp_path,
connection.execute(f"PRAGMA user_version={old_version}")
migrated = HistoryIndexStore(tmp_path / "state")
assert migrated.get_page("codex", "codex", source, before=None, limit=4) is None
- assert migrated.get_page("claude", "claude", source, before=None, limit=4) is not None
+ assert migrated.get_page("claude", "claude", source, before=None, limit=4) is None
with sqlite3.connect(store.path) as connection:
assert connection.execute("SELECT COUNT(*) FROM history_turn_details WHERE engine='codex'").fetchone()[0] == 0
- assert connection.execute("SELECT COUNT(*) FROM history_turn_details WHERE engine='claude'").fetchone()[0] == 1
+ assert connection.execute("SELECT COUNT(*) FROM history_turn_details WHERE engine='claude'").fetchone()[0] == 0
assert connection.execute("SELECT COUNT(*) FROM history_image_assets").fetchone()[0] == 2
migrated.put_page("codex", "codex", source, before=None, limit=4, page=_page("rebuilt"))
reopened = HistoryIndexStore(tmp_path / "state")
assert reopened.get_page("codex", "codex", source, before=None, limit=4) == _page("rebuilt")
+@pytest.mark.parametrize("old_version", [38, 39])
+def test_background_and_summary_migrations_preserve_source_assets(tmp_path, old_version):
+ path = tmp_path / "source.jsonl"
+ path.write_text("{}\n")
+ source = HistorySourceFingerprint.capture(path)
+ store = HistoryIndexStore(tmp_path / "state")
+ for engine in ("claude", "codex", "dsh"):
+ store.put_page(engine, engine, source, before=None, limit=4, page=_page(engine))
+ store.put_image_asset(engine, engine, source, engine, "image", "thumbnail", "image/png", 1, 1, b"image")
+ with sqlite3.connect(store.path) as connection:
+ connection.execute(f"PRAGMA user_version={old_version}")
+ migrated = HistoryIndexStore(tmp_path / "state")
+ assert migrated.get_page("claude", "claude", source, before=None, limit=4) is None
+ assert migrated.get_page("codex", "codex", source, before=None, limit=4) is None
+ assert migrated.get_page("dsh", "dsh", source, before=None, limit=4) == _page("dsh")
+ with sqlite3.connect(store.path) as connection:
+ assert connection.execute("SELECT COUNT(*) FROM history_image_assets").fetchone()[0] == 3
+ migrated.put_page("claude", "claude", source, before=None, limit=4, page=_page("rebuilt"))
+ assert HistoryIndexStore(tmp_path / "state").get_page(
+ "claude", "claude", source, before=None, limit=4) == _page("rebuilt")
+
+
def test_v19_migration_rebuilds_history_and_adds_agent_details(tmp_path):
source_path = tmp_path / "transcript.jsonl"
source_path.write_text("{}\n")
@@ -518,7 +562,7 @@ def test_v19_migration_rebuilds_history_and_adds_agent_details(tmp_path):
assert migrated.get_page(
"session-1", "claude", source, before=None, limit=4) is None
with sqlite3.connect(migrated.path) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 38
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 42
assert connection.execute(
"SELECT COUNT(*) FROM history_agent_details").fetchone()[0] == 0
@@ -546,7 +590,7 @@ def test_v20_migration_rebuilds_codex_and_claude_identity_projections(tmp_path):
migrated = HistoryIndexStore(state_dir)
with sqlite3.connect(migrated.path) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 38
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 42
for table in ("history_pages", "history_turn_details"):
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='claude'"
@@ -607,7 +651,7 @@ def test_v21_migration_rebuilds_claude_alias_and_codex_process_projections(
migrated = HistoryIndexStore(state_dir)
with sqlite3.connect(migrated.path) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 38
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 42
for table in ("history_pages", "history_turn_details"):
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='claude'"
@@ -664,7 +708,7 @@ def test_v22_migration_applies_codex_and_claude_projection_repairs(
migrated = HistoryIndexStore(state_dir)
with sqlite3.connect(migrated.path) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 38
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 42
for table in ("history_pages", "history_turn_details"):
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='claude'"
@@ -726,7 +770,7 @@ def test_recent_migration_applies_codex_and_claude_projection_repairs(
migrated = HistoryIndexStore(state_dir)
with sqlite3.connect(migrated.path) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 38
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 42
for table in ("history_pages", "history_turn_details"):
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='claude'"
@@ -757,7 +801,7 @@ def test_recent_migration_applies_codex_and_claude_projection_repairs(
@pytest.mark.parametrize("old_version", [25, 26, 27, 28, 29, 30, 31])
-def test_async_question_migration_preserves_assets_and_unaffected_claude_details(
+def test_async_question_migration_preserves_assets(
tmp_path, old_version,
):
source_path = tmp_path / "transcript.jsonl"
@@ -797,11 +841,11 @@ def test_async_question_migration_preserves_assets_and_unaffected_claude_details
migrated = HistoryIndexStore(state_dir)
with sqlite3.connect(migrated.path) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 38
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 42
for table in ("history_pages", "history_turn_details"):
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='claude'"
- ).fetchone()[0] == int(old_version == 31 and table == "history_turn_details")
+ ).fetchone()[0] == 0
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='codex'"
).fetchone()[0] == 0
@@ -906,7 +950,7 @@ def test_legacy_migration_rebuilds_all_derived_history_rows(
migrated = HistoryIndexStore(state_dir)
with sqlite3.connect(migrated.path) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 38
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 42
for table in (
"history_pages",
"history_turn_details",
@@ -953,7 +997,7 @@ def test_v10_migration_invalidates_changed_projection_rows(tmp_path):
migrated = HistoryIndexStore(state_dir)
with sqlite3.connect(migrated.path) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 38
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 42
for table in (
"history_pages", "history_turn_details", "history_image_assets",
):
@@ -1004,7 +1048,7 @@ def test_v11_migration_invalidates_claude_pages_and_adds_compact_index(
"claude-session", "claude", source, before=None, limit=4,
) is None
with sqlite3.connect(migrated.path) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 38
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 42
tables = {
row[0] for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
@@ -1050,7 +1094,7 @@ def test_recent_migration_invalidates_changed_projection_rows(
migrated = HistoryIndexStore(state_dir)
with sqlite3.connect(migrated.path) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 38
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 42
for table in (
"history_pages", "history_turn_details", "history_image_assets",
):
@@ -1063,7 +1107,7 @@ def test_recent_migration_invalidates_changed_projection_rows(
1 if table == "history_image_assets" else 0)
assert connection.execute(
"SELECT COUNT(*) FROM claude_compact_sources"
- ).fetchone()[0] == 1
+ ).fetchone()[0] == 0
@pytest.mark.parametrize("old_version", [15, 16])
@@ -1092,7 +1136,7 @@ def test_owner_and_interrupt_alias_migration_invalidates_both_projections(
migrated = HistoryIndexStore(state_dir)
with sqlite3.connect(migrated.path) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 38
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 42
for table in ("history_pages", "history_turn_details"):
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='claude'"
@@ -1156,7 +1200,7 @@ def test_recent_summary_migration_rebuilds_pages_but_preserves_source_assets(
migrated = HistoryIndexStore(state_dir)
with sqlite3.connect(migrated.path) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 38
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 42
assert connection.execute(
"SELECT COUNT(*) FROM history_pages"
).fetchone()[0] == 0
@@ -1523,6 +1567,59 @@ def test_materialized_turn_bounds_initial_final_text_and_advertises_detail():
assert turns[0]["detailReasons"] == ["answer_truncated"]
+@pytest.mark.parametrize("channel,include_live_detail", [
+ ("final", False), ("final", True), ("commentary", True),
+])
+def test_recovered_text_replaces_partial_prefix_before_live_tail(
+ channel, include_live_detail,
+):
+ from cc_remote.protocol import Delta, deserialize, serialize
+
+ recovered = deserialize(serialize(Delta(
+ sid="session", message_id="reply", channel=channel,
+ text="first recovered", replace=True,
+ ))).model_dump(mode="json")
+ events = [
+ {"type": "user_msg", "msg_id": "prompt", "prompt": "continue"},
+ {"type": "assistant_msg_start", "message_id": "reply", "channel": channel},
+ {"type": "delta", "message_id": "reply", "channel": channel, "text": "first "},
+ recovered,
+ recovered, # A second reconnect must remain idempotent.
+ {"type": "delta", "message_id": "reply", "channel": channel, "text": " tail"},
+ {"type": "assistant_msg_end", "message_id": "reply", "channel": channel},
+ {"type": "turn_end", "result": {
+ "subtype": "success", "duration_ms": 1, "is_error": False,
+ }},
+ ]
+ turn = materialize_history_turns(events, include_live_detail=include_live_detail)[0]
+ assert [(block["text"], block["done"]) for block in turn["blocks"]] == [
+ ("first recovered tail", True),
+ ]
+
+
+@pytest.mark.parametrize("channel", ["unknown", "final"])
+@pytest.mark.parametrize("include_live_detail", [False, True])
+def test_many_short_answers_cannot_exceed_summary_wire_block_budget(channel, include_live_detail):
+ from cc_remote.protocol import History, deserialize, serialize
+
+ events = [{"type": "user_msg", "msg_id": "prompt", "prompt": "many replies"}]
+ for index in range(70):
+ events.extend([
+ {"type": "assistant_msg_start", "message_id": f"reply-{index}", "channel": channel},
+ {"type": "delta", "message_id": f"reply-{index}", "channel": channel, "text": f"answer {index}"},
+ {"type": "assistant_msg_end", "message_id": f"reply-{index}", "channel": channel},
+ ])
+ turns = materialize_history_turns(events, include_live_detail=include_live_detail)
+ assert len(turns[0]["blocks"]) == 32
+ assert turns[0]["blocks"][-1]["text"] == "answer 69"
+ assert "answer_truncated" in turns[0]["detailReasons"]
+ assert turns[0]["detailEventCount"] >= 1
+ summary = History(session_id="session", revision="revision", detail="summary", turns=turns)
+ assert deserialize(serialize(summary)) == summary
+ # Materialization is a projection: the full source detail remains intact.
+ assert sum(event["type"] == "delta" for event in events) == 70
+
+
def test_materialized_turn_defers_images_and_bounds_large_prompt():
turns = materialize_history_turns([
{"type": "user_msg", "msg_id": "message-1",
diff --git a/tests/test_markdown_preview.py b/tests/test_markdown_preview.py
index 15a8a31a..d3742e62 100644
--- a/tests/test_markdown_preview.py
+++ b/tests/test_markdown_preview.py
@@ -36,6 +36,17 @@
from tests.test_multisession import _mk_ctx, _mk_machine
+@pytest.mark.parametrize("extension", ["mmd", "MERMAID"])
+def test_mermaid_artifact_reads_exact_source_as_text(tmp_path, extension):
+ source = "flowchart TD\n camera[双目相机] --> decoder[硬件解码]\n"
+ path = tmp_path / f"camera_navigation_pipeline.{extension}"
+ path.write_text(source, encoding="utf-8")
+ preview = machine_module.WrapperMachine._read_file_preview(str(tmp_path), path.name)
+ assert preview["format"] == "text"
+ assert preview["content"] == source
+ assert preview["truncated"] is False
+
+
@pytest.mark.parametrize("engine", ["claude", "codex"])
@pytest.mark.parametrize("foreign_owner", [False, True])
def test_code_open_reads_os_readable_files_outside_session(
diff --git a/tests/test_release_distribution.py b/tests/test_release_distribution.py
index 62deb825..5042c249 100644
--- a/tests/test_release_distribution.py
+++ b/tests/test_release_distribution.py
@@ -31,8 +31,7 @@ def test_release_workflow_materializes_web_before_python_bundle_tests():
workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text()
python_job = workflow.split("\n python:\n", 1)[1].split(
"\n web:\n", 1)[0]
- web_job = workflow.split("\n web:\n", 1)[1].split(
- "\n deploy:\n", 1)[0]
+ web_job = workflow.split("\n web:\n", 1)[1]
assert "uses: ./.github/workflows/ci.yml" in release
assert "needs: web" in python_job
@@ -175,6 +174,8 @@ def test_release_bundles_are_deterministic_and_role_scoped(
assert not any("/web/" in name for name in members)
assert f"{prefix}/requirements-wrapper.lock" in members
assert f"{prefix}/deploy/install-wrapper.sh" in members
+ assert f"{prefix}/deploy/install_claude_service.py" in members
+ assert f"{prefix}/cc_remote/claude_service/server.py" in members
assert f"{prefix}/deploy/work_registry_snapshot.py" in members
assert f"{prefix}/scripts/codex-auth-daemon-restart" in members
assert f"{prefix}/deploy/setup-vps.sh" not in members
@@ -451,14 +452,12 @@ def test_role_locks_and_release_workflow_are_versioned_inputs():
assert "npm --prefix web run build" in workflow
assert "uses: ./.github/workflows/ci.yml" in workflow
assert "pytest" in source_workflow
- assert "test:reliability" in source_workflow
- assert "test:viewer" in source_workflow
assert "actions/attest" in workflow
assert "gh release upload" in workflow
uv_version = (ROOT / "deploy" / "uv-version.txt").read_text().strip()
assert (
workflow + source_workflow
- ).count(f'version: "{uv_version}"') == 3
+ ).count(f'version: "{uv_version}"') == 2
python_version = (
ROOT / "deploy" / "python-version.txt"
).read_text().strip()
diff --git a/tests/test_timed_tasks.py b/tests/test_timed_tasks.py
new file mode 100644
index 00000000..985bd514
--- /dev/null
+++ b/tests/test_timed_tasks.py
@@ -0,0 +1,187 @@
+import asyncio
+import json
+import sqlite3
+from uuid import uuid4
+
+import pytest
+
+from cc_remote import timed_tasks as timed
+from cc_remote.protocol import ConversationTurn, SessionInfo, TimedMessage, UserMsg, deserialize, serialize
+
+
+def task(tmp_path, monkeypatch, *, count=3):
+ monkeypatch.setattr(timed.time, "time", lambda: 1000)
+ home = tmp_path / "codex"
+ home.mkdir()
+ store = timed.TimedTaskStore(tmp_path / "state")
+ sid = str(uuid4())
+ task_id = store.create(str(home), sid, "每分钟测试", "测试", 60, 60, count)
+ return store, str(home), sid, task_id
+
+
+def test_receipts_are_account_session_and_message_scoped(tmp_path, monkeypatch):
+ store, home, sid, task_id = task(tmp_path, monkeypatch)
+ delivery = store.begin_delivery(task_id)
+ store.accepted(task_id, delivery)
+ source = store.source(home, sid, [delivery], bind="native-message")
+ assert source == {"task_id": task_id, "title": "每分钟测试", "scheduled_at": 1060}
+ assert store.source(home, sid, ["native-message"]) == source
+ assert store.source(home, str(uuid4()), [delivery]) is None
+ assert store.source(home + "-other", sid, [delivery]) is None
+ # Identical text and an unrelated user message never inherit a timer badge.
+ assert store.source(home, sid, ["测试", "manual-message"]) is None
+ event = UserMsg(msg_id="native-message", prompt="测试", timed_task=TimedMessage(**source))
+ assert deserialize(serialize(event)).timed_task == event.timed_task
+ assert ConversationTurn(id="native-message", timedTask=source).timedTask.task_id == task_id
+ assert SessionInfo(session_id=sid, timed_tasks=store.public_tasks(home, sid)).state is None
+
+
+def test_completion_cancel_and_expired_lease_remove_glow_but_keep_receipt(tmp_path, monkeypatch):
+ store, home, sid, task_id = task(tmp_path, monkeypatch, count=1)
+ assert store.public_tasks(home, sid)[0]["next_message_at"] == 1060
+ assert store.active(now=1091) == {}
+ delivery = store.begin_delivery(task_id)
+ store.accepted(task_id, delivery)
+ store.accepted(task_id, delivery) # Duplicate acknowledgment is harmless.
+ assert store.get(task_id)["sent"] == 1
+ assert store.get(task_id)["state"] == "completed"
+ assert store.active() == {}
+ assert store.source(home, sid, [delivery])
+ second = store.create(home, sid, "Later", "hi", 60, 60, 1)
+ store.finish(second, "cancelled")
+ assert store.public_tasks(home, sid) == []
+ with pytest.raises(ValueError):
+ store.begin_delivery(second)
+
+
+def test_ambiguous_delivery_cannot_be_retried_and_wakeup_does_not_burst(tmp_path, monkeypatch):
+ store, home, sid, task_id = task(tmp_path, monkeypatch)
+ delivery = store.begin_delivery(task_id)
+ with pytest.raises(sqlite3.IntegrityError):
+ store.begin_delivery(task_id)
+ monkeypatch.setattr(timed.time, "time", lambda: 1600)
+ store.accepted(task_id, delivery)
+ assert store.get(task_id)["next_at"] == 1660
+
+
+def test_read_missing_store_has_no_side_effects_and_symlinks_are_rejected(tmp_path):
+ store = timed.TimedTaskStore(tmp_path / "missing")
+ assert store.active() == {}
+ assert store.source("home", "sid", ["id"], bind="native") is None
+ assert not store.path.parent.exists()
+ store.path.parent.mkdir()
+ store.path.symlink_to(tmp_path / "elsewhere")
+ with pytest.raises(ValueError):
+ store.active()
+ assert not (tmp_path / "elsewhere").exists()
+
+
+def test_worker_marks_unknown_once_without_resending(tmp_path, monkeypatch):
+ store, home, sid, task_id = task(tmp_path, monkeypatch)
+ monkeypatch.setattr(timed.time, "time", lambda: 1061)
+ calls = []
+ async def rejected(task, delivery, on_accepted):
+ calls.append(delivery)
+ raise ConnectionResetError()
+ monkeypatch.setattr(timed, "deliver", rejected)
+ asyncio.run(timed.run_task(store, task_id))
+ asyncio.run(timed.run_task(store, task_id))
+ assert len(calls) == 1
+ assert store.get(task_id)["state"] == "unknown"
+ assert store.active() == {}
+
+
+def test_queue_uses_existing_thread_exact_client_id_and_accepts_before_start(tmp_path, monkeypatch):
+ import os
+ import stat
+ from types import SimpleNamespace
+ store, home, sid, task_id = task(tmp_path, monkeypatch, count=1)
+ socket_path = tmp_path / "codex" / "app-server-control" / "app-server-control.sock"
+ original_lstat = timed.Path.lstat
+ monkeypatch.setattr(timed.Path, "lstat", lambda path, *args, **kwargs:
+ SimpleNamespace(st_mode=stat.S_IFSOCK, st_uid=os.getuid())
+ if path == socket_path else original_lstat(path, *args, **kwargs))
+ delivery = store.begin_delivery(task_id)
+ methods = []
+ class WS:
+ async def __aenter__(self): return self
+ async def __aexit__(self, *args): pass
+ async def send(self, raw):
+ self.message = json.loads(raw)
+ methods.append(self.message["method"])
+ async def recv(self):
+ msg = self.message
+ if msg["method"] == "thread/queue/start":
+ assert store.get(task_id)["state"] == "completed"
+ raise ConnectionResetError()
+ if msg["method"] == "thread/queue/add":
+ assert msg["params"]["threadId"] == sid
+ assert msg["params"]["clientUserMessageId"] == delivery
+ result = {"queuedSubmission": {"id": "queue-id", "clientUserMessageId": delivery}}
+ else:
+ result = {"thread": {"status": {"type": "idle"}}}
+ return json.dumps({"id": msg["id"], "result": result})
+ monkeypatch.setattr(timed, "unix_connect", lambda *a, **kw: WS())
+ asyncio.run(timed.deliver(store.get(task_id), delivery, lambda: store.accepted(task_id, delivery)))
+ assert methods == ["initialize", "initialized", "thread/read", "thread/queue/add",
+ "thread/read", "thread/queue/start"]
+ assert store.get(task_id)["sent"] == 1
+
+
+def test_history_overlay_uses_receipts_for_summary_and_full_pages(tmp_path, monkeypatch):
+ from types import SimpleNamespace
+ from unittest.mock import AsyncMock
+ from cc_remote.protocol import History
+ from cc_remote.wrapper.machine import WrapperMachine
+ store, home, sid, task_id = task(tmp_path, monkeypatch)
+ delivery = store.begin_delivery(task_id)
+ store.accepted(task_id, delivery)
+ machine = object.__new__(WrapperMachine)
+ machine._timed_tasks = store
+ machine._ctx_by_sid = lambda sid: None
+ machine._codex_target = lambda sid: (SimpleNamespace(home=home), sid)
+ machine._build_history_source = AsyncMock(return_value=History(
+ session_id=sid, revision="r", detail="summary",
+ turns=[ConversationTurn(id="native", clientMsgId=delivery, prompt="测试"),
+ ConversationTurn(id="manual", prompt="测试")],
+ events=[{"type": "user_msg", "msg_id": "native", "client_msg_id": delivery, "prompt": "测试"}],
+ ))
+ history = asyncio.run(machine._build_history(sid, detail="summary"))
+ assert history.turns[0].timedTask.task_id == task_id
+ assert history.turns[1].timedTask is None
+ assert history.events[0]["timed_task"]["task_id"] == task_id
+ assert machine._build_history_source.await_count == 1
+
+
+def test_official_history_request_preserves_timed_message_receipts(tmp_path, monkeypatch):
+ from types import SimpleNamespace
+ from unittest.mock import AsyncMock
+ from cc_remote.protocol import History
+ from cc_remote.wrapper.machine import WrapperMachine
+
+ store, home, sid, task_id = task(tmp_path, monkeypatch)
+ delivery = store.begin_delivery(task_id)
+ store.accepted(task_id, delivery)
+ machine = object.__new__(WrapperMachine)
+ machine._timed_tasks = store
+ machine._ctx_by_sid = lambda sid: None
+ machine._codex_target = lambda sid: (SimpleNamespace(home=home), sid)
+ machine._watch_session = lambda sid: None
+ machine._watch = {sid: {"engine": "codex"}}
+ machine._history_revision = lambda sid: "r"
+ machine._history_continuity_revisions = {}
+ machine._codex_rollout_history_active = lambda sid: False
+ machine._codex_terminal_snapshot = AsyncMock(return_value=[])
+ machine._build_official_codex_history = AsyncMock(return_value=History(
+ session_id=sid, revision="r", detail="summary",
+ turns=[ConversationTurn(id="native", clientMsgId=delivery, prompt="测试"),
+ ConversationTurn(id="manual", prompt="测试")],
+ ))
+ machine._build_history_source = AsyncMock(side_effect=AssertionError("Must use official history"))
+ history = asyncio.run(machine._build_requested_history(
+ sid, before=None, limit=4, cwd=None, detail="summary"))
+ assert history.turns[0].timedTask is not None
+ assert history.turns[0].timedTask.task_id == task_id
+ assert history.turns[1].timedTask is None
+ machine._build_official_codex_history.assert_awaited_once()
+ machine._build_history_source.assert_not_awaited()
diff --git a/tests/test_tui_local.py b/tests/test_tui_local.py
index 03c6f01a..acfb6523 100644
--- a/tests/test_tui_local.py
+++ b/tests/test_tui_local.py
@@ -107,6 +107,7 @@ def test_local_secret_is_not_reused_for_other_targets(target):
def test_unavailable_service_does_not_read_process_environment(monkeypatch):
+ monkeypatch.setattr(tui_local.sys, "platform", "linux")
monkeypatch.setattr(tui_local.subprocess, "run", lambda *a, **k: SimpleNamespace(
stdout="MainPID=0\nActiveState=inactive\n"))
monkeypatch.setattr(tui_local.os, "open", lambda *a, **k: pytest.fail("opened proc"))
@@ -114,6 +115,7 @@ def test_unavailable_service_does_not_read_process_environment(monkeypatch):
def test_different_user_service_is_not_read(monkeypatch):
+ monkeypatch.setattr(tui_local.sys, "platform", "linux")
monkeypatch.setattr(tui_local.subprocess, "run", lambda *a, **k: SimpleNamespace(
stdout="MainPID=123\nActiveState=active\n"))
opened = []
diff --git a/tests/test_tui_projection.py b/tests/test_tui_projection.py
index 9f7f0b98..5a10cb7a 100644
--- a/tests/test_tui_projection.py
+++ b/tests/test_tui_projection.py
@@ -18,6 +18,31 @@ def test_projection_folds_completed_tools_without_losing_payload():
assert "echo hello" in view.render()[0]
+@pytest.mark.parametrize("channel", ["final", "commentary", "thinking"])
+def test_recovery_replaces_text_without_duplicate_prefixes(channel):
+ view = SessionView(active_turn="turn")
+ delta = dict(type="delta", message_id="answer", turn_id="turn",
+ channel=channel)
+ view.event(dict(delta, text="old prefix"))
+ view.event(dict(delta, message_id="other", text="unrelated message"))
+
+ for _ in range(2):
+ view.event(dict(delta, text="recovered answer", replace=True))
+ assert next(b for b in view.blocks if b.id == "answer").text == (
+ "recovered answer"
+ )
+
+ view.event(dict(delta, text=" and live tail"))
+ assert next(b for b in view.blocks if b.id == "answer").text == (
+ "recovered answer and live tail"
+ )
+ assert next(b for b in view.blocks if b.id == "other").text == (
+ "unrelated message"
+ )
+ view.event(dict(delta, text="", replace=True))
+ assert next(b for b in view.blocks if b.id == "answer").text == ""
+
+
def test_rekey_preserves_local_draft_and_read_position():
state = WorkspaceState()
view = state.view("temporary")
diff --git a/tests/test_turn_usage.py b/tests/test_turn_usage.py
new file mode 100644
index 00000000..e4618d8c
--- /dev/null
+++ b/tests/test_turn_usage.py
@@ -0,0 +1,136 @@
+"""Native usage accounting, ownership and reconnect regressions (no model calls)."""
+import asyncio
+
+import pytest
+from claude_agent_sdk.types import AssistantMessage, ResultMessage, StreamEvent
+from pydantic import ValidationError
+
+from cc_remote.protocol import TokenUsage, TurnUsage, deserialize, serialize
+from cc_remote.wrapper.codex_handle import CodexHandle
+from cc_remote.wrapper.codex_stream import CodexStreamTranslator
+from cc_remote.wrapper.ringbuffer import RingBuffer
+from cc_remote.wrapper.stream import StreamTranslator
+from cc_remote.wrapper.token_usage import CodexUsageTracker, native_usage
+
+
+def stream(event, parent=None):
+ return StreamEvent(uuid="event-id", session_id="session", event=event,
+ parent_tool_use_id=parent)
+
+
+def start(mid, tokens=10):
+ return stream({"type": "message_start", "message": {"id": mid, "usage": {
+ "input_tokens": tokens, "output_tokens": 1,
+ "cache_read_input_tokens": 100, "cache_creation_input_tokens": 20,
+ }}})
+
+
+def test_claude_usage_is_cumulative_per_response_not_per_block_or_replay():
+ tr = StreamTranslator(8000, turn_id="user-a")
+ first = tr.feed(start("m1"))[0]
+ assert first.usage.input_tokens == 130
+ assert first.usage.output_tokens is None # SDK message-start placeholder
+ delta = stream({"type": "message_delta", "usage": {"output_tokens": 40}})
+ assert tr.feed(delta)[0].usage.output_tokens == 40
+ assert tr.feed(delta) == []
+ assert tr.feed(AssistantMessage(content=[], model="test", message_id="m1",
+ usage={"input_tokens": 10, "output_tokens": 1})) == []
+ second = tr.feed(start("m2", 15))[0]
+ assert second.usage.input_tokens == 265
+ assert second.usage.cache_read_tokens == 200
+ assert tr.feed(delta)[0].usage.output_tokens == 80
+ assert tr.feed(stream({"type": "message_delta", "usage": {"output_tokens": 999}},
+ parent="subagent")) == []
+ tr.rebind_turn("user-b")
+ late = tr.feed(stream({"type": "message_delta", "usage": {"output_tokens": 50}}))[0]
+ assert late.turn_id == "user-a"
+ assert late.usage.output_tokens == 90
+ assert tr.feed(start("m3"))[0].turn_id == "user-b"
+ assert tr.feed(delta)[0].usage.output_tokens == 40
+
+
+def test_claude_result_supplies_usage_when_partial_usage_is_unavailable():
+ tr = StreamTranslator(8000, turn_id="user")
+ events = tr.feed(ResultMessage(subtype="success", duration_ms=12,
+ duration_api_ms=10, is_error=False, num_turns=1, session_id="session",
+ usage={"input_tokens": 50, "output_tokens": 75, "cache_read_input_tokens": 100}))
+ usage = next(event for event in events if isinstance(event, TurnUsage))
+ assert usage.usage.input_tokens == 150
+ assert usage.usage.output_tokens == 75
+ assert events[-1].type == "turn_end"
+
+
+def codex_sample(turn, inputs, outputs, *, last_inputs=20, last_outputs=10, cached=10):
+ return {"method": "thread/tokenUsage/updated", "params": {
+ "threadId": "thread", "turnId": turn, "tokenUsage": {
+ "total": {"inputTokens": inputs, "outputTokens": outputs, "cachedInputTokens": cached},
+ "last": {"inputTokens": last_inputs, "outputTokens": last_outputs, "cachedInputTokens": 10},
+ }}}
+
+
+def test_codex_totals_do_not_recount_duplicate_or_identical_requests():
+ tracker = CodexUsageTracker()
+ assert tracker.feed(codex_sample("old", 1000, 200)).usage.input_tokens == 20
+ tracker.feed({"method": "turn/started", "params": {"turn": {"id": "new"}}})
+ update = codex_sample("new", 1020, 210, cached=20)
+ first = tracker.feed(update)
+ assert first.usage == TokenUsage(input_tokens=20, output_tokens=10, cache_read_tokens=10)
+ assert tracker.feed(update) is None
+ assert tracker.feed(codex_sample("old", 1000, 200)) is None
+ second = tracker.feed(codex_sample("new", 1040, 220, cached=30))
+ assert second.usage.input_tokens == 40
+ assert second.usage.output_tokens == 20
+ tracker.feed({"method": "thread/compacted", "params": {}})
+ reset = tracker.feed(codex_sample("new", 20, 10))
+ assert reset.usage.input_tokens == 60
+ assert tracker.feed(codex_sample("new", 20, 10)) is None
+
+
+def test_codex_usage_reaches_managed_and_spontaneous_readers_without_foreign_updates():
+ class Cfg:
+ tool_result_max = 8000
+ cc_cwd = "/tmp"
+ turn_reader_queue_cap = 32
+
+ async def run():
+ handle = CodexHandle(Cfg())
+ handle.thread_id, handle.turn_id, handle.turn_active = "thread", "turn", True
+ handle._turn_q = asyncio.Queue()
+ foreign = codex_sample("turn", 1000, 200)
+ foreign["params"]["threadId"] = "other-thread"
+ await handle._dispatch(foreign)
+ assert handle._turn_q.empty()
+ await handle._dispatch(codex_sample("turn", 1000, 200))
+ native = handle._turn_q.get_nowait()
+ event = CodexStreamTranslator(8000).feed(native)[0]
+ assert event.turn_id == "turn"
+ assert event.usage.output_tokens == 10
+ # A duplicate can remain in the queue, but carries no new usage reading.
+ await handle._dispatch(codex_sample("turn", 1000, 200))
+ assert CodexStreamTranslator(8000).feed(handle._turn_q.get_nowait()) == []
+
+ handle._turn_q = None
+ handle._spontaneous_turn_id = "turn"
+ captured = []
+ handle._queue_spontaneous_notification = lambda message, size: captured.append(message)
+ await handle._dispatch(codex_sample("turn", 1020, 210, cached=20))
+ assert CodexStreamTranslator(8000).feed(captured[0])[0].usage.output_tokens == 20
+
+ asyncio.run(run())
+
+
+def test_usage_snapshot_survives_evicted_replay_tail_and_protocol_rejects_bad_counts():
+ ring = RingBuffer(max_events=1, max_bytes=1024)
+ for i in range(10):
+ event = TurnUsage(turn_id=f"turn-{i}", usage=TokenUsage(input_tokens=i), seq=i+1)
+ assert deserialize(serialize(event)) == event
+ ring.append(event)
+ snapshot = ring.replay_from(None, cc_session_id="session", state="running")[0]
+ assert len(snapshot.turn_usage) == 8
+ assert snapshot.turn_usage[-1].usage.input_tokens == 9
+ caught_up = ring.replay_from(10, cc_session_id="session", state="running")[-1]
+ assert caught_up.turn_usage[-1].usage.input_tokens == 9
+ for invalid in (-1, True, 1.5, float("inf"), 2**53):
+ with pytest.raises(ValidationError):
+ TokenUsage(input_tokens=invalid)
+ assert native_usage({"inputTokens": invalid}, "codex") is None
diff --git a/tests/test_workspaces.py b/tests/test_workspaces.py
index e2732757..e5cb3f90 100644
--- a/tests/test_workspaces.py
+++ b/tests/test_workspaces.py
@@ -543,6 +543,8 @@ def test_artifacts_list_only_user_deliverables_inside_private_workspace(self):
upload.parent.mkdir()
upload.write_text("user input", encoding="utf-8")
(workspace / "report.md").write_text("# result", encoding="utf-8")
+ for name in ("camera.mmd", "navigation.MERMAID"):
+ (workspace / name).write_text("flowchart TD\n A --> B\n", encoding="utf-8")
slides = workspace / "output" / "deck.pptx"
slides.parent.mkdir()
slides.write_bytes(b"presentation")
@@ -556,11 +558,14 @@ def test_artifacts_list_only_user_deliverables_inside_private_workspace(self):
artifacts = self.store.artifacts("session-1")
self.assertEqual({item["path"] for item in artifacts}, {
- "report.md", "output/deck.pptx",
+ "report.md", "output/deck.pptx", "camera.mmd", "navigation.MERMAID",
})
by_path = {item["path"]: item for item in artifacts}
self.assertTrue(by_path["report.md"]["previewable"])
self.assertEqual(by_path["report.md"]["kind"], "document")
+ for name in ("camera.mmd", "navigation.MERMAID"):
+ self.assertTrue(by_path[name]["previewable"])
+ self.assertEqual(by_path[name]["kind"], "document")
self.assertTrue(by_path["output/deck.pptx"]["previewable"])
self.assertEqual(by_path["output/deck.pptx"]["kind"], "presentation")
diff --git a/web/.gitignore b/web/.gitignore
index a547bf36..b997c93b 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
+dist-theme-preview
*.local
# Editor directories and files
diff --git a/web/package.json b/web/package.json
index 796a273e..ee5c106a 100644
--- a/web/package.json
+++ b/web/package.json
@@ -9,6 +9,7 @@
"dev": "vite",
"build": "tsc -b && vite build && npm run build:viewer-runner && node scripts/check-bundle-budget.mjs",
"build:viewer-runner": "vite build --config vite.viewer-runner.config.ts",
+ "build:theme-preview": "vite build --config vite.theme-preview.config.ts",
"lint": "oxlint",
"test:compile": "tsc -p tsconfig.tests.json",
"test:outbox": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/outbox.test.js",
@@ -19,7 +20,7 @@
"test:jitter": "playwright test -c playwright.jitter.config.ts --project=webkit",
"test:diff": "npm run test:compile --silent && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js",
"test:preview": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js",
- "test:reliability": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/outbox.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-requests.test.js && node node_modules/.tmp/cc-remote-tests/tests/completion-repair.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-browse.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-page-cache.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-live-order.test.js && node node_modules/.tmp/cc-remote-tests/tests/steer-boundary.test.js && node node_modules/.tmp/cc-remote-tests/tests/process-detail.test.js && node node_modules/.tmp/cc-remote-tests/tests/turn-detail-reset.test.js && node node_modules/.tmp/cc-remote-tests/tests/codex-terminal-fences.test.js && node node_modules/.tmp/cc-remote-tests/tests/pending-question-recovery.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-background-process.test.js && node node_modules/.tmp/cc-remote-tests/tests/reliability.test.js && node node_modules/.tmp/cc-remote-tests/tests/image-import.test.js && node node_modules/.tmp/cc-remote-tests/tests/mobile-viewport.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-profiles.test.js && node node_modules/.tmp/cc-remote-tests/tests/auto-compact.test.js && node node_modules/.tmp/cc-remote-tests/tests/surface-restoration.test.js && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js && node node_modules/.tmp/cc-remote-tests/tests/goal-command.test.js && node node_modules/.tmp/cc-remote-tests/tests/plan-progress.test.js && node node_modules/.tmp/cc-remote-tests/tests/status-capabilities.test.js && node node_modules/.tmp/cc-remote-tests/tests/usage-activity.test.js && node node_modules/.tmp/cc-remote-tests/tests/notices-rate-limits.test.js && node node_modules/.tmp/cc-remote-tests/tests/session-worktree.test.js && node node_modules/.tmp/cc-remote-tests/tests/scroll-follow.test.js && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js && node node_modules/.tmp/cc-remote-tests/tests/work-fast.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-models.test.js",
+ "test:reliability": "npm run test:compile --silent && node node_modules/.tmp/cc-remote-tests/tests/tool-details.test.js && node node_modules/.tmp/cc-remote-tests/tests/turn-usage.test.js && node node_modules/.tmp/cc-remote-tests/tests/themes.test.js && node node_modules/.tmp/cc-remote-tests/tests/timed-tasks.test.js && node node_modules/.tmp/cc-remote-tests/tests/outbox.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-requests.test.js && node node_modules/.tmp/cc-remote-tests/tests/completion-repair.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-browse.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-page-cache.test.js && node node_modules/.tmp/cc-remote-tests/tests/history-live-order.test.js && node node_modules/.tmp/cc-remote-tests/tests/steer-boundary.test.js && node node_modules/.tmp/cc-remote-tests/tests/process-detail.test.js && node node_modules/.tmp/cc-remote-tests/tests/turn-detail-reset.test.js && node node_modules/.tmp/cc-remote-tests/tests/codex-terminal-fences.test.js && node node_modules/.tmp/cc-remote-tests/tests/pending-question-recovery.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-background-process.test.js && node node_modules/.tmp/cc-remote-tests/tests/reliability.test.js && node node_modules/.tmp/cc-remote-tests/tests/image-import.test.js && node node_modules/.tmp/cc-remote-tests/tests/mobile-viewport.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-profiles.test.js && node node_modules/.tmp/cc-remote-tests/tests/auto-compact.test.js && node node_modules/.tmp/cc-remote-tests/tests/surface-restoration.test.js && node --expose-gc node_modules/.tmp/cc-remote-tests/tests/diff-performance.test.js && node node_modules/.tmp/cc-remote-tests/tests/goal-command.test.js && node node_modules/.tmp/cc-remote-tests/tests/plan-progress.test.js && node node_modules/.tmp/cc-remote-tests/tests/status-capabilities.test.js && node node_modules/.tmp/cc-remote-tests/tests/usage-activity.test.js && node node_modules/.tmp/cc-remote-tests/tests/notices-rate-limits.test.js && node node_modules/.tmp/cc-remote-tests/tests/session-worktree.test.js && node node_modules/.tmp/cc-remote-tests/tests/scroll-follow.test.js && node node_modules/.tmp/cc-remote-tests/tests/markdown-preview.test.js && node node_modules/.tmp/cc-remote-tests/tests/work-fast.test.js && node node_modules/.tmp/cc-remote-tests/tests/claude-models.test.js",
"preview": "vite preview"
},
"dependencies": {
diff --git a/web/playwright.config.ts b/web/playwright.config.ts
index f3fceb7c..43fc82f4 100644
--- a/web/playwright.config.ts
+++ b/web/playwright.config.ts
@@ -25,7 +25,7 @@ const WEBKIT_GENERAL_EXCLUSIONS = [
export default defineConfig({
testDir: "./tests",
- testMatch: "history-browser.spec.ts",
+ testMatch: ["history-browser.spec.ts", "background-tasks.spec.ts", "themes.spec.ts"],
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 2 : 0,
diff --git a/web/public/cc-remote-build.json b/web/public/cc-remote-build.json
index e8899076..36cf6839 100644
--- a/web/public/cc-remote-build.json
+++ b/web/public/cc-remote-build.json
@@ -1,4 +1,4 @@
{
"version": "3.0.0",
- "protocol": 67
+ "protocol": 71
}
diff --git a/web/scripts/check-bundle-budget.mjs b/web/scripts/check-bundle-budget.mjs
index d895944a..fbdf438e 100644
--- a/web/scripts/check-bundle-budget.mjs
+++ b/web/scripts/check-bundle-budget.mjs
@@ -29,8 +29,10 @@ const DIST = resolve(import.meta.dirname, "../dist");
// The card has no new dependencies; gzip and initial request caps stay unchanged.
// Quota failure copy and validated native retry dates add <1 KiB at startup.
// Keep entry, compressed-size, and request-count caps unchanged.
+// Native compaction animation and exact boundary replacement add <1 KiB of
+// startup JS. Keep entry, compressed-size and request-count limits unchanged.
const MAX_ENTRY_BYTES = 537 * 1024;
-const MAX_INITIAL_BYTES = 937 * 1024;
+const MAX_INITIAL_BYTES = 938 * 1024;
const MAX_INITIAL_GZIP_BYTES = 280 * 1024;
const MAX_INITIAL_JS_FILES = 4;
diff --git a/web/src/App.css b/web/src/App.css
index 76ca64a1..3ab4e2b9 100644
--- a/web/src/App.css
+++ b/web/src/App.css
@@ -2,7 +2,7 @@
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
- color: var(--accent);
+ color:var(--accent-text,var(--accent));
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
@@ -201,7 +201,7 @@
.goal-chip-loading { cursor:default; }
.goal-loading .goal-chip-dot { animation:pulse 1.2s ease-in-out infinite; }
.goal-chip-ring { --goal-progress:0deg; position:relative; width:21px; height:21px;
- display:grid; place-items:center; flex:none; border-radius:50%; color:var(--accent);
+ display:grid; place-items:center; flex:none; border-radius:50%; color:var(--accent-text,var(--accent));
background:conic-gradient(currentColor var(--goal-progress),var(--border-strong) 0); }
.goal-chip-ring::before { content:""; position:absolute; inset:3px; border-radius:50%; background:var(--surface); }
.goal-chip-ring svg { position:relative; }
@@ -213,10 +213,10 @@
.goal-chip-dot-paused,.goal-chip-dot-blocked { background:var(--warn); box-shadow:0 0 0 4px var(--warn-weak); }
.goal-chip-dot-complete { background:var(--ok); box-shadow:0 0 0 4px var(--ok-weak); }
.goal-chip-dot-usageLimited,.goal-chip-dot-budgetLimited { background:var(--danger); box-shadow:0 0 0 4px var(--danger-weak); }
-.goal-chip-label { flex:none; color:var(--accent-ink); font-size:11px; font-weight:750; letter-spacing:.02em; }
+.goal-chip-label { flex:none; color:var(--accent-ink); font-size:11px; font-weight:var(--font-weight-750); letter-spacing:.02em; }
.goal-chip-objective { min-width:0; max-width:min(380px,44vw); overflow:hidden; text-overflow:ellipsis;
- white-space:nowrap; font-size:12px; font-weight:600; }
-.goal-chip-status { flex:none; color:var(--dim); font-size:10px; font-weight:650; }
+ white-space:nowrap; font-size:12px; font-weight:var(--font-weight-600); }
+.goal-chip-status { flex:none; color:var(--dim); font-size:10px; font-weight:var(--font-weight-650); }
.goal-chip-status-paused,.goal-chip-status-blocked { color:var(--warn); }
.goal-chip-status-complete { color:var(--ok); }
.goal-chip-status-usageLimited,.goal-chip-status-budgetLimited { color:var(--danger); }
@@ -227,7 +227,7 @@
.plan-chip-ring.complete { color:var(--ok); }
.plan-chip-ring.failed { color:var(--danger); }
.plan-chip-ring.stale { color:var(--faint); }
-.goal-status { display:inline-flex; align-items:center; width:max-content; border-radius:999px; padding:2px 7px; font-size:10px; line-height:1.4; font-weight:700; color:var(--accent-ink); background:var(--accent-weak); }
+.goal-status { display:inline-flex; align-items:center; width:max-content; border-radius:999px; padding:2px 7px; font-size:10px; line-height:1.4; font-weight:var(--font-weight-700); color:var(--accent-ink); background:var(--accent-weak); }
.goal-status-paused,.goal-status-blocked { color:var(--warn); background:var(--warn-weak); }
.goal-status-complete { color:var(--ok); background:var(--ok-weak); }
.goal-status-usageLimited,.goal-status-budgetLimited { color:var(--danger); background:var(--danger-weak); }
@@ -236,7 +236,7 @@
.rollback-head { display:flex; align-items:center; gap:11px; padding:15px 18px 12px;
border-bottom:1px solid var(--divider); }
.rollback-head-icon { width:38px; height:38px; display:grid; place-items:center;
- border-radius:12px; color:#fff; background:var(--accent); }
+ border-radius:12px; color:var(--on-accent,#fff); background:var(--accent); }
.rollback-head > span:nth-child(2) { min-width:0; flex:1; display:flex; flex-direction:column; }
.rollback-head b { font-size:15px; }
.rollback-head small { color:var(--faint); font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
@@ -248,7 +248,7 @@
border:1px solid var(--border); border-radius:12px; background:var(--surface); }
.rollback-options > button:hover { border-color:var(--accent-line); background:var(--accent-weak); }
.rollback-options > button > span:first-child { width:34px; height:34px; display:grid;
- place-items:center; border-radius:10px; color:var(--accent); background:var(--raised); }
+ place-items:center; border-radius:10px; color:var(--accent-text,var(--accent)); background:var(--raised); }
.rollback-options > button > span:nth-child(2) { min-width:0; display:flex; flex-direction:column; gap:2px; }
.rollback-options b { font-size:13px; }
.rollback-options small { color:var(--dim); font-size:11px; line-height:1.45; }
@@ -279,12 +279,12 @@
}
.qa-text-answer { display: flex; gap: 8px; margin-top: 12px; }
.qa-text-answer input { flex: 1; min-width: 0; border: 1px solid var(--border); border-radius: 9px; padding: 10px; background: var(--surface, #fff); color: inherit; }
-.qa-text-answer button { border: 0; border-radius: 9px; padding: 0 14px; background: var(--accent, #5b6ee1); color: white; }
+.qa-text-answer button { border: 0; border-radius: 9px; padding: 0 14px; background: var(--accent, #5b6ee1); color:var(--on-accent,#fff); }
.qa-text-answer button:disabled { opacity: .45; }
.status-sheet { padding:0; }
.status-sheet-head { display:flex; align-items:center; gap:11px; padding:15px 18px 12px; border-bottom:1px solid var(--divider); flex:none; }
-.status-sheet-icon { width:38px; height:38px; display:grid; place-items:center; border-radius:12px; background:var(--accent); color:#fff; }
+.status-sheet-icon { width:38px; height:38px; display:grid; place-items:center; border-radius:12px; background:var(--accent); color:var(--on-accent,#fff); }
.status-sheet-head > span:nth-child(2) { min-width:0; flex:1; display:flex; flex-direction:column; }
.status-sheet-head b { font-size:15px; }
.status-sheet-head small { color:var(--faint); font-size:11px; }
@@ -300,7 +300,7 @@
.status-grid { display:grid; grid-template-columns:1fr 1fr; gap:1px 18px; }
.status-row { min-width:0; display:flex; justify-content:space-between; align-items:baseline; gap:10px; padding:6px 0; border-bottom:1px dashed color-mix(in srgb,var(--divider) 72%,transparent); }
.status-row > span { flex:none; color:var(--dim); font-size:11.5px; }
-.status-row > b { min-width:0; overflow:hidden; text-overflow:ellipsis; text-align:right; color:var(--text); font-size:11.5px; font-weight:650; }
+.status-row > b { min-width:0; overflow:hidden; text-overflow:ellipsis; text-align:right; color:var(--text); font-size:11.5px; font-weight:var(--font-weight-650); }
.status-row > b.mono { font-family:var(--mono); font-size:10.5px; }
.status-capability-note { margin-top:9px; padding:8px 10px; border:1px solid var(--border); border-radius:9px; color:var(--dim); background:var(--raised); font-size:10.5px; line-height:1.5; }
.status-thread-state { display:inline-flex; align-items:center; padding:2px 7px; border-radius:999px; background:var(--raised); }
@@ -335,7 +335,7 @@
.status-reset-credit > span { min-width:0; flex:1; display:flex; flex-direction:column; gap:2px; }
.status-reset-credit b { font-size:11.5px; }
.status-reset-credit small { color:var(--dim); font-size:9.5px; line-height:1.4; overflow-wrap:anywhere; }
-.status-reset-credit button,.status-reset-next { flex:none; padding:6px 10px; border-radius:8px; color:var(--accent-ink); background:var(--accent-weak); font-size:10.5px; font-weight:650; }
+.status-reset-credit button,.status-reset-next { flex:none; padding:6px 10px; border-radius:8px; color:var(--accent-ink); background:var(--accent-weak); font-size:10.5px; font-weight:var(--font-weight-650); }
.status-reset-credit button:hover,.status-reset-next:hover { filter:brightness(1.08); }
.status-reset-credit button:disabled,.status-reset-next:disabled { opacity:.45; cursor:default; filter:none; }
.status-reset-credit em { flex:none; color:var(--faint); font-size:10px; font-style:normal; }
@@ -359,11 +359,11 @@
53-week contribution calendar, using cc-remote's Codex accent tokens. */
.usage-activity-sheet { padding:0; max-height:min(88dvh,calc(var(--app-height,100dvh) - 12px)); }
.usage-activity-head { display:flex; align-items:center; gap:11px; padding:15px 18px 12px; border-bottom:1px solid var(--divider); flex:none; }
-.usage-activity-icon { width:38px; height:38px; display:grid; place-items:center; flex:none; border-radius:11px; color:#fff; background:var(--accent); box-shadow:0 7px 20px -13px var(--accent); }
+.usage-activity-icon { width:38px; height:38px; display:grid; place-items:center; flex:none; border-radius:11px; color:var(--on-accent,#fff); background:var(--accent); box-shadow:0 7px 20px -13px var(--accent); }
.usage-activity-head > span:nth-child(2) { min-width:0; flex:1; display:flex; flex-direction:column; }
.usage-activity-head b { font-size:15px; }
.usage-activity-head small { overflow:hidden; color:var(--faint); font-size:11px; text-overflow:ellipsis; white-space:nowrap; }
-.usage-activity-head > em { padding:3px 8px; border:1px solid var(--accent-line); border-radius:999px; color:var(--accent-ink); background:var(--accent-weak); font:600 10px/1.4 var(--mono); text-transform:capitalize; }
+.usage-activity-head > em { padding:3px 8px; border:1px solid var(--accent-line); border-radius:999px; color:var(--accent-ink); background:var(--accent-weak); font:var(--font-weight-600) 10px/1.4 var(--mono); text-transform:capitalize; }
.usage-activity-refresh,.usage-activity-close { min-height:34px; display:inline-flex; align-items:center; justify-content:center; gap:6px; border-radius:9px; color:var(--dim); }
.usage-activity-refresh { padding:0 9px; border:1px solid var(--border); font-size:11px; }
.usage-activity-close { width:34px; }
@@ -373,14 +373,14 @@
.usage-activity-stats { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); overflow:hidden; margin-bottom:22px; border:1px solid var(--border); border-radius:13px; background:color-mix(in srgb,var(--surface) 82%,var(--raised)); box-shadow:var(--shadow-sm); }
.usage-activity-stats > div { min-width:0; display:flex; flex-direction:column; align-items:center; gap:2px; padding:13px 8px 12px; position:relative; text-align:center; }
.usage-activity-stats > div + div::before { position:absolute; top:13px; bottom:13px; left:0; width:1px; background:var(--divider); content:""; }
-.usage-activity-stats b { max-width:100%; overflow:hidden; font:650 13px/1.35 var(--mono); text-overflow:ellipsis; white-space:nowrap; }
+.usage-activity-stats b { max-width:100%; overflow:hidden; font:var(--font-weight-650) 13px/1.35 var(--mono); text-overflow:ellipsis; white-space:nowrap; }
.usage-activity-stats span { color:var(--dim); font-size:10.5px; white-space:nowrap; }
.usage-activity-panel { padding:2px 0 0; }
.usage-activity-panel > header { display:flex; align-items:flex-end; justify-content:space-between; gap:12px; margin-bottom:10px; }
.usage-activity-panel > header > span { display:flex; flex-direction:column; }
.usage-activity-panel > header b { font-size:13px; }
.usage-activity-panel > header small { color:var(--faint); font-size:10.5px; }
-.usage-activity-panel > header em { padding:4px 9px; border-radius:8px; color:var(--accent-ink); background:var(--accent-weak); font-size:10.5px; font-style:normal; font-weight:650; }
+.usage-activity-panel > header em { padding:4px 9px; border-radius:8px; color:var(--accent-ink); background:var(--accent-weak); font-size:10.5px; font-style:normal; font-weight:var(--font-weight-650); }
.usage-activity-viewport { max-width:100%; overflow-x:auto; padding:6px 1px 5px; overscroll-behavior-inline:contain; scrollbar-color:var(--border-strong) transparent; scrollbar-width:thin; }
.usage-activity-calendar { --usage-tile-size:11px; --usage-tile-gap:3px; width:max-content; min-width:100%; }
.usage-activity-grid { display:grid; width:max-content; grid-auto-flow:column; grid-template-rows:repeat(7,var(--usage-tile-size)); grid-auto-columns:var(--usage-tile-size); gap:var(--usage-tile-gap); }
@@ -402,7 +402,7 @@
.usage-activity-legend i { width:10px; height:10px; display:block; }
.usage-activity-empty { min-height:150px; display:grid; place-items:center; border:1px dashed var(--border-strong); border-radius:12px; color:var(--dim); background:var(--bg); font-size:11.5px; }
.usage-activity-state { min-height:300px; display:flex; align-items:center; justify-content:center; flex-direction:column; gap:7px; color:var(--dim); text-align:center; }
-.usage-activity-state svg { color:var(--accent); }
+.usage-activity-state svg { color:var(--accent-text,var(--accent)); }
.usage-activity-state b { color:var(--text); font-size:13px; }
.usage-activity-state span { max-width:340px; font-size:11px; line-height:1.5; }
.usage-activity-spinner { width:18px; height:18px; border:2px solid var(--border-strong); border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; }
@@ -427,7 +427,7 @@
.fork-worktree-sheet { padding:0; }
.fork-worktree-sheet > form { min-height:0; display:flex; flex:1; flex-direction:column; }
.fork-worktree-head { display:flex; align-items:center; gap:11px; padding:15px 18px 12px; border-bottom:1px solid var(--divider); flex:none; }
-.fork-worktree-icon { width:38px; height:38px; display:grid; place-items:center; border-radius:12px; background:var(--accent); color:#fff; }
+.fork-worktree-icon { width:38px; height:38px; display:grid; place-items:center; border-radius:12px; background:var(--accent); color:var(--on-accent,#fff); }
.fork-worktree-head > span:nth-child(2) { min-width:0; flex:1; display:flex; flex-direction:column; }
.fork-worktree-head b { font-size:15px; }
.fork-worktree-head small { color:var(--faint); font-size:11px; }
@@ -441,17 +441,17 @@
.fork-worktree-source { display:grid; grid-template-columns:auto minmax(0,1fr); gap:7px 12px; align-items:baseline; padding:11px 12px; border:1px solid var(--border); border-radius:12px; background:var(--bg); }
.fork-worktree-source > span { color:var(--faint); font-size:10.5px; }
.fork-worktree-source > code { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text); font-family:var(--mono); font-size:10.5px; text-align:right; }
-.fork-worktree-field { display:flex; flex-direction:column; gap:6px; color:var(--dim); font-size:11px; font-weight:650; }
+.fork-worktree-field { display:flex; flex-direction:column; gap:6px; color:var(--dim); font-size:11px; font-weight:var(--font-weight-650); }
.fork-worktree-field input { width:100%; box-sizing:border-box; border:1px solid var(--border-strong); border-radius:11px; padding:11px 12px; background:var(--bg); color:var(--text); outline:none; font:inherit; font-size:16px; }
.fork-worktree-field input:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-weak); }
.fork-worktree-field input:disabled { opacity:.62; }
-.fork-worktree-field small { color:var(--faint); font-size:10.5px; font-weight:500; }
+.fork-worktree-field small { color:var(--faint); font-size:10.5px; font-weight:var(--font-weight-500); }
.fork-worktree-error { padding:9px 11px; border-radius:10px; color:var(--danger); background:var(--danger-weak); font-size:11.5px; line-height:1.45; white-space:pre-wrap; overflow-wrap:anywhere; }
.fork-worktree-actions { display:flex; justify-content:flex-end; gap:8px; padding:12px 18px calc(12px + env(safe-area-inset-bottom)); border-top:1px solid var(--divider); flex:none; }
-.fork-worktree-actions button { min-height:40px; display:inline-flex; align-items:center; justify-content:center; gap:8px; padding:8px 14px; border-radius:10px; font-size:13px; font-weight:650; }
+.fork-worktree-actions button { min-height:40px; display:inline-flex; align-items:center; justify-content:center; gap:8px; padding:8px 14px; border-radius:10px; font-size:13px; font-weight:var(--font-weight-650); }
.fork-worktree-actions button:disabled { opacity:.44; cursor:not-allowed; }
.fork-worktree-cancel { color:var(--dim); background:var(--raised); }
-.fork-worktree-primary { min-width:150px; color:#fff; background:var(--accent); }
+.fork-worktree-primary { min-width:150px; color:var(--on-accent,#fff); background:var(--accent); }
.fork-worktree-spinner { width:13px; height:13px; border:2px solid rgba(255,255,255,.48); border-top-color:#fff; border-radius:50%; animation:spin .7s linear infinite; }
@media (max-width:700px) {
.status-sheet-scroll { padding-left:14px; padding-right:14px; }
@@ -482,7 +482,7 @@
background: transparent;
color: var(--dim);
font-size: .9em;
- font-weight: 500;
+ font-weight: var(--font-weight-500);
line-height: 1.5;
overflow-wrap: anywhere;
list-style: none;
@@ -551,7 +551,7 @@
.generated-output figcaption { display: flex; align-items: center; gap: 7px; margin-top: 8px; color: var(--dim); font-size: 12px; }
.generated-image-size { color: var(--faint); font-size: 11px; }
.generated-image-open { display: inline-flex; align-items: center; gap: 5px; margin-left: auto; padding: 5px 0 5px 10px; border: 0; background: none; color: var(--dim); font: inherit; cursor: pointer; }
-.generated-image-open:hover { color: var(--accent); }
+.generated-image-open:hover { color:var(--accent-text,var(--accent)); }
.async-question-card {
display: flex; align-items: center; gap: 12px; width: min(100%, 480px);
@@ -574,7 +574,7 @@
max-height: min(720px, calc(var(--app-height, 100dvh) - 32px));
border: 1px solid var(--border); border-radius: 36px; box-shadow: var(--shadow-lg);
background: var(--surface); color: var(--text); overflow: hidden;
- font: 15px/1.6 var(--sans); overflow-wrap: anywhere;
+ font: var(--font-weight-400) 15px/1.6 var(--sans); overflow-wrap: anywhere;
}
.async-question-dialog[open] { display: flex; flex-direction: column; }
.async-question-dialog::backdrop { background: rgb(18 28 48 / .2); }
@@ -588,7 +588,7 @@
.async-question-body { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 4px 28px 20px; scrollbar-width: thin; }
.async-question-body fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
.async-question-body fieldset + fieldset { margin-top: 24px; }
-.async-question-body legend { width: 100%; padding: 0; margin-bottom: 18px; font-size: 18px; font-weight: 500; line-height: 1.65; white-space: pre-wrap; }
+.async-question-body legend { width: 100%; padding: 0; margin-bottom: 18px; font-size: 18px; font-weight: var(--font-weight-500); line-height: 1.65; white-space: pre-wrap; }
.async-question-option { position: relative; display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 10px 20px; margin-bottom: 8px; border: 1px solid var(--border); border-radius: 999px; cursor: pointer; font-size: 14px; }
/* A transparent radio still gets a native tap overlay. Suppress it on both
hit targets; keyboard focus remains visible on the rounded option below. */
@@ -599,7 +599,7 @@
.async-question-option input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: inherit; }
.async-question-option > span { pointer-events: none; }
.async-question-option > span:first-of-type { flex: 1; min-width: 0; white-space: pre-wrap; }
-.async-question-option-check { display: flex; flex: none; color: var(--accent); visibility: hidden; }
+.async-question-option-check { display: flex; flex: none; color:var(--accent-text,var(--accent)); visibility: hidden; }
.async-question-option:has(input:checked) .async-question-option-check { visibility: visible; }
.async-question-label-hidden { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.async-question-composer { margin-top: 12px; padding: 14px 18px; border: 1px solid var(--border); border-radius: 24px; background: var(--surface); }
@@ -612,7 +612,7 @@
.async-question-footer-actions { display: flex; gap: 8px; margin-left: auto; flex: none; }
.async-question-send, .async-question-later { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; padding: 0 18px; border: 0; border-radius: 999px; font: inherit; font-size: 13px; cursor: pointer; }
.async-question-later { background: var(--raised); color: var(--dim); }
-.async-question-send { background: var(--accent); color: white; }
+.async-question-send { background: var(--accent); color:var(--on-accent,#fff); }
:root[data-theme="dark"] .async-question-send { color: var(--bg); }
.async-question-send > svg { transform: rotate(90deg); }
.async-question-send:disabled { opacity: .4; cursor: default; }
@@ -640,7 +640,7 @@
.engine-selector { display: inline-flex; position: relative; flex: none; }
.engine-selector .engine-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 6px 8px;
- min-height: 32px; border-radius: 9px; font: 600 11px var(--mono); letter-spacing: .02em; cursor: pointer; }
+ min-height: 32px; border-radius: 9px; font: var(--font-weight-600) 11px var(--mono); letter-spacing: .02em; cursor: pointer; }
.engine-label { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.engine-label svg { flex: none; }
.engine-toggle > svg { color: var(--dim); transition: transform 120ms ease; }
@@ -650,7 +650,7 @@
border-radius: 13px; background: var(--surface); color: var(--text); box-shadow: 0 8px 30px #0002; }
.engine-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px;
padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--dim);
- font: 500 13px var(--sans); text-align: left; cursor: pointer; }
+ font: var(--font-weight-500) 13px var(--sans); text-align: left; cursor: pointer; }
.engine-menu-item span { flex: 1; color: var(--text); }
.engine-menu-item[aria-checked="true"] { background: var(--accent-weak); color: var(--accent-ink); }
.engine-menu-item[aria-checked="true"] span { color: inherit; }
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 88869eae..0a3dbd79 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -32,6 +32,7 @@ import { TurnFilePageRequests, type LoadTurnFilePage } from "./turn-file-pages";
import { Icon } from "./icons";
import { ChatView } from "./components/ChatView";
import { Composer } from "./components/Composer";
+const BackgroundTaskControl = lazy(() => import("./components/BackgroundTaskControl"));
import type { QueuedQueryEditor } from "./components/QueuedQueryDialog";
import { ReconnectBanner } from "./components/ReconnectBanner";
import { NoticeStack } from "./components/NoticeStack";
@@ -49,7 +50,6 @@ import { WorkDashboardSheet } from "./components/WorkDashboardSheet";
import type { HookDraft, SkillDraft } from "./components/CapabilitiesSheet";
import { TerminalControl } from "./components/TerminalControl";
import { DeviceSheet, type PairingState, type RemoteDevice } from "./components/DeviceSheet";
-import { HeaderMenu } from "./components/HeaderMenu";
import { EngineSelector } from "./components/EngineSelector";
import {
claudeProfileIdForSession,
@@ -113,11 +113,11 @@ import {
withoutForkFocusPlaceholder,
} from "./session-worktree";
import { matchesBtwRequest,
- normalizeDiffTheme, normalizeEngine, type Snapshot, type QueryImg,
+ normalizeEngine, type Snapshot, type QueryImg,
type QueryFile, type SessionInfo, type CodexPermissionMode,
type CodexWebSearchMode, type PermissionProfileInfo,
type CodexServiceTier, type CollaborationModeName,
- type DiffTheme, type Engine, type Space,
+ type Engine, type Space,
type SessionControl, type History,
sessionControlLocksInput } from "./protocol";
import type { EngineCapabilities, EngineCapabilityItem, EngineCapabilityKind, WorkArtifactInfo, WorkDashboard } from "./protocol";
@@ -252,8 +252,13 @@ import {
import type { AgentDetail, FilesListed } from "./protocol";
import type { AgentDetailSelection } from "./components/AgentDetailController";
import type { RightPanelView } from "./components/PanelTabs";
+import { useTheme } from "./use-theme";
+import { useBoldText } from "./use-bold-text";
+
+const HeaderMenu = lazy(() => import("./components/HeaderMenu").then(
+ ({ HeaderMenu: Menu }) => ({ default: Menu }),
+));
-const THEME_KEY = "cc_remote_theme";
const ENGINE_KEY = "cc_remote_engine"; // which backend the NEXT new session uses
const MACHINE_KEY = "cc_remote_machine";
const GoalPanel = lazy(() => import("./components/GoalPanel").then(
@@ -326,11 +331,11 @@ function catalogForEngineProfile(
}
export default function App() {
- const [theme, setTheme] = useState(
- () => normalizeDiffTheme(localStorage.getItem(THEME_KEY)));
const initialEngineRef = useRef(normalizeEngine(localStorage.getItem(ENGINE_KEY)));
const initialSpacesRef = useRef(readEngineSpaces(localStorage, initialEngineRef.current));
const [engine, setEngine] = useState(initialEngineRef.current);
+ const { mode: theme, choice: themeChoice, selectTheme } = useTheme(engine);
+ const { boldText, setBoldText } = useBoldText();
const [space, setSpace] = useState(initialSpacesRef.current[initialEngineRef.current]);
const spacesByEngineRef = useRef>(initialSpacesRef.current);
const [authed, setAuthed] = useState(false);
@@ -513,7 +518,7 @@ export default function App() {
const requestId = transport?.sendGetContextTo(sid, refresh) ?? null;
if (!requestId) return null;
contextRequestLaunchesRef.current.set(sid, requestId);
- dispatch({ type: "begin_context_request", sid, requestId });
+ dispatch({ type: "begin_context_request", sid, requestId, refresh });
return requestId;
}, []);
const resumeListedSession = useCallback((
@@ -1627,17 +1632,13 @@ export default function App() {
else if (action === "close") setSidebarOpen(false);
};
- useEffect(() => {
- document.documentElement.setAttribute("data-theme", theme);
- localStorage.setItem(THEME_KEY, theme);
- }, [theme]);
useEffect(() => {
try {
sessionStorage.setItem(
BTW_PANEL_SCOPES_KEY, JSON.stringify(btwPanelScopes));
} catch { /* storage is best-effort in private browsing */ }
}, [btwPanelScopes]);
- const toggleTheme = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
+ const toggleTheme = () => selectTheme(theme === "dark" ? "light" : "dark");
// `engine` selects the backend (Claude Code / Codex): the whole UI re-skins via
// data-engine, and the sidebar re-lists that engine's own sessions.
@@ -4290,15 +4291,21 @@ export default function App() {
const deferred = contextRuntime?.contextRefreshDeferred === true;
if (!contextRuntime?.contextRequestId
&& (!deferred || focusedEngine === "claude" && contextRuntime?.state === "idle")) {
- sendContextRequestTo(focusedSid, deferred);
+ // A resumed Claude child has no capacity cache until its first native
+ // summary. The Wrapper selects its safe local read, including while busy.
+ sendContextRequestTo(focusedSid, deferred || focusedEngine === "claude"
+ && (contextRuntime?.contextReport?.max_tokens ?? 0) <= 0);
}
- // A long Codex turn can compact before TurnEnd. These visible-session
- // reads are bounded and never invoke a model or resume an engine.
- if (focusedEngine !== "codex") return;
+ // Both engines receive usage during a long turn. Poll cached totals; Claude
+ // only needs a native summary again if its generation's capacity is absent.
+ if (focusedEngine !== "codex" && focusedEngine !== "claude") return;
const timer = window.setInterval(() => {
+ const runtime = stateRef.current.runtimes[focusedSid];
if (document.visibilityState === "visible"
- && stateRef.current.runtimes[focusedSid]?.state === "running") {
- sendContextRequestTo(focusedSid, false);
+ && runtime?.state === "running") {
+ sendContextRequestTo(focusedSid, focusedEngine === "claude"
+ && !runtime.contextRefreshDeferred
+ && (runtime.contextReport?.max_tokens ?? 0) <= 0);
}
}, 5000);
return () => window.clearInterval(timer);
@@ -4631,7 +4638,7 @@ export default function App() {
prompt: string, images?: QueryImg[], files?: QueryFile[],
): boolean => {
const ws = wsRef.current;
- if (!ws || !focusedSid || focusedEngine !== "codex") return false;
+ if (!ws || !focusedSid || runtimeIsReadOnly(focusedSid)) return false;
const runtime = stateRef.current.runtimes[focusedSid];
if (ws.pendingQueryFor(focusedSid) || runtime?.acceptancePending) {
return false;
@@ -5052,11 +5059,8 @@ export default function App() {
const runtime = stateRef.current.runtimes[focusedSid];
if (runtime?.contextRequestId
|| contextRequestLaunchesRef.current.has(focusedSid)) return;
- if (focusedEngine === "claude" && runtime
- && (runtime.state !== "idle" || runtime.queue.length > 0)) {
- dispatch({ type: "defer_context_request", sid: focusedSid });
- return;
- }
+ // The Wrapper knows whether this Claude handle supports a live local
+ // summary. Let it choose, rather than suppress every running read here.
// Closing and reopening is an explicit retry even if a previous busy
// response exhausted its automatic finalizer catch-up attempts.
contextDeferredRetryAttemptsRef.current.delete(focusedSid);
@@ -5199,8 +5203,12 @@ export default function App() {
if (!focusedSid || focusedEngine !== "claude" || space !== "code"
|| !rt.historyRevision) return;
closeViewer();
+ const agent = rt.backgroundProcesses.find((block) => block.item_id === runId)
+ ?? rt.turns.flatMap((turn) => turn.blocks).find((block) =>
+ block.kind === "process" && block.item_id === runId);
setAgentPanel({ sid: focusedSid, revision: rt.historyRevision,
- runId, title: title || "协作代理" });
+ runId, title: title || "协作代理",
+ status: agent?.kind === "process" ? agent.status : undefined });
};
const previewAgentFile = (file: string, line?: number) => {
if (previewFileForSid(focusedSid, file, line)) setAgentPanel(null);
@@ -5314,7 +5322,9 @@ export default function App() {
if (!parentSid) return;
dispatch({ type: "select_btw", parentSid, btwSid: sid });
};
- const sendBtw = (prompt: string): boolean => {
+ const sendBtw = (
+ prompt: string, images?: QueryImg[], files?: QueryFile[], steer = false,
+ ): boolean => {
const sid = activeBtwSid;
const ws = wsRef.current;
if (!sid || !ws || runtimeIsReadOnly(sid)) return false;
@@ -5322,10 +5332,11 @@ export default function App() {
const awaitingAcceptance = !!(
ws.pendingQueryFor(sid) || runtime?.acceptancePending
);
- if (awaitingAcceptance || runtime?.queue.length || runtime?.pendingSend) {
+ if (steer && awaitingAcceptance) return false;
+ const query = { prompt, images, files };
+ if (!steer && (awaitingAcceptance || runtime?.queue.length || runtime?.pendingSend)) {
const delivery = awaitingAcceptance && activeBtwSendMode !== "queue"
? "replace" : "queue";
- const query = { prompt };
const currentState = stateRef.current;
const unconfirmed = collectUnconfirmedQueries(
currentState.runtimes,
@@ -5340,25 +5351,17 @@ export default function App() {
return sendDeferredQuery(sid, query, delivery);
}
const msg_id = uuid();
- if (!ws.sendQueryTo(sid, prompt, msg_id)) return false;
+ const accepted = steer
+ ? ws.sendSteerTo(sid, prompt, msg_id, images, files)
+ : ws.sendQueryTo(sid, prompt, msg_id, images, files);
+ if (!accepted) return false;
dispatch({
- type: "query_sent", sid, prompt, msg_id, ts: Date.now(),
- });
- return true;
- };
- const steerBtw = (prompt: string): boolean => {
- const sid = activeBtwSid;
- const ws = wsRef.current;
- if (!sid || !ws || activeBtw?.engine !== "codex" || runtimeIsReadOnly(sid)) return false;
- const runtime = stateRef.current.runtimes[sid];
- if (ws.pendingQueryFor(sid) || runtime?.acceptancePending) return false;
- const msg_id = uuid();
- if (!ws.sendSteerTo(sid, prompt, msg_id)) return false;
- dispatch({
- type: "steer_sent", sid, prompt, msg_id, ts: Date.now(),
+ type: steer ? "steer_sent" : "query_sent", sid, ...query, msg_id, ts: Date.now(),
});
return true;
};
+ const steerBtw = (prompt: string, images?: QueryImg[], files?: QueryFile[]): boolean =>
+ sendBtw(prompt, images, files, true);
const interruptBtw = (sid: string) => {
if (runtimeIsReadOnly(sid)) return;
wsRef.current?.sendInterruptTo(sid);
@@ -5671,9 +5674,11 @@ export default function App() {
{activeDevice?.label ?? machineId}
- }> openViewer() : undefined}
onOpenFiles={visibleParentSid && !archivedBrowse && state.wrapperOnline ? () => openFiles() : undefined}
- onToggleTheme={toggleTheme}
+ onSelectTheme={selectTheme}
onLogout={() => void logout()}
- />
+ />
0
+ ?
+
+ : null}
surface={space}
state={rt.state}
catalog={focusedCatalog}
@@ -6054,7 +6065,7 @@ export default function App() {
return
setAgentPanel(null)}
onOpenFile={previewAgentFile} />
@@ -6123,6 +6134,11 @@ export default function App() {
onSetEffort={(effort) => {
if (activeBtwSid) setBtwEffort(activeBtwSid, effort);
}}
+ onSetServiceTier={(tier) => {
+ if (!activeBtwSid || activeBtw?.engine !== "codex"
+ || runtimeIsReadOnly(activeBtwSid)) return false;
+ return wsRef.current?.sendSetServiceTier(tier, activeBtwSid) ?? false;
+ }}
onSetAutoCompact={(selection) => activeBtwSid
? setBtwAutoCompact(activeBtwSid, selection) : false}
onOpenFile={previewBtwFile} onCollapse={collapseBtw}
diff --git a/web/src/agent-detail.ts b/web/src/agent-detail.ts
index dffaca0e..3b9b2a8a 100644
--- a/web/src/agent-detail.ts
+++ b/web/src/agent-detail.ts
@@ -17,6 +17,7 @@ export interface AgentDetailRun {
oldestCursor: string | null;
hasNewer: boolean;
newerCursor: string | null;
+ viewingOlder?: boolean;
}
export interface AgentDetailPanelState {
@@ -190,5 +191,6 @@ export function acceptAgentDetail(
hasMore: message.has_more ?? false,
oldestCursor: message.oldest_cursor ?? null,
hasNewer: message.has_newer ?? false,
- newerCursor: message.newer_cursor ?? null };
+ newerCursor: message.newer_cursor ?? null,
+ viewingOlder: older };
}
diff --git a/web/src/cache.ts b/web/src/cache.ts
index 35643820..756172a8 100644
--- a/web/src/cache.ts
+++ b/web/src/cache.ts
@@ -67,7 +67,8 @@ const SCHEMA = 1;
// resume bookkeeping, and projections bloated by automatic full-detail paging.
// v25 reprojects native async questions instead of preserving plain-answer shells.
// v26 discards summaries where those questions hid ordinary unphased replies.
-const CACHE_VER = 26;
+// v27 removes Claude turns split by native isMeta recovery prompts.
+const CACHE_VER = 27;
const MAX_CACHE_SESSIONS = 64;
const MAX_CACHE_TURNS = 100;
const MAX_CACHE_BYTES = 2 * 1024 * 1024;
diff --git a/web/src/chat-dialog-geometry.ts b/web/src/chat-dialog-geometry.ts
index 6227fff8..fed423fd 100644
--- a/web/src/chat-dialog-geometry.ts
+++ b/web/src/chat-dialog-geometry.ts
@@ -32,6 +32,7 @@ interface AnchoredPopoverGeometryOptions {
gap?: number;
gutter?: number;
minimumHeight?: number;
+ align?: "center" | "start";
}
interface Bounds {
@@ -263,6 +264,7 @@ export function useAnchoredPopoverGeometry({
gap = 8,
gutter = 16,
minimumHeight = 64,
+ align = "center",
}: AnchoredPopoverGeometryOptions): AnchoredPopoverGeometry | null {
const [geometry, setGeometry] = useState(null);
@@ -301,7 +303,8 @@ export function useAnchoredPopoverGeometry({
const width = Math.min(maxWidth, availableWidth);
const minimumCenter = bounds.left + horizontalGutter + width / 2;
const maximumCenter = bounds.right - horizontalGutter - width / 2;
- const anchorCenter = (anchorBounds.left + anchorBounds.right) / 2;
+ const anchorCenter = align === "start" ? anchorBounds.left + width / 2
+ : (anchorBounds.left + anchorBounds.right) / 2;
const left = minimumCenter <= maximumCenter
? clamp(anchorCenter, minimumCenter, maximumCenter)
: (bounds.left + bounds.right) / 2;
@@ -360,7 +363,7 @@ export function useAnchoredPopoverGeometry({
document.removeEventListener("scroll", schedule, true);
resizeObserver?.disconnect();
};
- }, [anchorRef, gap, gutter, maxHeight, maxWidth, minimumHeight, open]);
+ }, [align, anchorRef, gap, gutter, maxHeight, maxWidth, minimumHeight, open]);
return open ? geometry : null;
}
diff --git a/web/src/claude-continuations.ts b/web/src/claude-continuations.ts
new file mode 100644
index 00000000..16983b13
--- /dev/null
+++ b/web/src/claude-continuations.ts
@@ -0,0 +1,60 @@
+import type { Block, TextBlock } from "./domain/conversation";
+import { finalTextBlocks } from "./process-blocks";
+
+export interface ClaudeContinuation {
+ id: string;
+ blocks: Block[];
+ answers: TextBlock[];
+ startedTs?: number;
+ doneTs?: number;
+}
+
+/** Native task notifications update an existing child in place. Only actual
+ * main-agent output starts a new narrative segment after the settled answer.
+ * Source order and native message IDs keep live, replay and detail identical. */
+export function claudeContinuations(blocks: Block[], answers: TextBlock[]) {
+ const original: Block[] = [];
+ const continuations: ClaudeContinuation[] = [];
+ const continuationAnswers = new Set();
+ const visibleAnswers = new Map(answers.map((block) => [block.message_id, block]));
+ let current: ClaudeContinuation | undefined;
+ let answered = false;
+ for (const block of blocks) {
+ const child = block.kind === "process"
+ && (block.processKind === "agent" || block.processKind === "task");
+ const bookkeeping = block.kind === "process" && ![
+ "command", "file_change", "mcp", "web_search", "server_tool", "reasoning",
+ ].includes(block.processKind);
+ if (block.background !== true || child
+ || (bookkeeping && (!current || answered))
+ || (block.kind === "text" && block.delivery === "async")) {
+ original.push(block);
+ continue;
+ }
+ if (!current || answered) {
+ current = {
+ id: block.kind === "text" ? block.message_id
+ : block.kind === "tool" ? block.tool_use_id : block.item_id,
+ blocks: [], answers: [], startedTs: block.startedTs,
+ };
+ continuations.push(current);
+ answered = false;
+ }
+ current.blocks.push(block);
+ const doneTs = block.kind === "process" ? block.terminalTs : block.doneTs;
+ if (doneTs != null) current.doneTs = Math.max(current.doneTs ?? 0, doneTs);
+ if (block.kind === "text" && finalTextBlocks([block]).length > 0) {
+ answered = true;
+ const answer = visibleAnswers.get(block.message_id);
+ if (answer) {
+ current.answers.push(answer);
+ continuationAnswers.add(block.message_id);
+ }
+ }
+ }
+ return {
+ original,
+ answers: answers.filter((block) => !continuationAnswers.has(block.message_id)),
+ continuations,
+ };
+}
diff --git a/web/src/components/AgentDetailController.tsx b/web/src/components/AgentDetailController.tsx
index 8fdb13ad..d983c86f 100644
--- a/web/src/components/AgentDetailController.tsx
+++ b/web/src/components/AgentDetailController.tsx
@@ -5,16 +5,18 @@ import {
emptyAgentRun,
type AgentDetailPanelState,
} from "../agent-detail";
-import type { AgentDetail } from "../protocol";
+import type { AgentDetail, ProcessStatus } from "../protocol";
import type { RelayWs } from "../ws";
import { uuid } from "../util";
import { AgentDetailPanel } from "./AgentDetailPanel";
+import { HISTORY_DETAIL_REQUEST_TIMEOUT_MS } from "../history-requests";
export interface AgentDetailSelection {
sid: string;
revision: string;
runId: string;
title: string;
+ status?: ProcessStatus;
}
export function AgentDetailController({ selection, ws, onListen, onClose,
@@ -29,56 +31,90 @@ export function AgentDetailController({ selection, ws, onListen, onClose,
sid: selection.sid,
revision: selection.revision,
stack: [selection.runId],
- runs: { [selection.runId]: emptyAgentRun(
- selection.runId, selection.title) },
+ runs: { [selection.runId]: { ...emptyAgentRun(
+ selection.runId, selection.title), status: selection.status ?? "unknown" } },
}));
const panelRef = useRef(panel);
panelRef.current = panel;
+ const deadlines = useRef(new Map>());
+ const clearDeadline = useCallback((runId: string) => {
+ clearTimeout(deadlines.current.get(runId));
+ deadlines.current.delete(runId);
+ }, []);
+ useEffect(() => {
+ const timers = deadlines.current;
+ return () => { timers.forEach(clearTimeout); timers.clear(); };
+ }, []);
const request = useCallback((runId: string, title = "协作代理",
before?: string | null) => {
const current = panelRef.current;
const run = current.runs[runId];
const requestId = uuid();
- if (!ws?.sendGetAgentDetail(
- selection.sid, runId, selection.revision,
- run?.detailRevision, before, 192, requestId,
- )) return false;
+ clearDeadline(runId);
+ const sent = ws?.sendGetAgentDetail(
+ current.sid, runId, before ? current.revision : undefined,
+ before ? run?.detailRevision : undefined, before, 192, requestId,
+ );
setPanel((value) => {
const currentRun = value.runs[runId] ?? emptyAgentRun(runId, title);
return { ...value, runs: { ...value.runs, [runId]: {
...currentRun, title: title || currentRun.title,
- loading: true, error: null, requestId,
+ loading: !!sent, error: sent ? null : "连接未就绪,请重试读取协作代理", requestId: sent ? requestId : null,
} } };
});
- return true;
- }, [selection.revision, selection.sid, ws]);
+ if (sent) deadlines.current.set(runId, setTimeout(() => {
+ deadlines.current.delete(runId);
+ setPanel((value) => {
+ const pending = value.runs[runId];
+ if (pending?.requestId !== requestId) return value;
+ return { ...value, runs: { ...value.runs, [runId]: {
+ ...pending, loading: false, requestId: null,
+ error: "读取协作代理超时,请重试",
+ } } };
+ });
+ }, HISTORY_DETAIL_REQUEST_TIMEOUT_MS));
+ return !!sent;
+ }, [clearDeadline, ws]);
const receive = useCallback((message: AgentDetail) => {
const current = panelRef.current;
const run = current.runs[message.run_id];
- if (!run || message.session_id !== current.sid
- || message.revision !== current.revision) return;
+ if (!run || message.session_id !== current.sid) return;
if (!message.live && (!message.request_id
|| message.request_id !== run.requestId)) return;
+ if (message.live && message.revision !== current.revision) return;
+ if (!message.live) clearDeadline(message.run_id);
setPanel((value) => {
const currentRun = value.runs[message.run_id];
if (!currentRun) return value;
if (!message.live && message.request_id !== currentRun.requestId) {
return value;
}
+ if (message.revision !== value.revision) {
+ // A correlated stale-revision rejection is still a response. Keeping
+ // the old waiter here used to leave this panel loading forever.
+ const fresh = emptyAgentRun(message.run_id, currentRun.title);
+ return { ...value, revision: message.revision, runs: {
+ [message.run_id]: message.authoritative && !message.error && !message.before
+ ? acceptAgentDetail(fresh, message)
+ : { ...fresh, loading: false, error: "会话记录已更新,请重试读取协作代理" },
+ }, stack: [message.run_id] };
+ }
return { ...value, runs: { ...value.runs,
[message.run_id]: acceptAgentDetail(currentRun, message) } };
});
- }, []);
+ }, [clearDeadline]);
useEffect(() => {
onListen(receive);
return () => onListen(null);
}, [onListen, receive]);
useEffect(() => {
- request(selection.runId, selection.title);
- }, [request, selection.runId, selection.title]);
+ const current = panelRef.current;
+ const id = current.stack.at(-1) ?? selection.runId;
+ request(id, current.runs[id]?.title ?? selection.title);
+ }, [request, selection.runId, selection.title, selection.revision]);
const open = (runId: string, title?: string) => {
if (!request(runId, title)) return;
@@ -88,6 +124,15 @@ export function AgentDetailController({ selection, ws, onListen, onClose,
};
const activeId = panel.stack.at(-1);
const run = activeId ? panel.runs[activeId] : null;
+ useEffect(() => {
+ // Cold/native CLI agents have no resident SDK subscription. Refresh only
+ // that visible active source; resident agents already push their updates.
+ if (!run || run.loading || run.error || run.viewingOlder || !run.detailRevision
+ || run.detailRevision.startsWith("live-")
+ || !["running", "pending"].includes(run.status)) return;
+ const timer = setTimeout(() => request(run.runId, run.title), 3000);
+ return () => clearTimeout(timer);
+ }, [request, run]);
if (!run) return null;
return 1}
onBack={() => setPanel((value) => ({
diff --git a/web/src/components/AgentDetailPanel.tsx b/web/src/components/AgentDetailPanel.tsx
index 947d8a6d..8e89d2cb 100644
--- a/web/src/components/AgentDetailPanel.tsx
+++ b/web/src/components/AgentDetailPanel.tsx
@@ -1,6 +1,6 @@
import type { AgentDetailRun } from "../agent-detail";
import { finalTextBlocks, presentableProcessBlocks } from "../process-blocks";
-import { Icon } from "../icons";
+import { ClaudeWorking, Icon } from "../icons";
import { MessageBlock } from "./MessageBlock";
import { ProcessTimeline } from "./ProcessTimeline";
import { PanelResizer } from "./PanelResizer";
@@ -73,6 +73,10 @@ export function AgentDetailPanel({ run, canGoBack, onBack, onClose, onRetry,
))}
+ {!done &&
+
+ 子代理处理中
+
}
{!run.loading && !run.error && run.blocks.length === 0 && (
这个协作代理暂时没有可展示的过程。
)}
diff --git a/web/src/components/ArtifactPanel.tsx b/web/src/components/ArtifactPanel.tsx
index 0750ecc9..dc28cdfc 100644
--- a/web/src/components/ArtifactPanel.tsx
+++ b/web/src/components/ArtifactPanel.tsx
@@ -534,6 +534,7 @@ export function ArtifactPanel({ artifact, active, hasBtw, onTab, onClose,
const title = artifact.file.split("/").pop()
|| (["md", "file", "html", "image", "pdf", "audio", "spreadsheet"].includes(artifact.kind) ? "文件预览" : "改动");
+ const mermaidFile = artifact.kind === "file" && /\.(?:mmd|mermaid)$/i.test(artifact.file);
const renderedArtifact = ["image", "pdf", "audio"].includes(artifact.kind)
|| (artifact.kind === "html" && mode === "preview");
@@ -547,9 +548,9 @@ export function ArtifactPanel({ artifact, active, hasBtw, onTab, onClose,
onTab={switchPanelTab} />
: {title}}
{artifact.file || "所有改动"}
- {["md", "html"].includes(artifact.kind) && !loading && !artifact.error &&
+ aria-label={`${mermaidFile ? "Mermaid" : artifact.kind === "html" ? "HTML" : "Markdown"} 显示模式`}>
);
}
@@ -2611,8 +2569,6 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading
? "添加资料并描述成果,我会把生成的文档和文件留在这项工作的私有目录。"
: <>发一条消息开始,或用 / 唤起命令面板(Plan mode、review、技能…)。>}
-
);
}
@@ -2694,7 +2650,8 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading
timelineBlocks, engine);
const foregroundProcessItems = t.done
? processItems.filter((block) => !(
- block.kind === "process" && block.background === true
+ block.background === true
+ && (engine === "claude" || block.kind === "process")
))
: processItems;
const activeProcess = hasActiveProcess(foregroundProcessItems);
@@ -2705,8 +2662,13 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading
|| supplemental.questionOwners.get(block.message_id) === t.id);
const generatedImages = generatedOutputImages(timelineBlocks);
const modelNotices = modelFallbackNotices(timelineBlocks);
- const followupBoundaries = backgroundFollowupBoundaries(
- finalBlocks, timelineBlocks);
+ const narrative = engine === "claude"
+ ? claudeContinuations(timelineBlocks, finalBlocks)
+ : { original: timelineBlocks, answers: finalBlocks,
+ continuations: [] as ClaudeContinuation[] };
+ const lastContinuation = narrative.continuations.at(-1);
+ const originalProcessItems = presentableProcessBlocks(
+ narrative.original, engine);
const enclosingTaskActive = activeTurnId === t.id;
const processDetailState = processItems.length > 0
? "present"
@@ -2736,13 +2698,16 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading
|| processDetailState === "present";
const activePhase = !working
? "complete"
- : hasProcessTimeline
+ : lastContinuation
+ ? lastContinuation.answers.length > 0 ? "answering" : "process"
+ : hasProcessTimeline
&& (activeTimeline
|| (enclosingTaskActive
&& (finalBlocks.length === 0 || t.done)))
? "process"
: finalBlocks.length > 0 ? "answering" : "waiting";
- const showProcessTimeline = hasProcessTimeline;
+ const showProcessTimeline = originalProcessItems.length > 0
+ || processDetailState === "present";
// Unknown native summaries don't prove that there is anything to
// expand. Keep that uncertainty in the projection, not as a button
// which disappears after an empty read. Real deferred content,
@@ -2806,6 +2771,98 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading
? Math.max(1, t.detailEventCount ?? 0)
: t.detailEventCount ?? 0
: 0;
+ const renderProcess = (segment?: ClaudeContinuation) => {
+ const continuing = !!segment && segment === lastContinuation
+ && enclosingTaskActive && !terminalProblem;
+ const disclosureKey = segment
+ ? `${processOpenKey}\u0000continuation:${segment.id}` : processOpenKey;
+ return (
+ lastContinuation.startedTs) ? undefined : t.durationMs}
+ startTs={segment?.startedTs ?? (engine === "codex" ? t.processStartedTs : t.ts)}
+ doneTs={segment ? segment.doneTs : engine === "codex" ? t.processDoneTs
+ : lastContinuation && t.doneTs != null
+ && lastContinuation.startedTs != null
+ && t.doneTs > lastContinuation.startedTs ? undefined : t.doneTs}
+ deferredCount={segment ? (deferredProcessCount > 0 ? 1 : 0) : deferredProcessCount}
+ detailLoading={t.detailLoading}
+ detailError={processDetailError}
+ externalPlanItemId={externalPlanItemId}
+ onLoadDetail={onLoadDetail
+ ? () => requestProcessDetail(
+ t.id, undefined, "initial", false)
+ : undefined}
+ onRetryDetail={onLoadDetail && detailRetryDirection
+ ? () => requestProcessDetail(
+ t.id,
+ detailRetryBefore,
+ detailRetryDirection,
+ false)
+ : undefined}
+ canLoadEarlier={!segment && canReadOlderDetail}
+ canLoadNewer={!segment && canReadNewerDetail}
+ onLoadEarlier={onLoadDetail && canReadOlderDetail
+ ? () => requestProcessDetail(
+ t.id, t.detailOldestCursor, "older")
+ : undefined}
+ onLoadNewer={onLoadDetail && canReadNewerDetail
+ ? () => requestProcessDetail(
+ t.id, t.detailNewerCursor, "newer")
+ : undefined}
+ onOpenFile={onOpenFile} imageAssets={imageAssets}
+ onLoadImage={onLoadImage}
+ onAuthorizeImage={onAuthorizeImage}
+ historyTurnId={historyTurnId}
+ historyImageAssets={historyImageAssets}
+ onLoadHistoryImage={onLoadHistoryImage}
+ onPreviewHistoryImage={(turnId, imageId) => setZoom({
+ kind: "history",
+ turnId,
+ imageId,
+ alt: "查看过的图片",
+ })}
+ onOpenAgent={onOpenAgent}
+ onInteractionStart={beginProcessInteraction}
+ onInteractionEnd={endProcessInteraction}
+ openOverride={
+ processDisclosureOpen[`${disclosureKey}\u0000outer`]
+ ?? (!segment && t.detailRestoreOpen ? true : undefined)
+ }
+ onOpenChange={(open) => rememberProcessDisclosure(
+ `${disclosureKey}\u0000outer`, open,
+ )}
+ itemOpen={(key) =>
+ processDisclosureOpen[`${disclosureKey}\u0000${key}`]}
+ onItemOpenChange={(key, open) => rememberProcessDisclosure(
+ `${disclosureKey}\u0000${key}`, open,
+ )}
+ onPreviewImage={(src, alt) => setZoom({ kind: "data", src, alt })} />
+ );
+ };
+ const renderAnswer = (block: TextBlock) => (
+
+ {block.delivery === "async" && block.questions?.length
+ ?
助手询问…}>
+ {
+ pauseOutputFollow();
+ setOpenAsyncQuestion({ scope: asyncQuestionScope, messageId: block.message_id });
+ }} />
+
+ :
setZoom({ kind: "data", src, alt })} />}
+
+ );
const historyImagesReady = !!t.imageRefs?.length
&& t.imageRefs.every((image) => (
historyImageAssets?.[historyImageAssetKey(
@@ -2838,7 +2895,9 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading
}}>
{(t.prompt || (t.images && t.images.length) || (t.imageRefs && t.imageRefs.length) || (t.files && t.files.length)) && (
- {t.prompt &&
{supplemental.replies.has(t.id)
+ {t.prompt &&
+ {t.timedTask &&
}
+ {supplemental.replies.has(t.id)
?
查看问题
@@ -2910,68 +2969,7 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading
)}
- {showProcessTimeline && (
-
requestProcessDetail(
- t.id, undefined, "initial", false)
- : undefined}
- onRetryDetail={onLoadDetail && detailRetryDirection
- ? () => requestProcessDetail(
- t.id,
- detailRetryBefore,
- detailRetryDirection,
- false)
- : undefined}
- canLoadEarlier={canReadOlderDetail}
- canLoadNewer={canReadNewerDetail}
- onLoadEarlier={onLoadDetail && canReadOlderDetail
- ? () => requestProcessDetail(
- t.id, t.detailOldestCursor, "older")
- : undefined}
- onLoadNewer={onLoadDetail && canReadNewerDetail
- ? () => requestProcessDetail(
- t.id, t.detailNewerCursor, "newer")
- : undefined}
- onOpenFile={onOpenFile} imageAssets={imageAssets}
- onLoadImage={onLoadImage}
- onAuthorizeImage={onAuthorizeImage}
- historyTurnId={historyTurnId}
- historyImageAssets={historyImageAssets}
- onLoadHistoryImage={onLoadHistoryImage}
- onPreviewHistoryImage={(turnId, imageId) => setZoom({
- kind: "history",
- turnId,
- imageId,
- alt: "查看过的图片",
- })}
- onOpenAgent={onOpenAgent}
- onInteractionStart={beginProcessInteraction}
- onInteractionEnd={endProcessInteraction}
- openOverride={
- processDisclosureOpen[`${processOpenKey}\u0000outer`]
- ?? (t.detailRestoreOpen ? true : undefined)
- }
- onOpenChange={(open) => rememberProcessDisclosure(
- `${processOpenKey}\u0000outer`, open,
- )}
- itemOpen={(key) =>
- processDisclosureOpen[`${processOpenKey}\u0000${key}`]}
- onItemOpenChange={(key, open) => rememberProcessDisclosure(
- `${processOpenKey}\u0000${key}`, open,
- )}
- onPreviewImage={(src, alt) => setZoom({ kind: "data", src, alt })} />
- )}
+ {showProcessTimeline && renderProcess()}
{modelNotices.map((notice) =>
{notice.summary}
@@ -2989,40 +2987,15 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading
}
{(t.blocks.length > 0 || t.error) && (
<>
- {finalBlocks.map((block) => (
-
- {followupBoundaries.has(block.message_id) && (
-
-
-
-
- {backgroundFollowupLabel(
- followupBoundaries.get(block.message_id)
- ?? undefined,
- )} · Claude 随后继续回复
- {(followupBoundaries.get(block.message_id)?.terminalTs
- || block.startedTs) && (
-
- )}
-
- )}
- {block.delivery === "async" && block.questions?.length
- ?
助手询问…}>
- {
- pauseOutputFollow();
- setOpenAsyncQuestion({ scope: asyncQuestionScope, messageId: block.message_id });
- }} />
-
- :
setZoom({ kind: "data", src, alt })} />}
+ {narrative.answers.map(renderAnswer)}
+ {narrative.continuations.map((segment) => (
+
+
+ Claude 继续处理
+ {segment.startedTs != null && }
+
+ {renderProcess(segment)}
+ {segment.answers.map(renderAnswer)}
))}
{t.error && ti} />}
@@ -3080,6 +3053,9 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading
{workingLabel}
+ {usageForTurn(t, turnUsage) &&
+
+ }
{!showCompletionFooter &&
}
@@ -3107,8 +3083,6 @@ export function ChatView({ sid, turns: incomingTurns, engine = "claude", loading
)}
-
{openAsyncQuestion?.scope === asyncQuestionScope &&
import("./CommandSheet").then(m => ({ default: m.CommandSheet })));
import { attachmentBytes, snapshotAttachmentFiles } from "../img";
+import { useAttachmentDrop } from "../use-attachment-drop";
import {
readClipboardImport, resolveClipboardImport, insertClipboardText,
type ClipboardImport,
@@ -66,6 +68,7 @@ const ContextPopover = lazy(() => import("./ContextPopover"));
interface Props {
draftKey: string;
draftStore: ComposerDraftStore;
+ backgroundTasks?: ReactNode;
surface?: "code" | "work";
state: State;
connState: ConnState;
@@ -197,14 +200,10 @@ export function Composer(p: Props) {
const noticeTimer = useRef(null);
const [importing, setImporting] = useState(false);
const importingRef = useRef(false);
- const [dragDepth, setDragDepth] = useState(0);
- const dragOver = dragDepth > 0;
const taRef = useRef(null);
const imeSubmitRef = useRef(new ImeSubmitGuard());
const buttonSendTimerRef = useRef(null);
const requestedSkillScopeRef = useRef(null);
- const pickFilesRef = useRef<(files: FileList | File[] | null) => Promise>(
- async () => {});
useLayoutEffect(() => {
if (draftKeyRef.current === p.draftKey) return;
@@ -379,6 +378,7 @@ export function Composer(p: Props) {
const onPickFiles = async (
fl: FileList | File[] | null, clipboard?: ClipboardImport,
) => {
+ if (locked) return;
if (importingRef.current) { flash("附件正在导入,请稍候"); return; }
const targetDraftKey = draftKeyRef.current;
importingRef.current = true;
@@ -415,33 +415,7 @@ export function Composer(p: Props) {
setImporting(false);
}
};
- pickFilesRef.current = onPickFiles;
-
- // Whole-window drag-drop overlay. The effect is refreshed with the current
- // attachment limits/import state so its async drop handler never uses a stale
- // count or appends after a send.
- useEffect(() => {
- const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types || []).includes("Files");
- const onEnter = (e: DragEvent) => { if (hasFiles(e)) setDragDepth((d) => d + 1); };
- const onLeave = (e: DragEvent) => { if (hasFiles(e)) setDragDepth((d) => Math.max(0, d - 1)); };
- const onOver = (e: DragEvent) => { if (hasFiles(e)) e.preventDefault(); };
- const onDrop = (e: DragEvent) => {
- if (!hasFiles(e)) return;
- e.preventDefault();
- setDragDepth(0);
- if (e.dataTransfer?.files?.length) void pickFilesRef.current(e.dataTransfer.files);
- };
- window.addEventListener("dragenter", onEnter);
- window.addEventListener("dragleave", onLeave);
- window.addEventListener("dragover", onOver);
- window.addEventListener("drop", onDrop);
- return () => {
- window.removeEventListener("dragenter", onEnter);
- window.removeEventListener("dragleave", onLeave);
- window.removeEventListener("dragover", onOver);
- window.removeEventListener("drop", onDrop);
- };
- }, []);
+ const dragOver = useAttachmentDrop("main", locked || importing, onPickFiles);
// Keep the native textarea for reliable selection/undo/IME. Large text is
// retained privately by the draft and represented only by an editable card.
@@ -504,7 +478,6 @@ export function Composer(p: Props) {
}
return;
}
- if (action === "interrupt-and-replace") p.onInterrupt();
if (p.onSetPending(query)) {
clearDraft(); resetTaHeight();
}
@@ -719,13 +692,10 @@ export function Composer(p: Props) {
const stopping = busy && !hasText && !hasAttachments;
const interruptSettling = isInterruptSettling(p.state);
- const primaryIsInterrupt = (p.engine ?? "claude") !== "codex";
const sendIcon = !busy ? "send" : stopping ? "stop"
- : p.sendMode === "steer" ? (primaryIsInterrupt ? "bolt" : "send")
+ : p.sendMode === "steer" ? "send"
: "queue";
- const sendClass = "sendbtn" + ((stopping
- || (busy && p.sendMode === "steer" && primaryIsInterrupt
- && (hasText || hasAttachments))) ? " interrupt" : "");
+ const sendClass = "sendbtn" + (stopping ? " interrupt" : "");
const disabled = locked || importing || (!busy && !hasText && !hasAttachments)
|| isSettlingStopDisabled(p.state, hasText || hasAttachments);
// Fall back to the raw id (not MODELS[0]) so a hidden model set via
@@ -737,7 +707,9 @@ export function Composer(p: Props) {
// A background read may temporarily return billing usage or no estimate.
// Keep the last native reading until another arrives. Runtime invalidation
// already clears both reports when the session's model or capacity changes.
- const exactContextReport = currentContextReport?.source !== "recent_turn" && currentContextReport
+ const exactContextReport = currentContextReport
+ && (p.engine !== "codex" || currentContextReport.source !== "recent_turn")
+ && (currentContextReport.max_tokens > 0 || (retainedContextReport?.max_tokens ?? 0) <= 0)
? currentContextReport : retainedContextReport ?? currentContextReport;
const codexEstimate = p.engine !== "codex"
|| exactContextReport?.source === "native_estimate";
@@ -911,18 +883,18 @@ export function Composer(p: Props) {
)}
- {busy && (
+ {(busy || p.backgroundTasks) && (
-
+ {p.backgroundTasks}
+ {busy &&
p.setSendMode("steer")}>
-
- {primaryIsInterrupt ? "打断并发送" : "引导"}
+ 引导
p.setSendMode("queue")}>
排队
-
+
}
)}
@@ -1129,6 +1101,7 @@ export function Composer(p: Props) {
{ setCtxOpen(false); setAutoCompactOpen(true); } : undefined} />
@@ -1174,7 +1147,7 @@ export function Composer(p: Props) {
/>}
{dragOver && (
-
+
拖拽文件到此处发送
diff --git a/web/src/components/ContextPopover.tsx b/web/src/components/ContextPopover.tsx
index 10e73809..da1c5062 100644
--- a/web/src/components/ContextPopover.tsx
+++ b/web/src/components/ContextPopover.tsx
@@ -1,4 +1,4 @@
-import type { CodexContext, ContextReport } from "../protocol";
+import type { AutoCompact, CodexContext, ContextReport } from "../protocol";
import { workContextMetrics } from "../work-context";
export const CODEX_CONTEXT_USAGE_NOTE =
"进度按 Codex 原生上下文估算显示;后台刷新期间保留最近有效读数。";
@@ -9,6 +9,7 @@ interface Props {
work?: boolean;
onAutoCompact?: () => void;
codexContext?: CodexContext | null;
+ autoCompact?: AutoCompact | null;
codex?: boolean;
}
@@ -84,6 +85,17 @@ export default function ContextPopover(p: Props) {
{p.report.model &&
{p.report.model}
}
>
) :
上下文窗口—
}
+ {p.autoCompact && <>
+
生效压缩阈值
+ {p.autoCompact.applied_mode === "custom"
+ ? p.autoCompact.applied_threshold_tokens?.toLocaleString() ?? "待确认"
+ : p.report?.auto_compact_threshold_tokens?.toLocaleString() ?? "Claude 默认值"}
+
+ {p.autoCompact.pending &&
+ 已保存 {p.autoCompact.mode === "custom"
+ ? p.autoCompact.threshold_tokens?.toLocaleString() : "默认值"},等待生效
+
}
+ >}
{p.codexContext && <>
生效压缩阈值
{(p.report?.source === "native_estimate"
diff --git a/web/src/components/FileBrowserPanel.css b/web/src/components/FileBrowserPanel.css
index 3474d22d..51888796 100644
--- a/web/src/components/FileBrowserPanel.css
+++ b/web/src/components/FileBrowserPanel.css
@@ -5,7 +5,7 @@
.workspace-files[hidden] { display: none; }
.workspace-files .artifact-head { gap: 8px; }
.workspace-path { display: flex; gap: 8px; padding: 10px 16px 4px; }
-.workspace-path input { flex: 1; min-width: 0; border: 1px solid var(--border); background: var(--surface); color: var(--text); border-radius: 8px; padding: 9px; font: 12px var(--mono); }
+.workspace-path input { flex: 1; min-width: 0; border: 1px solid var(--border); background: var(--surface); color: var(--text); border-radius: 8px; padding: 9px; font: var(--font-weight-400) 12px var(--mono); }
.workspace-path button, .workspace-options button, .workspace-more, .workspace-back { display: inline-flex; align-items: center; gap: 4px; min-height: 32px; border: 0; border-radius: 6px; background: transparent; color: var(--dim); padding: 4px 6px; font-size: 13px; line-height: 20px; white-space: nowrap; cursor: pointer; }
.workspace-path button:disabled, .workspace-options button:disabled, .workspace-more:disabled { opacity: .45; cursor: default; }
.workspace-options { display: flex; justify-content: space-between; align-items: center; padding: 0 10px 4px; font-size: 12px; color: var(--dim); }
diff --git a/web/src/components/GoalDialog.css b/web/src/components/GoalDialog.css
index 50496c60..a22a536b 100644
--- a/web/src/components/GoalDialog.css
+++ b/web/src/components/GoalDialog.css
@@ -5,8 +5,8 @@
.goal-card .goal-sheet-head { display:flex; align-items:center; gap:10px; padding:25px 26px 14px;
border:0; background:none; flex:none; }
.goal-card .goal-sheet-icon { display:grid; place-items:center; width:28px; height:28px;
- flex:none; padding:0; border:0; background:none; color:var(--accent); }
-.goal-card .goal-sheet-head b { font:650 15px/1.4 var(--sans); color:var(--text); }
+ flex:none; padding:0; border:0; background:none; color:var(--accent-text,var(--accent)); }
+.goal-card .goal-sheet-head b { font:var(--font-weight-650) 15px/1.4 var(--sans); color:var(--text); }
.goal-card .goal-card-status { display:flex; align-items:center; gap:5px; margin-left:auto;
color:var(--dim); font-size:11px; white-space:nowrap; }
.goal-card-status i { width:5px; height:5px; border-radius:50%; background:var(--accent); }
@@ -22,20 +22,20 @@
.goal-card .goal-editor { display:block; }
.goal-card .goal-editor textarea { display:block; width:100%; box-sizing:border-box; min-height:145px;
border:0; border-radius:18px; padding:10px 2px; background:transparent; outline:none; box-shadow:none;
- color:var(--text); font:500 21px/1.7 var(--sans); font-size:21px !important; resize:none; }
-.goal-card .goal-editor textarea::placeholder { color:var(--faint); font-weight:400; }
+ color:var(--text); font:var(--font-weight-500) 21px/1.7 var(--sans); font-size:21px !important; resize:none; }
+.goal-card .goal-editor textarea::placeholder { color:var(--faint); font-weight:var(--font-weight-400); }
.goal-card .goal-editor textarea:focus { box-shadow:none; }
.goal-card .goal-hint { margin:12px 0 0; color:var(--dim); font-size:12px; line-height:1.6; }
.goal-card .goal-objective { margin:3px 0 24px; color:var(--text); font-size:20px;
- font-weight:500; line-height:1.65; white-space:pre-wrap; overflow-wrap:anywhere; }
+ font-weight:var(--font-weight-500); line-height:1.65; white-space:pre-wrap; overflow-wrap:anywhere; }
.goal-card .goal-sheet-actions { display:flex; align-items:center; justify-content:flex-end; gap:10px;
flex:none; position:relative; padding:18px 26px 24px; border:0; background:none; }
.goal-card .goal-sheet-actions::before { content:""; position:absolute; top:0; left:30px; right:30px;
height:1px; background:var(--divider); }
.goal-card .goal-sheet-actions button { min-height:40px; padding:9px 17px; border-radius:999px;
- display:inline-flex; align-items:center; justify-content:center; gap:7px; font-size:13px; font-weight:550; }
+ display:inline-flex; align-items:center; justify-content:center; gap:7px; font-size:13px; font-weight:var(--font-weight-550); }
.goal-card .goal-sheet-actions .goal-icon-button { min-width:36px; min-height:36px; width:36px; height:36px; padding:0; }
-.goal-card .goal-primary { color:var(--surface); background:var(--accent); min-width:105px; }
+.goal-card .goal-primary { color:var(--on-accent,var(--surface)); background:var(--accent); min-width:105px; }
.goal-card .goal-primary:hover { background:var(--accent); filter:brightness(1.08); }
.goal-card .goal-cancel,.goal-card .goal-edit { color:var(--dim); background:transparent; }
.goal-card .goal-edit { margin-right:auto; padding-left:3px; }
@@ -43,7 +43,7 @@
.goal-card .goal-budget { margin:20px 0 4px; }
.goal-card .goal-budget > div { display:flex; align-items:center; justify-content:space-between; gap:10px;
color:var(--dim); font-size:12px; }
-.goal-card .goal-budget b { font:500 14px var(--mono); color:var(--text); }
+.goal-card .goal-budget b { font:var(--font-weight-500) 14px var(--mono); color:var(--text); }
.goal-card .goal-budget em { font-style:normal; color:var(--dim); }
.goal-card progress { display:block; width:100%; height:6px; margin:12px 0; border:0; border-radius:999px;
appearance:none; overflow:hidden; background:var(--border); accent-color:var(--accent); }
@@ -55,7 +55,7 @@
padding:15px 18px; border:0; border-radius:19px; background:var(--raised); }
.goal-card .goal-stats > div { display:flex; flex-direction:column; gap:5px; border:0; padding:0; min-width:0; }
.goal-card .goal-stats small { color:var(--dim); font-size:11px; }
-.goal-card .goal-stats b { color:var(--text); font:550 15px var(--mono); white-space:nowrap; }
+.goal-card .goal-stats b { color:var(--text); font:var(--font-weight-550) 15px var(--mono); white-space:nowrap; }
.goal-card .goal-last-check { margin:22px 0 0; padding:0; background:none; border:0; }
.goal-card .goal-last-check small { display:block; margin-bottom:9px; color:var(--dim); font-size:11px; }
.goal-card .goal-last-check p { margin:0; padding:14px 17px; background:var(--accent-weak); border-radius:18px;
@@ -75,7 +75,7 @@
.goal-limit-picker label { display:flex; align-items:center; justify-content:space-between; gap:12px;
color:var(--dim); font-size:12px; }
.goal-limit-picker input { width:110px; min-width:0; border:1px solid var(--border-strong); border-radius:11px;
- padding:8px 10px; color:var(--text); background:var(--raised); font:16px var(--mono); }
+ padding:8px 10px; color:var(--text); background:var(--raised); font:var(--font-weight-400) 16px var(--mono); }
.goal-limit-picker input:focus { outline:2px solid var(--accent-line); }
.goal-limit-presets { display:flex; flex-wrap:wrap; gap:5px; margin-top:12px; }
.goal-card .goal-limit-presets button { padding:6px 10px; min-height:32px; color:var(--dim); background:var(--raised); font-size:12px; }
diff --git a/web/src/components/HeaderMenu.tsx b/web/src/components/HeaderMenu.tsx
index d016288a..0085a17d 100644
--- a/web/src/components/HeaderMenu.tsx
+++ b/web/src/components/HeaderMenu.tsx
@@ -1,12 +1,18 @@
-import { useEffect, useRef, useState, type CSSProperties } from "react";
+import { lazy, Suspense, useEffect, useRef, useState, type CSSProperties } from "react";
import { createPortal } from "react-dom";
import { Icon } from "../icons";
import type { NotificationMode } from "../notification-mode";
import type { PushBindingState } from "../push";
+import { themeLabel, type ThemeChoice } from "../themes";
+
+const ThemePicker = lazy(() => import("./ThemePicker"));
interface Props {
engine: "claude" | "codex";
theme: "light" | "dark";
+ themeChoice: ThemeChoice;
+ boldText: boolean;
+ onBoldText: (enabled: boolean) => void;
notificationMode: NotificationMode;
notificationBinding: PushBindingState;
notificationAvailable: boolean;
@@ -14,7 +20,7 @@ interface Props {
onOpenUsageActivity: () => void;
onOpenViewer?: () => void;
onOpenFiles?: () => void;
- onToggleTheme: () => void;
+ onSelectTheme: (choice: ThemeChoice) => void;
onLogout: () => void;
}
@@ -32,6 +38,9 @@ const MODE_LABELS: Record = {
export function HeaderMenu({
engine,
theme,
+ themeChoice,
+ boldText,
+ onBoldText,
notificationMode,
notificationBinding,
notificationAvailable,
@@ -39,13 +48,14 @@ export function HeaderMenu({
onOpenUsageActivity,
onOpenViewer,
onOpenFiles,
- onToggleTheme,
+ onSelectTheme,
onLogout,
}: Props) {
const triggerRef = useRef(null);
const cardRef = useRef(null);
const firstRef = useRef(null);
const [open, setOpen] = useState(false);
+ const [themeOpen, setThemeOpen] = useState(false);
const [page, setPage] = useState<"main" | "notifications">("main");
const [changingMode, setChangingMode] = useState(false);
const [position, setPosition] = useState({ top: 58, right: 8 });
@@ -184,9 +194,17 @@ export function HeaderMenu({
+ onClick={() => { close(); setThemeOpen(true); }}>
- 主题{theme === "dark" ? "深色" : "浅色"}
+ 主题{themeLabel(themeChoice)}
+
+
+ onBoldText(!boldText)}>
+
+ 加粗字体整页文字加粗,更易阅读
+
@@ -234,6 +252,10 @@ export function HeaderMenu({
,
document.body,
)}
+ {themeOpen &&
+ setThemeOpen(false)} returnFocusRef={triggerRef} />
+ }
>
);
}
diff --git a/web/src/components/LazyToolDetails.tsx b/web/src/components/LazyToolDetails.tsx
new file mode 100644
index 00000000..eb84beb0
--- /dev/null
+++ b/web/src/components/LazyToolDetails.tsx
@@ -0,0 +1,16 @@
+import { lazy, Suspense, type ComponentProps } from "react";
+
+const Input = lazy(() => import("./ToolDetails").then((module) => ({ default: module.ToolInput })));
+const Output = lazy(() => import("./ToolDetails").then((module) => ({ default: module.ToolOutput })));
+
+export function ToolInput(props: ComponentProps
) {
+ return 读取参数…}>
+
+ ;
+}
+
+export function ToolOutput(props: ComponentProps) {
+ return 读取输出…}>
+
+ ;
+}
diff --git a/web/src/components/ProcessTimeline.tsx b/web/src/components/ProcessTimeline.tsx
index a5814d6f..61550f05 100644
--- a/web/src/components/ProcessTimeline.tsx
+++ b/web/src/components/ProcessTimeline.tsx
@@ -20,6 +20,8 @@ import { MessageBlock } from "./MessageBlock";
import { PreviewAuthorizationPrompt } from "./PreviewAuthorizationPrompt";
import { HistoryUserImage } from "./HistoryUserImage";
import { ToolGroup } from "./ToolGroup";
+import { ToolInput, ToolOutput } from "./LazyToolDetails";
+import { displayCommand } from "../tool-command";
import {
hasActiveProcess,
presentableProcessBlocks,
@@ -403,6 +405,16 @@ export function ProcessActivity({ block, onOpenFile, imageAssets, onLoadImage,
onInteractionEnd?: (token: number, followOutput?: boolean) => void;
onOpenAgent?: (runId: string, title?: string) => void;
}) {
+ if (block.processKind === "compaction" && !block.done && block.status === "running") {
+ return (
+
+
+
+
+ 正在压缩上下文
+
+ );
+ }
if (block.processKind === "agent" && onOpenAgent) {
return (
)}
- {block.command && $ {block.command}}
+ {block.command && $ {displayCommand(block.command)}}
{block.cwd && {block.cwd}
}
{block.summary && !imageView
&& {block.summary}
}
- {block.detail && {block.detail}}
+ {block.detail && }
{onOpenFile && filePaths.map((filePath) => (
onOpenFile(filePath)}>
@@ -485,9 +497,12 @@ export function ProcessActivity({ block, onOpenFile, imageAssets, onLoadImage,
)}
{block.input && Object.keys(block.input).length > 0
&& filePaths.length === 0 && !imageView && (
- {JSON.stringify(block.input, null, 2)}
+
)}
- {block.output && {block.output}{block.truncated ? "\n…(truncated)" : ""}}
+ {block.output && }
{block.diff && {block.diff}}
{(block.exit_code != null || block.duration_ms != null) && (
@@ -525,29 +540,6 @@ export function ProcessActivity({ block, onOpenFile, imageAssets, onLoadImage,
);
}
-export function BackgroundProcessDock({ processes, onOpenFile, onOpenAgent }: {
- processes: ProcessBlock[];
- onOpenFile?: (path: string, line?: number) => void;
- onOpenAgent?: (runId: string, title?: string) => void;
-}) {
- if (processes.length === 0) return null;
- return (
-
- );
-}
-
function TimelineItem({ block, onOpenFile, imageAssets, onLoadImage,
onAuthorizeImage, onPreviewImage,
historyTurnId, historyImageAssets, onLoadHistoryImage,
@@ -960,13 +952,14 @@ export function ProcessTimeline({ blocks, done, active, outcome, problem, durati
}
toggle();
}}>
-
+ {(terminalOutcome || (detailLoading && !processActive)) &&
{detailLoading && !processActive
?
- : }
-
- {terminalOutcome ? presentTurnOutcome(terminalOutcome, problem)
+ : }
+ }
+
+ {terminalOutcome ? presentTurnOutcome(terminalOutcome, problem)
: processSettled ? "已处理" : "正在处理"}
{elapsed == null ? null : ` ${durationLabel(elapsed)}`}
{countLabel}
@@ -1015,7 +1008,8 @@ export function ProcessTimeline({ blocks, done, active, outcome, problem, durati
)}
{rows.map((row) => (
row.kind === "tools"
- ?
+ ?
: ;
+ onClose: () => void;
+ children: ReactNode;
+}) {
+ const menuRef = useRef(null);
+
+ useLayoutEffect(() => {
+ const trigger = anchor.current;
+ const menu = menuRef.current;
+ const sidebar = trigger?.closest(".sessions");
+ const scroll = trigger?.closest(".s-scroll");
+ const footer = sidebar?.querySelector(".s-foot");
+ if (!trigger || !menu || !sidebar || !scroll || !footer) return;
+ const viewport = window.visualViewport;
+ const position = () => {
+ const button = trigger.getBoundingClientRect();
+ const list = scroll.getBoundingClientRect();
+ if (!trigger.isConnected || button.bottom <= list.top || button.top >= list.bottom) {
+ onClose();
+ return;
+ }
+ const panel = sidebar.getBoundingClientRect();
+ const viewLeft = viewport?.offsetLeft ?? 0;
+ const viewTop = viewport?.offsetTop ?? 0;
+ const left = Math.max(panel.left, viewLeft) + 8;
+ const right = Math.min(panel.right, viewLeft + (viewport?.width ?? window.innerWidth)) - 8;
+ const top = Math.max(panel.top, viewTop) + 8;
+ const bottom = Math.min(footer.getBoundingClientRect().top,
+ viewTop + (viewport?.height ?? window.innerHeight)) - 8;
+ const width = Math.max(0, right - left);
+ menu.style.minWidth = `${Math.min(152, width)}px`;
+ menu.style.maxWidth = `${width}px`;
+ const height = menu.scrollHeight + menu.offsetHeight - menu.clientHeight;
+ const above = Math.max(0, Math.min(button.top - 6, bottom) - top);
+ const below = Math.max(0, bottom - Math.max(button.bottom + 6, top));
+ const upward = height > below && above > below;
+ const available = upward ? above : below;
+ menu.style.maxHeight = `${available}px`;
+ const box = menu.getBoundingClientRect();
+ menu.style.left = `${Math.max(left, Math.min(button.right - box.width, right - box.width))}px`;
+ menu.style.top = `${Math.max(top, Math.min(
+ upward ? button.top - 6 - box.height : button.bottom + 6, bottom - box.height,
+ ))}px`;
+ menu.dataset.placement = upward ? "above" : "below";
+ menu.style.visibility = "visible";
+ };
+ const escape = (event: KeyboardEvent) => {
+ if (event.key !== "Escape") return;
+ event.preventDefault();
+ event.stopPropagation();
+ onClose();
+ trigger.focus({ preventScroll: true });
+ };
+ position();
+ if (document.activeElement === trigger && trigger.matches(":focus-visible")) {
+ menu.querySelector("button:not(:disabled)")?.focus({ preventScroll: true });
+ }
+ const observer = new ResizeObserver(position);
+ for (const element of [menu, trigger, sidebar, scroll, footer]) observer.observe(element);
+ window.addEventListener("resize", position);
+ window.addEventListener("scroll", position, true);
+ viewport?.addEventListener("resize", position);
+ viewport?.addEventListener("scroll", position);
+ sidebar.addEventListener("transitionend", position);
+ document.addEventListener("keydown", escape, true);
+ return () => {
+ observer.disconnect();
+ window.removeEventListener("resize", position);
+ window.removeEventListener("scroll", position, true);
+ viewport?.removeEventListener("resize", position);
+ viewport?.removeEventListener("scroll", position);
+ sidebar.removeEventListener("transitionend", position);
+ document.removeEventListener("keydown", escape, true);
+ };
+ }, [anchor, onClose]);
+
+ return typeof document === "undefined" ? null : createPortal(
+ event.stopPropagation()}
+ onTouchStart={event => event.stopPropagation()}
+ onKeyDown={event => {
+ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ const items = [...event.currentTarget.querySelectorAll("button:not(:disabled)")];
+ const index = items.indexOf(document.activeElement as HTMLButtonElement);
+ const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
+ : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
+ items[next]?.focus();
+ }}>
+ {children}
+
, document.body,
+ );
+}
diff --git a/web/src/components/SessionsSidebar.tsx b/web/src/components/SessionsSidebar.tsx
index a65635a1..6a860167 100644
--- a/web/src/components/SessionsSidebar.tsx
+++ b/web/src/components/SessionsSidebar.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState, type TouchEvent } from "react";
+import { lazy, Suspense, useEffect, useRef, useState, type TouchEvent } from "react";
import type { ClaudeProfileInfo, CodexProfileInfo, Engine, SessionInfo, Space, State } from "../protocol";
import type { CompletionBadgeKind } from "../completion-badges";
import { Icon, ClaudeMark } from "../icons";
@@ -19,6 +19,8 @@ import { codexProfilePresentation } from "../codex-profile-presentation";
import { newWorkProfileForSidebarFilter } from "../work-profile-selection";
import { manualUnreadKey } from "../manual-unread";
import { useManualUnread } from "../use-manual-unread";
+const SessionCardMenu = lazy(() => import("./SessionCardMenu").then(module => ({ default: module.SessionCardMenu })));
+const TimedTaskIndicator = lazy(() => import("./TimedTaskIndicator").then(module => ({ default: module.TimedTaskIndicator })));
interface Props {
open: boolean;
@@ -80,6 +82,7 @@ export function SessionsSidebar({ open, engine, space,
const manualUnread = useManualUnread();
const [q, setQ] = useState("");
const [menuCardId, setMenuCardId] = useState(null);
+ const menuAnchor = useRef(null);
const [lifting, setLifting] = useState(false);
const [renaming, setRenaming] = useState<{ id: string; value: string } | null>(null);
const [copiedId, setCopiedId] = useState(null);
@@ -330,6 +333,7 @@ export function SessionsSidebar({ open, engine, space,
{s.summary || (s.first_prompt || "").slice(0, 40) || s.session_id.slice(0, 8)}
{isActive &&
当前}
+ {!isArchived && !!s.timed_tasks?.length &&
}
{sessionBusy && (
{st === "running" ? "运行" : "中断"}
)}
@@ -345,13 +349,14 @@ export function SessionsSidebar({ open, engine, space,
)}
{s.first_prompt && !s.summary &&
{s.first_prompt}
}
- { e.stopPropagation(); setMenuCardId(isMenu ? null : s.session_id); setLifting(false); }}>
{isMenu && (
-
e.stopPropagation()}>
+
{capabilities.rename && (
startRename(s)}>重命名
)}
@@ -390,7 +395,7 @@ export function SessionsSidebar({ open, engine, space,
{space === "work" ? "删除工作" : "删除会话"}
)}
-
+
)}
);
diff --git a/web/src/components/SpreadsheetPreview.css b/web/src/components/SpreadsheetPreview.css
index 670c8dbd..4f64e842 100644
--- a/web/src/components/SpreadsheetPreview.css
+++ b/web/src/components/SpreadsheetPreview.css
@@ -7,9 +7,9 @@
.spreadsheet-scroll{overflow:auto;flex:1;min-height:100px;overscroll-behavior:contain;border-block:1px solid var(--border);background:var(--surface)}
.spreadsheet-scroll table{border-collapse:separate;border-spacing:0;min-width:100%;font-size:13px;margin:0}
.spreadsheet-scroll td,.spreadsheet-scroll th{border-right:1px solid var(--border);border-bottom:1px solid var(--border);padding:7px 10px;min-width:100px;max-width:350px;white-space:pre-wrap;overflow-wrap:anywhere;text-align:left;vertical-align:top}
-.spreadsheet-scroll thead th{position:sticky;top:0;background:var(--raised);text-align:center;color:var(--dim);font-weight:500;z-index:2}
-.spreadsheet-scroll tbody th,.spreadsheet-scroll thead th:first-child{position:sticky;left:0;background:var(--raised);color:var(--dim);font-weight:400;min-width:44px;text-align:right;z-index:1}
+.spreadsheet-scroll thead th{position:sticky;top:0;background:var(--raised);text-align:center;color:var(--dim);font-weight:var(--font-weight-500);z-index:2}
+.spreadsheet-scroll tbody th,.spreadsheet-scroll thead th:first-child{position:sticky;left:0;background:var(--raised);color:var(--dim);font-weight:var(--font-weight-400);min-width:44px;text-align:right;z-index:1}
.spreadsheet-scroll thead th:first-child{z-index:3}
.spreadsheet-note{margin:0;padding:10px 16px;color:var(--dim);font-size:12px;line-height:1.5}
-.spreadsheet-note strong{font-weight:500;color:var(--accent-ink)}
+.spreadsheet-note strong{font-weight:var(--font-weight-500);color:var(--accent-ink)}
.spreadsheet-empty{padding:30px;text-align:center;color:var(--dim)}
diff --git a/web/src/components/ThemePicker.css b/web/src/components/ThemePicker.css
new file mode 100644
index 00000000..9b09091e
--- /dev/null
+++ b/web/src/components/ThemePicker.css
@@ -0,0 +1,69 @@
+.theme-picker-intro { display:flex; align-items:center; flex-wrap:wrap; gap:9px; padding:0 20px 18px; }
+.theme-picker-intro > span { font-size:11px; font-weight:var(--font-weight-600); padding:3px 9px; border-radius:999px;
+ color:var(--accent-ink); background:var(--accent-weak); }
+.theme-picker-intro p { margin:0; font-size:12px; color:var(--dim); }
+.theme-picker .theme-picker-scroll { padding:0 20px 20px; }
+.theme-basics { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
+.theme-basics button { display:flex; align-items:center; justify-content:center; gap:7px; min-height:44px;
+ padding:8px; font-size:12px; border:1px solid var(--border); border-radius:13px; background:var(--raised); color:var(--dim); }
+.theme-basics button[aria-pressed="true"] { color:var(--accent-ink); background:var(--accent-weak); border-color:var(--accent-line); }
+.theme-basics button > svg { flex:none; }
+.theme-section-label { display:flex; align-items:center; justify-content:space-between; gap:12px;
+ margin:22px 0 12px; font-size:12px; font-weight:var(--font-weight-600); }
+.theme-section-label span { font-size:11px; font-weight:var(--font-weight-400); color:var(--dim); }
+.theme-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
+.theme-card { display:flex; flex-direction:column; min-width:0; text-align:left; border:1px solid var(--border);
+ border-radius:17px; padding:7px 7px 11px; background:var(--bg); transition:border-color .16s,box-shadow .16s; }
+.theme-card:hover { border-color:var(--border-strong); box-shadow:var(--shadow-sm); }
+.theme-card[aria-pressed="true"] { border-color:var(--accent-ink); box-shadow:0 0 0 1px var(--accent-ink); }
+.theme-miniature { position:relative; display:flex; width:100%; aspect-ratio:1.68; overflow:hidden;
+ border:1px solid var(--border); border-radius:11px; background:var(--bg); }
+.theme-mini-sidebar { width:27%; flex:none; display:flex; flex-direction:column; gap:8px; padding:13px 6px;
+ background:var(--sidebar); border-right:1px solid var(--border); }
+.theme-mini-sidebar i { height:3px; width:75%; border-radius:3px; background:var(--dim); opacity:.35; }
+.theme-mini-sidebar b { height:10px; border-radius:4px; background:var(--accent-weak); border:1px solid var(--accent-line); }
+.theme-mini-chat { display:flex; flex:1; min-width:0; flex-direction:column; gap:8px; padding:9px 8px 7px; }
+.theme-mini-head { display:flex; justify-content:space-between; align-items:center; }
+.theme-mini-head i { height:3px; width:28%; border-radius:2px; background:var(--dim); opacity:.6; }
+.theme-mini-head b { height:5px; width:5px; border-radius:50%; background:var(--accent); }
+.theme-mini-bubble { display:block; align-self:flex-end; width:61%; height:12px; border-radius:5px;
+ background:var(--accent-weak); border:1px solid var(--accent-line); }
+.theme-mini-lines { display:flex; flex-direction:column; gap:4px; }
+.theme-mini-lines i { height:2px; width:87%; border-radius:2px; background:var(--text); opacity:.22; }
+.theme-mini-lines i:nth-child(2) { width:73%; }
+.theme-mini-lines i:last-child { width:42%; }
+.theme-mini-input { display:flex; align-items:center; justify-content:space-between; min-height:13px; padding:3px 4px;
+ margin-top:auto; border:1px solid var(--border); border-radius:6px; background:var(--surface); }
+.theme-mini-input i { width:40%; height:2px; background:var(--dim); opacity:.35; }
+.theme-mini-input b { width:6px; height:6px; border-radius:50%; background:var(--accent); }
+.theme-selected { position:absolute; top:5px; right:5px; width:22px; height:22px; display:grid; place-items:center;
+ border-radius:50%; background:var(--accent); color:var(--on-accent); box-shadow:0 1px 5px rgb(0 0 0 / .15); }
+.theme-card-name { display:flex; align-items:center; justify-content:space-between; gap:5px; padding:9px 4px 0; font-size:12px; font-weight:var(--font-weight-600); }
+.theme-card-name > span { font-size:10px; font-weight:var(--font-weight-400); color:var(--dim); }
+.theme-card-description { padding:3px 4px 0; font-size:10.5px; color:var(--dim); }
+.theme-picker-footer { display:flex; align-items:center; justify-content:space-between; gap:12px; flex:none;
+ padding:12px 20px; border-top:1px solid var(--border); }
+.theme-picker-footer > span { color:var(--dim); font-size:12px; }
+.theme-picker-footer > button { padding:8px 22px; min-height:38px; border-radius:999px;
+ background:var(--accent); color:var(--on-accent,#fff); font-size:12px; font-weight:var(--font-weight-600); }
+@media (max-width:600px) {
+ .theme-picker-intro { padding:0 14px 14px; gap:6px; }
+ .theme-picker-intro p { font-size:11px; }
+ .theme-picker .theme-picker-scroll { padding:0 14px 14px; }
+ .theme-basics { gap:5px; }
+ .theme-basics button { gap:4px; padding:7px 4px; font-size:11px; }
+ .theme-basics button > svg { width:15px; }
+ .theme-grid { grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
+ .theme-section-label { margin-top:17px; }
+ .theme-card { border-radius:15px; padding-bottom:9px; }
+ .theme-miniature { height:66px; aspect-ratio:auto; }
+ .theme-mini-sidebar { gap:5px; padding:8px 6px; }
+ .theme-mini-chat { gap:5px; padding:6px; }
+ .theme-mini-lines { gap:3px; }
+ .theme-mini-bubble { height:9px; }
+ .theme-mini-input { min-height:11px; padding:2px 4px; }
+ .theme-picker-footer { padding:10px 14px max(10px,env(safe-area-inset-bottom)); }
+}
+@media (prefers-reduced-motion:reduce) {
+ .theme-card,.theme-basics button { transition:none; }
+}
diff --git a/web/src/components/ThemePicker.tsx b/web/src/components/ThemePicker.tsx
new file mode 100644
index 00000000..5061f644
--- /dev/null
+++ b/web/src/components/ThemePicker.tsx
@@ -0,0 +1,54 @@
+import type { RefObject } from "react";
+import { Icon } from "../icons";
+import { THEME_PALETTES, themeLabel, type ThemeChoice, type ThemeEngine } from "../themes";
+import { CenteredSheet } from "./CenteredSheet";
+import "./ThemePicker.css";
+
+export default function ThemePicker({ engine, choice, onSelect, onClose, returnFocusRef }: {
+ engine: ThemeEngine;
+ choice: ThemeChoice;
+ onSelect: (choice: ThemeChoice) => void;
+ onClose: () => void;
+ returnFocusRef?: RefObject;
+}) {
+ return
+
+
{({ claude: "Claude", codex: "Codex", dsh: "DSH" })[engine]}
+
点击即切换,为当前引擎单独记住。
+
+
+
+ {(["system", "light", "dark"] as const).map(id => onSelect(id)}>
+
+ {themeLabel(id)}
+ {choice === id && }
+ )}
+
+
柔和配色 低饱和 · 轻灰调
+
+ {THEME_PALETTES.map(palette => onSelect(palette.id)}>
+
+
+
+
+
+
+
+
+ {choice === palette.id && }
+
+ {palette.name}{palette.mode === "light" ? "浅" : "深"}
+ {palette.description}
+ )}
+
+
+
+ ;
+}
diff --git a/web/src/components/TimedMessageTag.tsx b/web/src/components/TimedMessageTag.tsx
new file mode 100644
index 00000000..c852e8a6
--- /dev/null
+++ b/web/src/components/TimedMessageTag.tsx
@@ -0,0 +1,10 @@
+import type { TimedMessage } from "../protocol";
+import { Icon } from "../icons";
+import "./timed-task.css";
+
+export function TimedMessageTag({ task }: { task: TimedMessage }) {
+ return
+ 定时任务
+ {task.title}计划发送于 {new Date(task.scheduled_at * 1000).toLocaleString()}
+ ;
+}
diff --git a/web/src/components/TimedTaskIndicator.tsx b/web/src/components/TimedTaskIndicator.tsx
new file mode 100644
index 00000000..780b9f72
--- /dev/null
+++ b/web/src/components/TimedTaskIndicator.tsx
@@ -0,0 +1,139 @@
+import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import type { TimedTaskInfo } from "../protocol";
+import { Icon } from "../icons";
+import "./timed-task.css";
+
+function remaining(seconds: number) {
+ if (seconds <= 0) return "正在发送";
+ if (seconds < 60) return `约 ${Math.ceil(seconds)} 秒后`;
+ if (seconds < 3600) return `约 ${Math.ceil(seconds / 60)} 分钟后`;
+ return `约 ${Math.ceil(seconds / 3600)} 小时后`;
+}
+
+function intervalLabel(seconds: number) {
+ if (seconds % 3600 === 0) return `每 ${seconds / 3600} 小时`;
+ if (seconds % 60 === 0) return `每 ${seconds / 60} 分钟`;
+ return `每 ${seconds} 秒`;
+}
+
+/** A task clock is independent of model activity and completion receipts. */
+export function TimedTaskIndicator({ tasks, hidden = false }: {
+ tasks: TimedTaskInfo[]; hidden?: boolean;
+}) {
+ const buttonRef = useRef(null);
+ const panelRef = useRef(null);
+ const closeTimer = useRef | undefined>(undefined);
+ const [open, setOpen] = useState(false);
+ const [now, setNow] = useState(() => Date.now() / 1000);
+ const id = useId();
+ const active = tasks.filter(task => task.valid_until > now
+ && task.sent_count < task.total_count).sort((a, b) => a.next_message_at - b.next_message_at);
+ const task = active[0];
+ const hasTask = !!task;
+ const visible = open && !hidden && !!task;
+ useEffect(() => {
+ if (!hasTask) return;
+ const timer = window.setInterval(() => setNow(Date.now() / 1000), 1000);
+ return () => window.clearInterval(timer);
+ }, [hasTask]);
+ useEffect(() => {
+ if (hidden) setOpen(false);
+ }, [hidden]);
+ useEffect(() => {
+ const button = buttonRef.current;
+ const card = button?.closest(".scard");
+ if (!card) return;
+ const show = () => { clearTimeout(closeTimer.current); setOpen(true); };
+ const hide = () => { closeTimer.current = setTimeout(() => setOpen(false), 160); };
+ const focus = (event: Event) => { if (event.target === button) show(); };
+ const dismiss = (event: PointerEvent) => {
+ if (!card.contains(event.target as Node) && !panelRef.current?.contains(event.target as Node)) setOpen(false);
+ };
+ const escape = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setOpen(false);
+ };
+ card.addEventListener("mouseenter", show);
+ card.addEventListener("mouseleave", hide);
+ card.addEventListener("focusin", focus);
+ card.addEventListener("focusout", hide);
+ document.addEventListener("pointerdown", dismiss);
+ document.addEventListener("keydown", escape);
+ return () => {
+ clearTimeout(closeTimer.current);
+ card.removeEventListener("mouseenter", show);
+ card.removeEventListener("mouseleave", hide);
+ card.removeEventListener("focusin", focus);
+ card.removeEventListener("focusout", hide);
+ document.removeEventListener("pointerdown", dismiss);
+ document.removeEventListener("keydown", escape);
+ };
+ }, [hasTask]);
+ useLayoutEffect(() => {
+ const trigger = buttonRef.current;
+ const card = trigger?.closest(".scard");
+ const panel = panelRef.current;
+ if (!visible || !card || !panel) return;
+ const viewport = window.visualViewport;
+ const position = () => {
+ const box = card.getBoundingClientRect();
+ const scroll = card.closest(".s-scroll")!.getBoundingClientRect();
+ if (box.bottom <= scroll.top || box.top >= scroll.bottom) {
+ setOpen(false);
+ return;
+ }
+ const left = (viewport?.offsetLeft ?? 0) + 8;
+ const top = (viewport?.offsetTop ?? 0) + 8;
+ const right = left + (viewport?.width ?? window.innerWidth) - 16;
+ const footer = card.closest(".sessions")?.querySelector(".s-foot")?.getBoundingClientRect();
+ const bottom = Math.min(top + (viewport?.height ?? window.innerHeight) - 16,
+ footer?.top ?? Infinity) - 8;
+ panel.style.maxWidth = `${Math.max(0, right - left)}px`;
+ panel.style.maxHeight = `${Math.max(0, bottom - top)}px`;
+ const size = panel.getBoundingClientRect();
+ const beside = box.right + 10 + size.width <= right;
+ panel.style.left = `${beside ? box.right + 10 : Math.max(left, Math.min(box.right - size.width, right - size.width))}px`;
+ const below = box.bottom + 8 + size.height <= bottom;
+ panel.style.top = `${Math.max(top, Math.min(beside ? box.top : below ? box.bottom + 8 : box.top - size.height - 8, bottom - size.height))}px`;
+ panel.dataset.placement = beside ? "right" : below ? "below" : "above";
+ panel.style.visibility = "visible";
+ };
+ position();
+ const observer = new ResizeObserver(position);
+ observer.observe(card);
+ observer.observe(panel);
+ window.addEventListener("resize", position);
+ window.addEventListener("scroll", position, true);
+ viewport?.addEventListener("resize", position);
+ viewport?.addEventListener("scroll", position);
+ return () => {
+ observer.disconnect();
+ window.removeEventListener("resize", position);
+ window.removeEventListener("scroll", position, true);
+ viewport?.removeEventListener("resize", position);
+ viewport?.removeEventListener("scroll", position);
+ };
+ }, [visible]);
+ if (!task) return null;
+ return <>
+
+ event.stopPropagation()}
+ onClick={event => { event.stopPropagation(); setOpen(true); }}>
+
+
+ {visible && typeof document !== "undefined" && createPortal(
+ { clearTimeout(closeTimer.current); setOpen(true); }} onMouseLeave={() => setOpen(false)}
+ onClick={event => event.stopPropagation()}>
+
定时任务仍在处理
+
{task.title}{active.length > 1 && · 共 {active.length} 项}
+
下一条消息
+ {new Date(task.next_message_at * 1000).toLocaleTimeString([], { hour12: false })}
+
{remaining(task.next_message_at - now)}
+
{task.total_count > 1 ? `${intervalLabel(task.interval_seconds)} · ` : ""}
+ 已发送 {task.sent_count}/{task.total_count} 次
+
, document.body)}
+ >;
+}
diff --git a/web/src/components/ToolCallCard.tsx b/web/src/components/ToolCallCard.tsx
index c9a9028c..3c2c7e20 100644
--- a/web/src/components/ToolCallCard.tsx
+++ b/web/src/components/ToolCallCard.tsx
@@ -8,6 +8,7 @@ import {
releaseDraggedPointer,
} from "../pointer-tap";
import { isToolFailure, presentTool } from "../tool-presentation";
+import { ToolInput, ToolOutput } from "./ToolDetails";
function EditDiff({ oldString, newString, serverDiff }: {
oldString: string; newString: string; serverDiff?: string | null;
@@ -118,10 +119,7 @@ export function ToolCallCard({ block }: { block: ToolBlock }) {
{inp.content}
>
) : hasInput ? (
- <>
- 输入
- {JSON.stringify(block.input, null, 2)}
- >
+
) : null}
{diff && !isEdit && (
<>
@@ -130,13 +128,9 @@ export function ToolCallCard({ block }: { block: ToolBlock }) {
>
)}
{output && (
- <>
- 输出{block.result?.is_error ? " (error)" : ""}
-
- {output}
- {block.result?.truncated && "\n…(truncated)"}
-
- >
+
)}
{block.result && (block.result.exit_code != null || block.result.duration_ms != null) && (
diff --git a/web/src/components/ToolDetails.tsx b/web/src/components/ToolDetails.tsx
new file mode 100644
index 00000000..f0516c56
--- /dev/null
+++ b/web/src/components/ToolDetails.tsx
@@ -0,0 +1,34 @@
+import { useMemo, useState } from "react";
+import { readableToolInput, readableToolOutput } from "../tool-details";
+
+function RawToolData({ label, value }: { label: string; value: unknown }) {
+ const [open, setOpen] = useState(false);
+ return
setOpen(event.currentTarget.open)}>
+ {label}
+ {open && {typeof value === "string"
+ ? value : JSON.stringify(value, null, 2)}}
+ ;
+}
+
+export function ToolInput({ input, omit = [] }: {
+ input: Record
; omit?: string[];
+}) {
+ return <>
+ {readableToolInput(input, omit).map(({ label, text }) => )}
+
+ >;
+}
+
+export function ToolOutput({ output, truncated, label = "输出" }: {
+ output: string; truncated?: boolean | null; label?: string;
+}) {
+ const projection = useMemo(() => readableToolOutput(output), [output]);
+ return <>
+ {label}
+ {projection.text}{truncated ? "\n…(输出已截断)" : ""}
+ {projection.unwrapped && }
+ >;
+}
diff --git a/web/src/components/ToolGroup.tsx b/web/src/components/ToolGroup.tsx
index feaa1c96..f0e2f4de 100644
--- a/web/src/components/ToolGroup.tsx
+++ b/web/src/components/ToolGroup.tsx
@@ -1,4 +1,4 @@
-import { useRef, useState } from "react";
+import { lazy, Suspense, useRef, useState } from "react";
import type { ToolBlock } from "../domain/conversation";
import { Icon } from "../icons";
import {
@@ -6,18 +6,21 @@ import {
PointerTapGuard,
releaseDraggedPointer,
} from "../pointer-tap";
-import { ToolCallCard } from "./ToolCallCard";
import { isToolFailure, presentTool } from "../tool-presentation";
+const ToolCallCard = lazy(() => import("./ToolCallCard").then((module) => ({ default: module.ToolCallCard })));
+
/** Collapsible group for tool calls within a turn (Claude-app style: a gray
* summary line "N 个工具调用 · Bash ×2 · Edit ×1" that expands to the individual
* tool cards). ALWAYS collapsed by default — even while running, only the
- * summary + spinner show; the busy stack of Bash/Edit cards is hidden until
+ * summary shows live activity; the busy stack of Bash/Edit cards is hidden until
* the user clicks. */
-export function ToolGroup({ tools }: { tools: ToolBlock[] }) {
+export function ToolGroup({ tools, active = true }: {
+ tools: ToolBlock[]; active?: boolean;
+}) {
const [open, setOpen] = useState(false);
const tapGuard = useRef(new PointerTapGuard());
- const running = tools.some((t) => !t.done);
+ const running = active && tools.some((t) => !t.done);
const hasErr = tools.some(isToolFailure);
const counts: Record = {};
@@ -53,18 +56,19 @@ export function ToolGroup({ tools }: { tools: ToolBlock[] }) {
event.preventDefault();
if (tapGuard.current.consumeClick(event.detail)) setOpen(!open);
}}>
-
- {running ? : }
-
-
- {running ? `正在调用 ${tools.length} 个工具` : `${tools.length} 个工具调用`}
- {hasErr && !running && · 有错}
+
+
+ {running ? `正在调用 ${tools.length} 个工具` : `${tools.length} 个工具调用`}
+ {hasErr && !running && · 有错}
+
+ {sub}
- {sub}
{open &&
+ 读取工具详情…}>
{tools.map((t) => )}
+
}
);
diff --git a/web/src/components/TurnUsageIndicator.css b/web/src/components/TurnUsageIndicator.css
new file mode 100644
index 00000000..45d56ec0
--- /dev/null
+++ b/web/src/components/TurnUsageIndicator.css
@@ -0,0 +1,20 @@
+.turn-working{ flex-wrap:wrap; row-gap:4px; }
+.turn-usage-trigger{ display:inline-flex; align-items:center; gap:12px; padding:3px 0;
+ margin-left:5px; border:0; border-radius:4px; background:transparent; color:var(--dim);
+ font:inherit; font-size:12.5px; font-weight:var(--font-weight-400); cursor:pointer;
+ font-variant-numeric:tabular-nums; white-space:nowrap; line-height:1.5; }
+.turn-usage-trigger:focus-visible{ outline:2px solid var(--dim); outline-offset:4px; }
+.turn-usage-unit{ font-weight:inherit; }
+.turn-usage-popover{ position:fixed; z-index:90; box-sizing:border-box; overflow:auto;
+ transform:translate(-50%,-100%); padding:16px 18px; border:1px solid var(--border);
+ border-radius:16px; background:var(--surface); color:var(--text);
+ box-shadow:0 8px 32px rgb(0 0 0 / 16%); font-size:13px; }
+.turn-usage-popover.place-below{ transform:translate(-50%,0); }
+.turn-usage-heading{ display:flex; justify-content:space-between; gap:12px;
+ margin-bottom:10px; font-weight:var(--font-weight-600); }
+.turn-usage-heading>span{ color:var(--dim); font-weight:var(--font-weight-400); }
+.turn-usage-row{ display:flex; align-items:center; justify-content:space-between; gap:16px;
+ padding:5px 0; color:var(--dim); }
+.turn-usage-row strong{ color:var(--text); font-weight:var(--font-weight-600);
+ font-variant-numeric:tabular-nums; }
+@media(max-width:480px){ .turn-usage-trigger{ gap:9px; margin-left:2px; } }
diff --git a/web/src/components/TurnUsageIndicator.tsx b/web/src/components/TurnUsageIndicator.tsx
new file mode 100644
index 00000000..de8ff910
--- /dev/null
+++ b/web/src/components/TurnUsageIndicator.tsx
@@ -0,0 +1,56 @@
+import { useEffect, useId, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import type { TokenUsage } from "../protocol";
+import { useAnchoredPopoverGeometry } from "../chat-dialog-geometry";
+import { compactTokens } from "../turn-usage";
+import "./TurnUsageIndicator.css";
+
+export function TurnUsageIndicator({ usage }: { usage: TokenUsage | undefined }) {
+ const [open, setOpen] = useState(false);
+ const trigger = useRef(null);
+ const card = useRef(null);
+ const id = useId();
+ const position = useAnchoredPopoverGeometry({ open, anchorRef: trigger,
+ maxWidth: 300, maxHeight: 260, minimumHeight: 190, align: "start" });
+ useEffect(() => {
+ if (!open) return;
+ const close = () => setOpen(false);
+ const outside = (event: PointerEvent) => {
+ if (event.target instanceof Node && !trigger.current?.contains(event.target)
+ && !card.current?.contains(event.target)) close();
+ };
+ const escape = (event: KeyboardEvent) => { if (event.key === "Escape") close(); };
+ document.addEventListener("pointerdown", outside, true);
+ document.addEventListener("keydown", escape);
+ return () => {
+ document.removeEventListener("pointerdown", outside, true);
+ document.removeEventListener("keydown", escape);
+ };
+ }, [open]);
+ if (!usage) return null;
+ const rows = [
+ ["输入", usage.input_tokens], ["输出", usage.output_tokens],
+ ["缓存读取", usage.cache_read_tokens], ["缓存写入", usage.cache_write_tokens],
+ ] as const;
+ const style = position ? {
+ left: position.left, top: position.top, width: position.width,
+ maxHeight: position.maxHeight,
+ } : undefined;
+ return <>
+ setOpen(current => !current)}>
+ ↑ {compactTokens(usage.input_tokens)}
+ ↓ {compactTokens(usage.output_tokens)} tokens
+
+ {open && position && createPortal(
+
+
本轮用量 tokens
+ {rows.map(([label, value]) =>
+ {label}{value == null ? "—" : value.toLocaleString("en-US")}
+
)}
+
, document.body)}
+ >;
+}
diff --git a/web/src/components/WorkArtifactsSheet.tsx b/web/src/components/WorkArtifactsSheet.tsx
index 3417dcb0..9b68657a 100644
--- a/web/src/components/WorkArtifactsSheet.tsx
+++ b/web/src/components/WorkArtifactsSheet.tsx
@@ -52,7 +52,7 @@ export function WorkArtifactsSheet({ open, artifacts, onOpen, onClose }: Props)
{artifact.path}
- {LABELS[artifact.kind]}
+ {/\.(?:mmd|mermaid)$/i.test(artifact.path) ? "Mermaid 图表" : LABELS[artifact.kind]}
{fileSize(artifact.size)}
{artifact.previewable
diff --git a/web/src/components/remote-viewer.css b/web/src/components/remote-viewer.css
index d936ce7c..c7ce2306 100644
--- a/web/src/components/remote-viewer.css
+++ b/web/src/components/remote-viewer.css
@@ -1,7 +1,7 @@
.remote-viewer-panel{padding:0;min-width:0;isolation:isolate}
.viewer-header{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--border);flex:none;color:var(--dim)}
.viewer-heading{display:flex;flex:1;min-width:0;flex-direction:column;gap:3px}
-.viewer-heading strong{color:var(--text);font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.viewer-heading strong{color:var(--text);font-size:14px;font-weight:var(--font-weight-600);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.viewer-heading small{font-size:11px;color:var(--dim)}
.viewer-header .iconbtn{flex:none;border-radius:9px}
.viewer-location{display:flex;align-items:center;gap:12px;padding:8px 16px;background:var(--raised);border-bottom:1px solid var(--border);font-size:11px;color:var(--dim)}
@@ -15,7 +15,7 @@
.viewer-stage{position:relative;display:flex;flex:1;min-height:0;min-width:0;background:var(--surface)}
.viewer-stage>iframe{display:block;border:0;flex:1;width:100%;height:100%;min-height:0;min-width:0;background:#f8fafc}
.viewer-empty{display:flex;flex:1;min-width:0;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:32px 24px;color:var(--dim);text-align:center}
-.viewer-empty>b{font-size:14px;color:var(--text);font-weight:500}
+.viewer-empty>b{font-size:14px;color:var(--text);font-weight:var(--font-weight-500)}
.viewer-empty p{margin:0;max-width:30em;font-size:13px;line-height:1.7}
.viewer-action{margin-top:4px;background:var(--accent-weak);border:1px solid var(--border);border-radius:10px;padding:8px 14px;color:var(--accent-ink);font-size:12px;cursor:pointer}
.viewer-catalog{width:100%;padding:18px;overflow:auto}
@@ -24,7 +24,7 @@
.viewer-site:hover{background:var(--raised);border-color:var(--border-strong)}
.viewer-site-icon{display:grid;place-items:center;width:36px;height:36px;flex:none;border-radius:11px;background:var(--accent-weak);color:var(--accent-ink)}
.viewer-site>span:nth-child(2){flex:1;display:flex;min-width:0;flex-direction:column;gap:5px}
-.viewer-site b{font-size:13px;font-weight:550;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.viewer-site b{font-size:13px;font-weight:var(--font-weight-550);color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.viewer-site small{font-size:11px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.viewer-loading{position:absolute;left:50%;top:16px;transform:translateX(-50%);display:flex;align-items:center;gap:8px;background:var(--surface);padding:8px 12px;border:1px solid var(--border);border-radius:12px;font-size:12px;color:var(--dim);box-shadow:var(--shadow-sm)}
.viewer-mobile-back{display:none}
diff --git a/web/src/components/timed-task.css b/web/src/components/timed-task.css
new file mode 100644
index 00000000..8aa42adf
--- /dev/null
+++ b/web/src/components/timed-task.css
@@ -0,0 +1,45 @@
+@property --task-orbit-angle {
+ syntax: "";
+ initial-value: 0deg;
+ inherits: false;
+}
+.timed-task-orbit {
+ position: absolute; inset: -1px; border-radius: 16px; padding: 1.5px;
+ pointer-events: none;
+ background: conic-gradient(from var(--task-orbit-angle),
+ transparent 0deg 235deg, color-mix(in srgb, var(--accent) 18%, transparent) 280deg,
+ #a9a0ff 325deg, var(--accent) 350deg, transparent 360deg);
+ mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+ mask-composite: exclude;
+ animation: timed-task-orbit 4s linear infinite;
+}
+@keyframes timed-task-orbit { to { --task-orbit-angle: 360deg; } }
+@media (prefers-reduced-motion: reduce) {
+ .timed-task-orbit { animation: none; background: var(--accent-line); }
+}
+.timed-task-trigger {
+ display: grid; place-items: center; flex: none; width: 24px; height: 24px;
+ color: var(--accent-ink); border-radius: 50%; margin: -3px;
+}
+.timed-task-trigger:hover, .timed-task-trigger:focus-visible { background: var(--accent-weak); }
+.timed-task-trigger:focus-visible { outline: 2px solid var(--accent-line); outline-offset: 2px; }
+.timed-task-popover {
+ position: fixed; z-index: 41; visibility: hidden; width: 286px; padding: 18px;
+ overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--border);
+ border-radius: 20px; box-shadow: var(--shadow); background: var(--surface); color: var(--text);
+ font-size: 13px; line-height: 1.5;
+}
+.timed-task-heading { display: flex; align-items: center; gap: 8px; font-weight: var(--font-weight-600); color: var(--accent-ink); }
+.timed-task-title { color: var(--dim); margin-top: 9px; overflow-wrap: anywhere; }
+.timed-task-title span { color: var(--faint); }
+.timed-task-next { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-top: 17px; color: var(--dim); }
+.timed-task-next strong { font-family: var(--mono); font-size: 19px; font-weight: var(--font-weight-500); color: var(--text); }
+.timed-task-countdown { text-align: right; color: var(--accent-ink); font-variant-numeric: tabular-nums; }
+.timed-task-progress { border-top: 1px solid var(--border); margin-top: 14px; padding-top: 11px; color: var(--dim); font-size: 12px; }
+.timed-message-tag { font-size: 12px; line-height: 1.5; color: var(--dim); margin-bottom: 8px; white-space: normal; }
+.timed-message-tag summary { cursor: pointer; list-style: none; display: flex; width: fit-content; align-items: center; gap: 6px; border-radius: 8px; }
+.timed-message-tag summary::-webkit-details-marker { display: none; }
+.timed-message-tag summary:hover { color: var(--accent-ink); }
+.timed-message-tag[open] summary svg:last-child { transform: rotate(180deg); }
+.timed-message-tag > div { display: flex; flex-direction: column; gap: 3px; border-top: 1px solid var(--accent-line); margin-top: 8px; padding-top: 8px; }
+.timed-message-tag strong { font-weight: var(--font-weight-500); color: var(--text); }
diff --git a/web/src/composer-submit.ts b/web/src/composer-submit.ts
index 56913995..7d52645d 100644
--- a/web/src/composer-submit.ts
+++ b/web/src/composer-submit.ts
@@ -3,7 +3,6 @@ import type { State } from "./protocol";
export type SendMode = "steer" | "queue";
export type BusySubmitAction =
- | "interrupt-and-replace"
| "steer"
| "replace"
| "enqueue"
@@ -19,14 +18,14 @@ export type BusySubmitAction =
export function classifyBusySubmit(
state: State,
mode: SendMode,
- engine: "claude" | "codex",
+ _engine: "claude" | "codex",
hasPayload: boolean,
): BusySubmitAction {
if (state === "idle") return "noop";
if (!hasPayload) return "noop";
if (mode === "queue") return "enqueue";
if (state !== "running") return "replace";
- return engine === "codex" ? "steer" : "interrupt-and-replace";
+ return "steer";
}
export function isComposerBusy(state: State): boolean {
diff --git a/web/src/domain/conversation.ts b/web/src/domain/conversation.ts
index 1862ac44..52085966 100644
--- a/web/src/domain/conversation.ts
+++ b/web/src/domain/conversation.ts
@@ -10,6 +10,7 @@ import type {
ServerEvent,
ToolCategory,
TurnChangeSummary,
+ TimedMessage,
} from "../protocol";
/** Browser-only fallback used when an authoritative idle History snapshot
@@ -128,6 +129,7 @@ export interface TurnDetailProjection {
export interface Turn {
id: string;
+ timedTask?: TimedMessage | null;
fileChanges?: TurnChangeSummary | null;
fileChangesTurnId?: string;
/** Codex turn/steer's browser id persisted beside a distinct history cursor. */
diff --git a/web/src/history-merge.ts b/web/src/history-merge.ts
index 61d60782..056ad76c 100644
--- a/web/src/history-merge.ts
+++ b/web/src/history-merge.ts
@@ -1039,6 +1039,7 @@ function mergeTurn(
...history,
id: live.id,
clientMsgId: history.clientMsgId ?? live.clientMsgId,
+ timedTask: history.timedTask ?? live.timedTask,
historyTurnId,
forkPointId: history.forkPointId ?? live.forkPointId,
checkpointId: history.checkpointId ?? live.checkpointId,
diff --git a/web/src/history-page-cache.ts b/web/src/history-page-cache.ts
index 16b29f5e..ec66e7e9 100644
--- a/web/src/history-page-cache.ts
+++ b/web/src/history-page-cache.ts
@@ -23,7 +23,8 @@ const DEFAULT_HISTORY_PAGE_CACHE_BYTES = 64 * 1024 * 1024;
// suppressed an ordinary unphased reply; the source revision alone cannot tell.
// v8 preserves native model-fallback notices and immutable turn-change metadata.
// v9 discards old official-summary pages which omitted their file lists.
-const RECORD_VERSION = 9;
+// v10 removes internal Claude recovery prompts cached as human page boundaries.
+const RECORD_VERSION = 10;
export interface HistoryPageCacheSessionScope {
machineId: string;
diff --git a/web/src/icons.tsx b/web/src/icons.tsx
index 7f0b98a6..1f8c90d1 100644
--- a/web/src/icons.tsx
+++ b/web/src/icons.tsx
@@ -14,6 +14,7 @@ const PATHS: Record = {
image: '',
camera: '',
back: '',
+ bold: '',
dots: '',
send: '',
stop: '',
diff --git a/web/src/index.css b/web/src/index.css
index 03543177..f8b0c851 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -1,3 +1,6 @@
+@import "./themes.css";
+@import "./typography.css";
+
/* cc-remote — warm paper theme (absorbed from design/prototype.html) */
*,*::before,*::after{box-sizing:border-box}
html,body,#root{height:100%;margin:0;overflow:hidden}
@@ -106,14 +109,14 @@ body{
.iconbtn svg{ width:20px; height:20px; } svg{ display:block; }
/* engine selector (which backend the next NEW session uses) */
-.engine-toggle{ font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:.02em;
+.engine-toggle{ font-family:var(--mono); font-size:11px; font-weight:var(--font-weight-600); letter-spacing:.02em;
padding:6px 10px; border-radius:9px; flex:none; transition:.16s; white-space:nowrap;
color:var(--dim); background:var(--raised); border:1px solid var(--border-strong); }
.engine-toggle:hover{ color:var(--accent-ink); border-color:var(--accent-line); }
-:root[data-engine="codex"] .engine-toggle{ color:var(--accent); background:var(--accent-weak); border-color:var(--accent-line); }
-.newchat-engine{ display:inline-block; margin-left:8px; font-family:var(--mono); font-size:11px; font-weight:600;
+:root[data-engine="codex"] .engine-toggle{ color:var(--accent-text,var(--accent)); background:var(--accent-weak); border-color:var(--accent-line); }
+.newchat-engine{ display:inline-block; margin-left:8px; font-family:var(--mono); font-size:11px; font-weight:var(--font-weight-600);
padding:3px 8px; border-radius:8px; vertical-align:middle; border:1px solid var(--border-strong); color:var(--dim); background:var(--raised); }
-.newchat-engine.codex{ color:var(--accent); background:var(--accent-weak); border-color:var(--accent-line); }
+.newchat-engine.codex{ color:var(--accent-text,var(--accent)); background:var(--accent-weak); border-color:var(--accent-line); }
/* CHAT HEADER */
.c-head{ display:flex; align-items:center; gap:6px; padding:9px 10px 9px 6px;
@@ -124,12 +127,12 @@ body{
.c-head::after{ content:""; position:absolute; left:0; right:0; bottom:0; height:1px; z-index:0;
pointer-events:none; background:linear-gradient(to right, transparent 0%, var(--divider) 12%, var(--divider) 88%, transparent 100%); }
.c-head .titlewrap{ flex:1; min-width:0; }
-.c-head .ttl{ font-weight:600; font-size:15px; letter-spacing:-.01em;
+.c-head .ttl{ font-weight:var(--font-weight-600); font-size:15px; letter-spacing:-.01em;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:flex; align-items:center; gap:8px; }
.c-head .sub{ font-family:var(--mono); font-size:11px; color:var(--dim);
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.hstat{ display:inline-flex; align-items:center; gap:5px; font-size:11.5px;
- font-weight:600; padding:2px 8px 2px 6px; border-radius:20px; flex:none; }
+ font-weight:var(--font-weight-600); padding:2px 8px 2px 6px; border-radius:20px; flex:none; }
.hstat .sd{ width:6px; height:6px; border-radius:50%; }
.hstat.running{ color:var(--warn); background:var(--warn-weak); }
.hstat.running .sd{ background:var(--warn); animation:pulse 1.4s ease-in-out infinite; }
@@ -177,12 +180,12 @@ body{
gap:14px; padding:6px 0; border-bottom:1px solid var(--divider); }
.terminal-control-card dl>div:last-child{ border-bottom:0; }
.terminal-control-card dt{ flex:none; color:var(--dim); font-size:11.5px; }
-.terminal-control-card dd{ margin:0; min-width:0; color:var(--text); font-size:11.5px; font-weight:600; text-align:right; overflow-wrap:anywhere; }
+.terminal-control-card dd{ margin:0; min-width:0; color:var(--text); font-size:11.5px; font-weight:var(--font-weight-600); text-align:right; overflow-wrap:anywhere; }
.terminal-control-reason{ display:flex; flex-direction:column; gap:5px; margin-top:10px; padding:8px 10px;
border-radius:10px; color:var(--dim); background:var(--raised); font-size:10.5px; }
-.terminal-control-reason code{ color:var(--text); font:10.5px/1.45 var(--mono); white-space:pre-wrap; overflow-wrap:anywhere; }
+.terminal-control-reason code{ color:var(--text); font:var(--font-weight-400) 10.5px/1.45 var(--mono); white-space:pre-wrap; overflow-wrap:anywhere; }
.terminal-control-takeover{ width:100%; min-height:44px; margin-top:11px; padding:8px 12px; border-radius:10px;
- color:#fff; background:var(--accent); font-size:12px; font-weight:650; }
+ color:var(--on-accent,#fff); background:var(--accent); font-size:12px; font-weight:var(--font-weight-650); }
.terminal-control-takeover:hover{ filter:brightness(1.05); }
.terminal-control-takeover:disabled{ opacity:.55; cursor:default; filter:none; }
@media(max-width:640px){
@@ -231,6 +234,14 @@ body{
.header-menu-item b,.header-menu-modes b{ color:var(--text); font-size:12.5px; }
.header-menu-item small,.header-menu-modes small{ color:var(--dim); font-size:10.5px;
line-height:1.4; overflow-wrap:anywhere; }
+.header-menu-item>.header-menu-switch{ flex:none; width:36px; height:22px;
+ padding:3px; border:1px solid var(--border-strong); border-radius:999px;
+ background:var(--raised); transition:background .15s; }
+.header-menu-switch::after{ content:""; width:14px; height:14px; border-radius:50%;
+ background:var(--dim); transition:transform .15s; }
+.header-menu-item[aria-checked="true"] .header-menu-switch{ background:var(--accent-weak); border-color:var(--accent); }
+.header-menu-item[aria-checked="true"] .header-menu-switch::after{ transform:translateX(14px); background:var(--accent-ink); }
+@media (prefers-reduced-motion:reduce){ .header-menu-switch,.header-menu-switch::after{transition:none} }
.header-menu-item.danger,.header-menu-item.danger b{ color:var(--danger); }
.header-menu-help,.header-menu-warning{ margin:10px 9px 5px; color:var(--dim);
font-size:11px; line-height:1.5; }
@@ -254,7 +265,7 @@ body{
.work-head{ min-height:58px; padding-left:12px; background:var(--surface); border-bottom:1px solid var(--border); }
.work-head::after{ display:none; }
.surface-head-title{ display:flex; align-items:center; gap:9px; min-width:0; color:var(--text);
- font:600 18px/1.2 var(--serif); letter-spacing:-.01em; }
+ font:var(--font-weight-600) 18px/1.2 var(--serif); letter-spacing:-.01em; }
.surface-head-title:hover{ color:var(--accent-ink); }
.surface-head-mark{ width:30px; height:30px; border-radius:9px; display:grid; place-items:center;
color:var(--accent-ink); background:var(--accent-weak); border:1px solid var(--accent-line); }
@@ -263,13 +274,13 @@ body{
white-space:nowrap; border:1px solid color-mix(in srgb,var(--profile-color) 38%,var(--border));
border-radius:6px; background:color-mix(in srgb,var(--profile-color) 10%,var(--surface));
color:color-mix(in srgb,var(--profile-color) 72%,var(--text));
- font:650 9px/1 var(--mono); letter-spacing:.035em; flex:none; }
+ font:var(--font-weight-650) 9px/1 var(--mono); letter-spacing:.035em; flex:none; }
.work-profile-owner .profile-tone{ width:6px; height:6px; }
/* CONNECTION BANNER */
.banner{ display:none; align-items:center; gap:8px;
padding:7px max(16px,env(safe-area-inset-right)) 7px max(16px,env(safe-area-inset-left));
- min-height:34px; font-size:12.5px; line-height:1.4; font-weight:600;
+ min-height:34px; font-size:12.5px; line-height:1.4; font-weight:var(--font-weight-600);
background:var(--warn-weak); color:var(--text); border-bottom:1px solid var(--border);
flex:none; position:relative; z-index:2; }
.banner.show{ display:flex; }
@@ -299,7 +310,7 @@ body{
.notice-bar.attention .notice-mark{ background:var(--warn); }
.notice-copy{ flex:1; min-width:0; }
.notice-title{ display:flex; align-items:baseline; gap:7px; min-width:0; }
-.notice-title small{ flex:none; font-size:10px; font-weight:700; letter-spacing:.04em;
+.notice-title small{ flex:none; font-size:10px; font-weight:var(--font-weight-700); letter-spacing:.04em;
text-transform:uppercase; color:var(--dim); }
.notice-title b{ min-width:0; font-size:12.5px; line-height:1.35; overflow-wrap:anywhere; }
.notice-copy p{ margin:2px 0 0; font-size:12px; line-height:1.45; overflow-wrap:anywhere; }
@@ -307,7 +318,7 @@ body{
.notice-copy summary{ width:max-content; max-width:100%; cursor:pointer; }
.notice-copy pre{ margin:4px 0 0; padding:6px 8px; max-height:92px; overflow:auto;
border-radius:7px; background:var(--surface); white-space:pre-wrap; overflow-wrap:anywhere;
- font:10.5px/1.45 var(--mono); color:var(--dim); }
+ font:var(--font-weight-400) 10.5px/1.45 var(--mono); color:var(--dim); }
.notice-dismiss{ flex:none; display:grid; place-items:center; width:26px; height:26px;
border-radius:7px; color:var(--dim); }
.notice-dismiss:hover{ color:var(--text); background:var(--surface); }
@@ -315,24 +326,9 @@ body{
/* THREAD */
.thread-shell{ flex:1; min-height:0; position:relative; display:flex; flex-direction:column; }
.thread-frame{ flex:1; min-height:0; position:relative; display:flex; }
-.background-process-dock{ flex:none; position:relative; z-index:3; width:min(760px,calc(100% - 32px));
- margin:0 auto 10px; padding:9px 11px; border:1px solid var(--line); border-radius:14px;
- background:color-mix(in srgb,var(--raised) 94%,transparent); box-shadow:0 8px 24px rgba(0,0,0,.08); }
-.background-process-head{ display:flex; align-items:center; gap:8px; min-height:24px; color:var(--muted);
- font-size:12px; font-weight:650; }
-.background-process-pulse{ width:8px; height:8px; border-radius:50%; background:var(--accent);
- box-shadow:0 0 0 0 color-mix(in srgb,var(--accent) 42%,transparent); animation:background-process-pulse 1.6s ease-out infinite; }
-.background-process-count{ margin-left:auto; min-width:20px; padding:1px 6px; border-radius:999px;
- background:var(--surface); color:var(--muted); text-align:center; }
-.background-process-items{ display:flex; flex-direction:column; gap:3px; margin-top:4px;
- max-height:min(32vh,240px); overflow-y:auto; overscroll-behavior:contain; scrollbar-width:thin; }
-.background-process-items .process-activity{ margin:0; }
-@keyframes background-process-pulse{ 70%,100%{ box-shadow:0 0 0 7px transparent; } }
.assistant-answer-segment{ display:contents; }
.background-followup-boundary{ display:flex; align-items:center; flex-wrap:wrap; gap:7px; margin:14px 0 8px;
padding-top:10px; border-top:1px solid var(--line); color:var(--muted); font-size:11px; line-height:1.4; }
-.background-followup-icon{ display:grid; place-items:center; width:19px; height:19px; border-radius:50%;
- color:var(--ok); background:var(--ok-weak); }
.background-followup-boundary time{ color:var(--dim); font-variant-numeric:tabular-nums; }
.thread{ flex:1; min-height:0; overflow-y:auto; overflow-x:hidden; overflow-anchor:none; -webkit-overflow-scrolling:touch; padding:0; position:relative; z-index:1;
color-scheme:light; scrollbar-width:thin;
@@ -393,7 +389,7 @@ body{
.ubub-act[data-tooltip]::after{
content:attr(data-tooltip); position:absolute; left:50%; bottom:calc(100% + 7px); z-index:20;
padding:5px 8px; border-radius:6px; background:var(--text); color:var(--surface);
- box-shadow:var(--shadow); font-size:11px; font-weight:500; line-height:1; white-space:nowrap;
+ box-shadow:var(--shadow); font-size:11px; font-weight:var(--font-weight-500); line-height:1; white-space:nowrap;
opacity:0; visibility:hidden; pointer-events:none; transform:translate(-50%,3px);
transition:opacity .12s ease,transform .12s ease,visibility 0s linear .12s;
}
@@ -416,15 +412,15 @@ body{
.message-code-copy.copied{ color:var(--ok); }
.arole{ display:flex; align-items:center; gap:8px; margin:0 0 8px; }
/* no box around the mark — just the terracotta spark */
-.arole .av{ width:22px; height:22px; color:var(--accent); display:grid; place-items:center; flex:none; }
+.arole .av{ width:22px; height:22px; color:var(--accent-text,var(--accent)); display:grid; place-items:center; flex:none; }
.arole .av svg{ width:22px; height:22px; }
-.arole .nm{ font-size:12.5px; font-weight:600; color:var(--dim); letter-spacing:.02em; }
+.arole .nm{ font-size:12.5px; font-weight:var(--font-weight-600); color:var(--dim); letter-spacing:.02em; }
/* prose (react-markdown output) */
.prose{ font-size:15px; line-height:1.62; }
.prose p{ margin:0 0 10px; } .prose p:last-child{ margin-bottom:0; }
-.prose strong{ font-weight:600; }
-.prose h1,.prose h2,.prose h3{ font-family:var(--serif); font-weight:500; margin:18px 0 8px; letter-spacing:-.01em; }
+.prose strong{ font-weight:var(--font-weight-600); }
+.prose h1,.prose h2,.prose h3{ font-family:var(--serif); font-weight:var(--font-weight-500); margin:18px 0 8px; letter-spacing:-.01em; }
.prose h1{ font-size:22px } .prose h2{ font-size:19px } .prose h3{ font-size:16px }
.prose code{ font-family:var(--mono); font-size:.86em; background:var(--raised);
border:1px solid var(--border); padding:1px 5px; border-radius:5px; }
@@ -471,13 +467,13 @@ body{
.prose ul,.prose ol{ margin:8px 0; padding-left:22px; } .prose li{ margin:3px 0; }
.prose a{ color:var(--accent-ink); text-decoration:none; border-bottom:1px solid var(--accent-line); }
.message-file-link{ display:inline; padding:0; border:0; border-bottom:1px solid var(--accent-line); border-radius:0; background:none; color:var(--accent-ink); font:inherit; cursor:pointer; }
-.message-file-link:hover{ color:var(--accent); border-bottom-color:var(--accent); }
+.message-file-link:hover{ color:var(--accent-text,var(--accent)); border-bottom-color:var(--accent); }
.message-file-tooltip{ position:fixed; z-index:120; display:flex; flex-direction:column; gap:3px;
box-sizing:border-box; width:max-content; max-width:min(520px,calc(100vw - 20px));
padding:7px 9px; border:1px solid var(--border-strong); border-radius:7px;
color:var(--text); background:var(--surface); box-shadow:var(--shadow-lg);
pointer-events:auto; }
-.message-file-tooltip-path{ font:11.5px/1.45 var(--mono); overflow-wrap:anywhere;
+.message-file-tooltip-path{ font:var(--font-weight-400) 11.5px/1.45 var(--mono); overflow-wrap:anywhere;
word-break:break-word; cursor:text; user-select:text; }
.message-link-disabled{ color:var(--dim); border-bottom:1px dotted var(--faint); cursor:not-allowed; }
.prose blockquote{ border-left:3px solid var(--accent-line); margin:8px 0; padding:2px 12px; color:var(--dim); }
@@ -516,10 +512,10 @@ button.message-image-error{ font:inherit; cursor:pointer; }
text-overflow:ellipsis; white-space:nowrap; }
.codex-visualization-copy>span{ color:var(--dim); font-size:11.5px; line-height:1.35; }
.codex-visualization-open{ display:inline-flex; align-items:center; gap:2px; flex:none;
- color:var(--accent-ink); font-size:11.5px; font-weight:600; }
+ color:var(--accent-ink); font-size:11.5px; font-weight:var(--font-weight-600); }
.prose table{ display:table; width:100%; border-collapse:collapse; margin:10px 0; font-size:13.5px; }
.prose th,.prose td{ border:1px solid var(--border); padding:6px 10px; text-align:left; word-break:break-word; }
-.prose th{ background:var(--raised); font-weight:600; }
+.prose th{ background:var(--raised); font-weight:var(--font-weight-600); }
@media (max-width:980px){ .prose pre{ white-space:pre-wrap; word-break:break-word; overflow-x:hidden; } .prose table{ touch-action:pan-y; } }
.cursor{ display:inline-block; width:8px; height:16px; margin-left:1px; background:var(--accent);
border-radius:2px; vertical-align:-2px; animation:blink 1.05s steps(2) infinite; }
@@ -530,12 +526,12 @@ button.message-image-error{ font:inherit; cursor:pointer; }
.thinking span:nth-child(3){ animation-delay:.36s; }
/* the "working" indicator lives BELOW the streamed text (so it stays visible as
the reply grows). The mark itself is animated — a loop of morphing sparks. */
-.turn-working{ display:inline-flex; align-items:center; gap:7px; margin:8px 0 2px; color:var(--accent); }
+.turn-working{ display:inline-flex; align-items:center; gap:7px; margin:8px 0 2px; color:var(--accent-text,var(--accent)); }
.turn-working svg{ display:block; }
-.turn-working .turn-working-tx{ font-weight:600; color:var(--accent-ink); font-size:12.5px; }
+.turn-working .turn-working-tx{ font-weight:var(--font-weight-600); color:var(--accent-ink); font-size:12.5px; }
/* the spark kept under a finished reply (below the time/copy row); click to replay */
.turn-done-mark{ margin-top:2px; }
-.spark-btn{ display:inline-grid; place-items:center; padding:3px; border-radius:9px; color:var(--accent); transition:.14s; }
+.spark-btn{ display:inline-grid; place-items:center; padding:3px; border-radius:9px; color:var(--accent-text,var(--accent)); transition:.14s; }
/* no hover box — the click plays the animation; just a subtle grow for affordance */
.spark-btn:hover{ transform:scale(1.12); }
.spark-btn svg{ display:block; }
@@ -549,7 +545,7 @@ button.message-image-error{ font:inherit; cursor:pointer; }
.tool-ic{ width:27px; height:27px; border-radius:8px; background:var(--raised); border:1px solid var(--border);
display:grid; place-items:center; flex:none; color:var(--dim); }
.tool-ic svg{ width:15px; height:15px; }
-.tool-nm{ font-family:var(--mono); font-size:13px; font-weight:600; flex:none; }
+.tool-nm{ font-family:var(--mono); font-size:13px; font-weight:var(--font-weight-600); flex:none; }
.tool-arg{ font-family:var(--mono); font-size:12px; color:var(--dim);
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
.tool-st{ flex:none; display:grid; place-items:center; width:16px; height:16px; }
@@ -563,20 +559,22 @@ button.message-image-error{ font:inherit; cursor:pointer; }
.tool-meta{ display:flex; gap:10px; margin-top:8px; color:var(--faint); font-family:var(--mono); font-size:10.5px; }
.tool-diff{ max-height:340px; background:var(--raised); }
.tool-diff-note{ margin-top:6px; color:var(--faint); font-size:11px; line-height:1.45; }
+.tool-raw{ margin-top:8px; color:var(--faint); font-size:11px; }
+.tool-raw>summary{ cursor:pointer; padding:4px 0; }
/* One turn-level process timeline. The final answer stays outside this shell. */
.turn-process{ margin:5px 0 10px; color:var(--dim); }
.turn-process-controls{ position:relative; display:flex; align-items:center; gap:3px; width:100%; }
.turn-process-head{ display:flex; align-items:center; gap:7px; width:100%; min-height:38px;
- padding:6px 8px; border-radius:10px; color:var(--dim); text-align:left; font-size:13px; font-weight:600;
+ padding:6px 8px 6px 0; border-radius:10px; color:var(--dim); text-align:left; font-size:13px; font-weight:var(--font-weight-600);
touch-action:pan-y; user-select:none; -webkit-user-select:none; }
.turn-process-controls>.turn-process-head{ width:auto; min-width:0; flex:1; }
@media (hover:hover) and (pointer:fine){
.turn-process-head:hover{ background:var(--raised); color:var(--text); }
.plan-progress-trigger:hover{ background:var(--accent-weak); }
- .process-page-control:hover{ background:var(--raised); color:var(--accent); }
+ .process-page-control:hover{ background:var(--raised); color:var(--accent-text,var(--accent)); }
.process-reasoning > summary:hover,.process-activity > summary:hover{ background:var(--raised); color:var(--text); }
- .process-file-link:hover{ color:var(--accent); border-bottom-color:var(--accent); }
+ .process-file-link:hover{ color:var(--accent-text,var(--accent)); border-bottom-color:var(--accent); }
.process-image-preview:hover{ border-color:var(--accent-line); background:var(--accent-weak); }
.process-image-preview:disabled:hover{ border-color:var(--line); background:var(--raised); }
.tool-group-h:hover{ background:var(--surface); color:var(--text); }
@@ -584,14 +582,35 @@ button.message-image-error{ font:inherit; cursor:pointer; }
}
.turn-process-head > svg{ margin-left:2px; color:var(--faint); transition:transform .18s ease; }
.turn-process.open .turn-process-head > svg{ transform:rotate(180deg); }
-.turn-process-state{ width:17px; height:17px; display:grid; place-items:center; flex:none; color:var(--ok); }
-.turn-process-state.running{ color:var(--accent); }
+.turn-process-state{ width:17px; height:17px; display:grid; place-items:center; flex:none; color:var(--dim); }
.turn-process-state.failed{ color:var(--warn); }
.turn-process-state.interrupted{ color:var(--dim); }
-.turn-process-count{ margin-left:auto; color:var(--faint); font-size:11px; font-weight:500; }
+.turn-process-label,.tool-group-label{ min-width:0; }
+/* One light band crosses the whole label, including its nested tool summary.
+ Only native activity enables it; historical/settled rows stay static. */
+@supports ((background-clip:text) or (-webkit-background-clip:text)){
+ .status-shimmer.is-active{
+ --status-shimmer-light:color-mix(in srgb,var(--dim) 40%,var(--surface));
+ background-image:linear-gradient(100deg,var(--dim) 40%,var(--status-shimmer-light) 50%,var(--dim) 60%);
+ background-size:250% 100%; background-repeat:no-repeat;
+ background-clip:text; -webkit-background-clip:text;
+ color:transparent; -webkit-text-fill-color:transparent;
+ animation:status-text-sweep 2.4s ease-in-out infinite;
+ }
+ :root[data-theme="dark"] .status-shimmer.is-active{ --status-shimmer-light:var(--text); }
+}
+@keyframes status-text-sweep{
+ 0%{ background-position:100% 0; }
+ 80%,100%{ background-position:0% 0; }
+}
+@media (prefers-reduced-motion:reduce), (forced-colors:active){
+ .status-shimmer.is-active{ animation:none; background:none;
+ color:var(--dim); -webkit-text-fill-color:currentColor; }
+}
+.turn-process-count{ margin-left:auto; color:var(--faint); font-size:11px; font-weight:var(--font-weight-500); }
.turn-detail-entry{ display:flex; align-items:center; gap:8px; min-height:30px; margin:3px 0 8px; color:var(--dim); }
.turn-detail-entry-btn{ display:inline-flex; align-items:center; gap:6px; min-height:30px; padding:4px 8px;
- border-radius:8px; color:var(--dim); font-size:12px; font-weight:600; text-align:left; }
+ border-radius:8px; color:var(--dim); font-size:12px; font-weight:var(--font-weight-600); text-align:left; }
.turn-detail-entry-btn>svg{ transform:rotate(-90deg); color:var(--faint); }
.turn-detail-entry-btn:disabled{ opacity:.65; cursor:default; }
.turn-detail-entry-error{ color:var(--danger); font-size:11px; line-height:1.4; }
@@ -602,7 +621,7 @@ button.message-image-error{ font:inherit; cursor:pointer; }
border-right-color:transparent; animation:spin .75s linear infinite; }
.plan-progress-control{ position:relative; flex:none; }
.plan-progress-trigger{ width:34px; height:34px; display:grid; place-items:center; border-radius:50%;
- color:var(--accent); touch-action:pan-y; user-select:none; -webkit-user-select:none; }
+ color:var(--accent-text,var(--accent)); touch-action:pan-y; user-select:none; -webkit-user-select:none; }
.plan-progress-trigger[aria-expanded="true"]{ background:var(--accent-weak); }
.plan-progress-trigger.complete{ color:var(--ok); }
.plan-progress-trigger.failed{ color:var(--danger); }
@@ -622,16 +641,16 @@ button.message-image-error{ font:inherit; cursor:pointer; }
.plan-progress-content>header>span:nth-child(2){ min-width:0; flex:1; display:flex; flex-direction:column; }
.plan-progress-content>header b{ font-size:13px; }
.plan-progress-content>header small{ color:var(--faint); font-size:10.5px; }
-.plan-progress-content>header strong{ flex:none; color:var(--dim); font:10.5px/1 var(--mono); }
+.plan-progress-content>header strong{ flex:none; color:var(--dim); font:var(--font-weight-400) 10.5px/1 var(--mono); }
.plan-progress-mark{ width:29px; height:29px; display:grid; place-items:center; flex:none;
- border-radius:9px; color:var(--accent); background:var(--accent-weak); }
+ border-radius:9px; color:var(--accent-text,var(--accent)); background:var(--accent-weak); }
.plan-progress-mark.complete{ color:var(--ok); background:var(--ok-weak); }
.plan-progress-mark.failed{ color:var(--danger); background:var(--danger-weak); }
.plan-progress-mark.stale{ color:var(--faint); background:var(--surface-soft); }
.plan-progress-content>p{ margin:10px 1px 8px; color:var(--dim); font-size:11.5px;
line-height:1.45; white-space:pre-wrap; overflow-wrap:anywhere; }
.plan-progress-fallback{ margin:9px 0 0; padding:9px 10px; border-radius:9px; overflow:auto;
- color:var(--dim); background:var(--raised); font:11.5px/1.5 var(--mono); white-space:pre-wrap;
+ color:var(--dim); background:var(--raised); font:var(--font-weight-400) 11.5px/1.5 var(--mono); white-space:pre-wrap;
overflow-wrap:anywhere; }
.plan-progress-content>ol{ display:flex; flex-direction:column; gap:1px; margin:9px 0 0; padding:0; list-style:none; }
.plan-progress-content>ol li{ display:grid; grid-template-columns:22px minmax(0,1fr); gap:7px;
@@ -641,11 +660,11 @@ button.message-image-error{ font:inherit; cursor:pointer; }
.plan-progress-content>ol li>span:last-child{ overflow-wrap:anywhere; }
.plan-progress-content li.plan-step-completed{ color:var(--faint); }
.plan-progress-content li.plan-step-completed>span:first-child{ color:var(--ok); }
-.plan-progress-content li.plan-step-inProgress{ color:var(--text); background:var(--accent-weak); font-weight:600; }
-.plan-progress-content.stale li.plan-step-inProgress{ color:var(--dim); background:transparent; font-weight:400; }
+.plan-progress-content li.plan-step-inProgress{ color:var(--text); background:var(--accent-weak); font-weight:var(--font-weight-600); }
+.plan-progress-content.stale li.plan-step-inProgress{ color:var(--dim); background:transparent; font-weight:var(--font-weight-400); }
.plan-progress-content li.plan-step-inProgress>span:first-child i{ width:8px; height:8px; border-radius:50%;
background:var(--accent); box-shadow:0 0 0 4px color-mix(in srgb,var(--accent) 16%,transparent); animation:pulse 1.4s ease-in-out infinite; }
-.plan-progress-content li.plan-step-pending>span:first-child em{ font-style:normal; font:9.5px/1 var(--mono); }
+.plan-progress-content li.plan-step-pending>span:first-child em{ font-style:normal; font:var(--font-weight-400) 9.5px/1 var(--mono); }
.plan-progress-empty{ padding:18px 4px 8px; color:var(--faint); font-size:12px; text-align:center; }
.process-timeline{ position:relative; display:flex; flex-direction:column; gap:3px; margin:2px 0 5px 15px;
padding:5px 0 5px 17px; border-left:1.5px solid var(--border-strong); }
@@ -654,10 +673,10 @@ button.message-image-error{ font:inherit; cursor:pointer; }
.process-detail-error{ min-height:35px; display:flex; align-items:center; justify-content:space-between;
gap:10px; padding:5px 8px; color:var(--danger); font-size:12.5px; }
.process-detail-error button{ flex:none; min-height:28px; padding:3px 10px; border-radius:8px;
- color:var(--accent-ink); background:var(--accent-weak); font-weight:600; }
+ color:var(--accent-ink); background:var(--accent-weak); font-weight:var(--font-weight-600); }
.process-page-control{ display:flex; align-items:center; justify-content:center; gap:7px; width:100%;
min-height:34px; padding:5px 8px; border-radius:9px; color:var(--accent-ink);
- font-size:12px; font-weight:600; touch-action:manipulation; }
+ font-size:12px; font-weight:var(--font-weight-600); touch-action:manipulation; }
.process-page-control:disabled{ color:var(--faint); cursor:default; }
.process-page-control.earlier > svg{ transform:rotate(180deg); }
.process-commentary{ padding:4px 8px 5px; color:var(--text); }
@@ -676,6 +695,17 @@ button.message-image-error{ font:inherit; cursor:pointer; }
.process-reasoning-body{ padding:4px 10px 7px 29px; color:var(--dim); }
.process-reasoning-body .prose{ font-size:12.5px; line-height:1.55; }
.process-item-ic{ width:23px; height:23px; display:grid; place-items:center; flex:none; color:var(--faint); }
+.process-activity.process-compaction-running{ width:fit-content; gap:10px; border-radius:16px;
+ color:var(--accent-text,var(--accent)); background:color-mix(in srgb,var(--accent) 7%,transparent); }
+.compact-motion{ display:flex; align-items:center; justify-content:center; gap:3px;
+ width:31px; height:23px; flex:none; }
+.compact-motion i{ width:3px; height:13px; border-radius:3px; background:currentColor;
+ animation:compact-fold 1.6s ease-in-out infinite; }
+.compact-motion i:nth-child(2),.compact-motion i:nth-child(4){ animation-delay:.13s; }
+.compact-motion i:nth-child(3){ animation-delay:.26s; }
+@keyframes compact-fold{ 0%,100%{ transform:scaleY(1); opacity:.45; }
+ 50%{ transform:scaleY(.35); opacity:1; } }
+@media(prefers-reduced-motion:reduce){ .compact-motion i{ animation:none; } }
.process-item-title{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:inherit; }
.process-item-status{ margin-left:auto; display:grid; place-items:center; color:var(--ok); }
.process-failed .process-item-status,.process-declined .process-item-status,.process-cancelled .process-item-status,.process-interrupted .process-item-status{ color:var(--danger); }
@@ -690,11 +720,11 @@ button.message-image-error{ font:inherit; cursor:pointer; }
.process-image-preview img,.process-image-placeholder{ width:42px; height:34px; flex:0 0 auto; border-radius:7px; object-fit:cover; background:var(--panel); }
.process-image-placeholder{ display:grid; place-items:center; color:var(--muted); }
.process-image-preview>span:last-child{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--muted); font-size:12px; }
-.process-meta{ color:var(--faint); font:10.5px/1.4 var(--mono); overflow-wrap:anywhere; }
+.process-meta{ color:var(--faint); font:var(--font-weight-400) 10.5px/1.4 var(--mono); overflow-wrap:anywhere; }
.process-plan{ display:flex; flex-direction:column; gap:5px; margin:2px 0; padding:0; list-style:none; }
.process-plan li{ display:grid; grid-template-columns:17px 1fr; gap:6px; color:var(--dim); font-size:12.5px; line-height:1.45; }
.process-plan li.plan-completed{ color:var(--faint); }
-.process-plan li.plan-inProgress{ color:var(--text); font-weight:600; }
+.process-plan li.plan-inProgress{ color:var(--text); font-weight:var(--font-weight-600); }
.process-command{ max-height:260px; }
@media (max-width:980px){
.process-timeline{ margin-left:8px; padding-left:11px; }
@@ -708,13 +738,8 @@ button.message-image-error{ font:inherit; cursor:pointer; }
font-size:12.5px; color:var(--dim); border-radius:9px; touch-action:pan-y;
user-select:none; -webkit-user-select:none; }
.tool-group-h::-webkit-details-marker{ display:none; }
-.tool-group-ic{ display:grid; place-items:center; width:16px; height:16px; flex:none; color:var(--faint); }
-.tool-group-ic.running{ color:var(--accent); }
-.tool-group-ic .spin-dot{ width:11px; height:11px; border-radius:50%; border:2px solid var(--accent);
- border-right-color:transparent; animation:spin .7s linear infinite; display:block; }
-.tool-group-ic svg{ width:13px; height:13px; color:var(--ok); }
-.tool-group-nm{ font-weight:600; }
-.tool-group-sub{ font-family:var(--mono); font-size:11.5px; color:var(--faint); }
+.tool-group-nm{ font-weight:var(--font-weight-600); }
+.tool-group-sub{ margin-left:8px; font-family:var(--mono); font-size:11.5px; color:var(--faint); }
.tool-group-err{ color:var(--danger); }
.tool-group-chev{ margin-left:auto; color:var(--faint); transition:transform .2s; }
.tool-group[open] .tool-group-chev{ transform:rotate(90deg); }
@@ -728,7 +753,7 @@ button.message-image-error{ font:inherit; cursor:pointer; }
.tool-group-b .tool-st{ width:14px; height:14px; }
.tool-group-b .tool-st svg,.tool-group-b .tool-chev svg{ width:13px; height:13px; }
.tool-group-b .tool-b{ padding:2px 5px 8px 32px; }
-.tool-lbl{ font-size:10.5px; font-weight:600; letter-spacing:.05em; text-transform:uppercase;
+.tool-lbl{ font-size:10.5px; font-weight:var(--font-weight-600); letter-spacing:.05em; text-transform:uppercase;
color:var(--faint); margin:8px 0 4px; }
.tool-pre{ font-family:var(--mono); font-size:12px; line-height:1.5; background:var(--bg);
border:1px solid var(--border); border-radius:9px; padding:9px 10px; margin:0;
@@ -737,12 +762,12 @@ button.message-image-error{ font:inherit; cursor:pointer; }
.diff-table{ font-family:var(--mono); font-size:12px; line-height:1.55; background:var(--raised); border:1px solid var(--border); border-radius:9px; overflow:hidden; }
.diff-page-nav{ position:sticky; top:0; z-index:2; display:flex; align-items:center; justify-content:center; gap:10px;
margin:0 0 8px; padding:7px 9px; border:1px solid var(--border); border-radius:9px; background:var(--surface); box-shadow:var(--shadow-sm); }
-.diff-page-nav span{ color:var(--dim); font:11px/1.4 var(--mono); }
+.diff-page-nav span{ color:var(--dim); font:var(--font-weight-400) 11px/1.4 var(--mono); }
.diff-page-nav button{ padding:4px 8px; border-radius:7px; color:var(--accent-ink); background:var(--accent-weak); font-size:11px; }
.diff-page-nav button:disabled{ color:var(--faint); background:var(--raised); cursor:default; }
.diff-empty{ color:var(--dim); padding:24px; text-align:center; }
.diff-file + .diff-file{ border-top:1px solid var(--border-strong); }
-.diff-file-h{ display:flex; align-items:center; gap:7px; padding:8px 12px; background:var(--bg); color:var(--text); font-weight:600; border-bottom:1px solid var(--border); font-size:12px; }
+.diff-file-h{ display:flex; align-items:center; gap:7px; padding:8px 12px; background:var(--bg); color:var(--text); font-weight:var(--font-weight-600); border-bottom:1px solid var(--border); font-size:12px; }
.diff-file-nm{ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.diff-hunk-h{ padding:5px 12px; color:var(--faint); background:var(--surface); border-bottom:1px solid var(--border); white-space:pre-wrap; overflow-wrap:anywhere; }
.drow{ display:grid; grid-template-columns:3.2ch 3.2ch 1fr; align-items:baseline; }
@@ -765,7 +790,7 @@ button.message-image-error{ font:inherit; cursor:pointer; }
.turn-changes button:focus-visible{ outline:2px solid var(--accent); outline-offset:3px; }
.turn-changes-toggle > svg{ transform:rotate(-90deg); transition:transform .15s; }
.turn-changes-toggle[aria-expanded="true"] > svg{ transform:rotate(0); }
-.turn-changes-counts{ display:flex; gap:6px; margin-left:4px; font:11px var(--mono); }
+.turn-changes-counts{ display:flex; gap:6px; margin-left:4px; font:var(--font-weight-400) 11px var(--mono); }
.turn-changes-counts > :first-child{ color:var(--ok); }
.turn-changes-counts > :last-child{ color:var(--danger); }
.turn-changes-files{ margin-top:6px; border:1px solid var(--border); border-radius:12px; overflow:hidden; }
@@ -775,10 +800,10 @@ button.message-image-error{ font:inherit; cursor:pointer; }
.turn-change-file{ display:flex; align-items:center; gap:10px; min-width:0; padding:9px 12px; }
.turn-change-file + .turn-change-file{ border-top:1px solid var(--border); }
.turn-change-path{ display:flex; align-items:center; gap:8px; flex:1; min-width:0;
- padding:0; background:none; color:var(--text); text-align:left; font:12px var(--mono); border-radius:4px; }
+ padding:0; background:none; color:var(--text); text-align:left; font:var(--font-weight-400) 12px var(--mono); border-radius:4px; }
.turn-change-path:disabled{ opacity:1; cursor:default; }
.turn-change-type{ display:flex; align-items:center; justify-content:center; flex:none;
- width:28px; height:28px; border-radius:7px; font:600 10px var(--mono);
+ width:28px; height:28px; border-radius:7px; font:var(--font-weight-600) 10px var(--mono);
color:var(--dim); background:color-mix(in srgb,currentColor 7%,transparent); }
.turn-change-type[data-tone="blue"]{ color:light-dark(#3b70ad,#8db6e8); }
.turn-change-type[data-tone="amber"]{ color:light-dark(#947025,#dbc185); }
@@ -786,7 +811,7 @@ button.message-image-error{ font:inherit; cursor:pointer; }
.turn-change-type[data-tone="green"]{ color:light-dark(#3d7b61,#8cc6a7); }
.turn-change-type[data-tone="cyan"]{ color:light-dark(#347e8d,#88c2cf); }
.turn-change-label{ display:flex; align-items:baseline; flex-wrap:wrap; gap:2px 10px; min-width:0; }
-.turn-change-name{ font-weight:550; overflow-wrap:anywhere; }
+.turn-change-name{ font-weight:var(--font-weight-550); overflow-wrap:anywhere; }
.turn-change-directory{ color:var(--dim); font-size:10.5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.turn-change-path:not(:disabled):hover{ color:var(--accent-ink); }
.turn-change-file small{ color:var(--faint); font-size:10px; flex:none; }
@@ -824,7 +849,7 @@ button.message-image-error{ font:inherit; cursor:pointer; }
.panel-resizer:hover::after,.panel-resizer:focus-visible::after{ opacity:1; background:var(--accent); }
html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-select:none !important; }
.artifact-head{ display:flex; align-items:center; gap:10px; min-width:0; padding:12px 14px; border-bottom:1px solid var(--border); flex:none; padding-top:max(12px, env(safe-area-inset-top)); }
-.artifact-title{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:600; font-size:14px; flex:0 1 auto; }
+.artifact-title{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:var(--font-weight-600); font-size:14px; flex:0 1 auto; }
.artifact-path{ font-family:var(--mono); font-size:11px; color:var(--dim); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.artifact-body{ flex:1; min-width:0; min-height:0; max-width:100%; overflow-y:auto; overflow-x:hidden; overscroll-behavior:contain; touch-action:pan-y; -webkit-overflow-scrolling:touch; padding:14px 16px; }
.artifact-body.rendered-artifact-body{ display:flex; overflow:hidden; padding:0; background:var(--raised); }
@@ -833,7 +858,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.source-artifact-body .preview-truncated,.source-artifact-body .markdown-save-error{ flex:none; }
.artifact-body .diff-pre{ margin:0; }
.artifact-body .prose{ font-size:14px; }
-.artifact-converted{ flex:none; border:1px solid var(--border); border-radius:7px; padding:3px 6px; color:var(--dim); background:var(--raised); font:9.5px/1.2 var(--mono); white-space:nowrap; }
+.artifact-converted{ flex:none; border:1px solid var(--border); border-radius:7px; padding:3px 6px; color:var(--dim); background:var(--raised); font:var(--font-weight-400) 9.5px/1.2 var(--mono); white-space:nowrap; }
.artifact-html-preview{ display:block; width:100%; min-width:0; height:100%; border:0; background:#fff; }
.artifact-html-stage{ display:flex; flex:1; width:100%; min-width:0; height:100%; flex-direction:column; }
.artifact-html-stage .artifact-html-preview{ flex:1; min-height:0; }
@@ -855,7 +880,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.artifact-audio-error{ margin:12px 0 0; color:var(--danger); font-size:12px; line-height:1.6; }
.artifact-pdf-stage{ display:flex; flex:1; width:100%; min-width:0; height:100%; min-height:0; flex-direction:column; }
.artifact-pdf-controls{ display:flex; flex:none; min-height:38px; align-items:center; justify-content:center; gap:10px; padding:6px 10px; border-bottom:1px solid var(--line); background:var(--surface); }
-.artifact-pdf-controls span{ min-width:76px; color:var(--dim); font:11px/1.4 var(--mono); text-align:center; }
+.artifact-pdf-controls span{ min-width:76px; color:var(--dim); font:var(--font-weight-400) 11px/1.4 var(--mono); text-align:center; }
.artifact-pdf-controls button{ border:1px solid var(--border); border-radius:7px; padding:4px 8px; background:var(--raised); color:var(--accent-ink); font-size:11px; }
.artifact-pdf-controls button:disabled{ color:var(--faint); cursor:default; }
.artifact-pdf-page{ position:relative; display:flex; flex:1; min-width:0; min-height:0; align-items:flex-start; justify-content:center; overflow:auto; overscroll-behavior:contain; padding:12px; touch-action:pan-x pan-y; }
@@ -866,7 +891,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.preview-modes{ display:flex; gap:2px; flex:none; padding:2px; border:1px solid var(--border); border-radius:8px; background:var(--raised); }
.preview-modes button{ padding:4px 8px; border-radius:6px; color:var(--dim); font-size:11px; }
.preview-modes button.on{ color:var(--accent-ink); background:var(--surface); box-shadow:var(--shadow-sm); }
-.markdown-save{ display:inline-flex; align-items:center; gap:5px; flex:none; padding:6px 9px; border:1px solid var(--accent-line); border-radius:8px; color:var(--accent-ink); background:var(--accent-weak); font-size:11px; font-weight:650; }
+.markdown-save{ display:inline-flex; align-items:center; gap:5px; flex:none; padding:6px 9px; border:1px solid var(--accent-line); border-radius:8px; color:var(--accent-ink); background:var(--accent-weak); font-size:11px; font-weight:var(--font-weight-650); }
.markdown-save:disabled{ color:var(--faint); border-color:var(--border); background:var(--raised); cursor:default; }
.markdown-save-state{ flex:none; font-size:11px; color:var(--dim); }
.markdown-save-state.ok{ color:var(--success); }
@@ -877,14 +902,14 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.preview-authorization{ display:flex; align-items:center; gap:8px; margin:auto; padding:14px; color:var(--dim); font-size:13px; }
.preview-authorization.compact{ display:inline-flex; margin:6px 0; padding:8px 0; font-size:12px; }
.preview-truncated{ margin-bottom:12px; color:var(--dim); background:var(--raised); border:1px solid var(--border); }
-.markdown-editor{ display:block; box-sizing:border-box; width:100%; min-height:calc(100% - 2px); resize:none; border:1px solid var(--border); border-radius:9px; padding:12px 14px; background:var(--raised); color:var(--text); font:12.5px/1.65 var(--mono); tab-size:2; white-space:pre-wrap; overflow-wrap:anywhere; }
+.markdown-editor{ display:block; box-sizing:border-box; width:100%; min-height:calc(100% - 2px); resize:none; border:1px solid var(--border); border-radius:9px; padding:12px 14px; background:var(--raised); color:var(--text); font:var(--font-weight-400) 12.5px/1.65 var(--mono); tab-size:2; white-space:pre-wrap; overflow-wrap:anywhere; }
.markdown-editor:focus{ outline:none; border-color:var(--accent-line); box-shadow:0 0 0 2px var(--accent-weak); }
.markdown-editor:read-only{ color:var(--dim); cursor:not-allowed; }
.source-page-nav{ position:sticky; top:0; z-index:2; display:flex; align-items:center; justify-content:center; gap:10px; margin:0 0 8px; padding:7px 9px; border:1px solid var(--border); border-radius:9px; background:var(--surface); box-shadow:var(--shadow-sm); }
-.source-page-nav span{ color:var(--dim); font:11px/1.4 var(--mono); }
+.source-page-nav span{ color:var(--dim); font:var(--font-weight-400) 11px/1.4 var(--mono); }
.source-page-nav button{ padding:4px 8px; border-radius:7px; color:var(--accent-ink); background:var(--accent-weak); font-size:11px; }
.source-page-nav button:disabled{ color:var(--faint); background:var(--raised); cursor:default; }
-.source-file{ min-width:0; width:100%; border:1px solid var(--border); border-radius:9px; overflow:hidden; background:var(--raised); font:12px/1.6 var(--mono); tab-size:2; }
+.source-file{ min-width:0; width:100%; border:1px solid var(--border); border-radius:9px; overflow:hidden; background:var(--raised); font:var(--font-weight-400) 12px/1.6 var(--mono); tab-size:2; }
.source-line{ display:grid; grid-template-columns:5.5ch minmax(0,1fr); min-height:1.6em; }
.source-line-no{ padding:0 9px 0 5px; color:var(--faint); text-align:right; user-select:none; border-right:1px solid var(--border); background:var(--bg); }
.source-line code{ display:block; min-width:0; padding:0 11px; white-space:pre-wrap; overflow-wrap:anywhere; word-break:break-word; color:var(--text); }
@@ -920,51 +945,11 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
}
@media (min-width:981px){ .panel-resizer{ display:block; } }
-/* /btw ephemeral side-fork panel (mini chat over a forked session) */
-.btw-panel{ position:fixed; top:14px; right:14px; bottom:14px; width:var(--panel-w); z-index:36; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--r-panel); box-shadow:var(--shadow-lg); display:flex; flex-direction:column; overflow:hidden; animation:panel-in .24s var(--ease); }
-.btw-head{ display:flex; align-items:center; gap:10px; padding:12px 14px; border-bottom:1px solid var(--border); flex:none; padding-top:max(12px, env(safe-area-inset-top)); }
-.btw-titles{ flex:1; display:flex; flex-direction:column; gap:1px; min-width:0; }
-.btw-title{ font-weight:650; font-size:13px; color:var(--accent); }
-.btw-sub{ font-size:11px; color:var(--dim); }
-.btw-new{ color:var(--accent); }
-.btw-chat-tabs{ flex:none; display:flex; gap:6px; padding:8px 10px; overflow-x:auto; overscroll-behavior-x:contain; scrollbar-width:thin; border-bottom:1px solid var(--border); background:color-mix(in srgb,var(--surface) 88%,var(--bg)); }
-.btw-chat-tab-wrap{ flex:0 0 auto; max-width:190px; display:flex; align-items:center; border:1px solid var(--border); border-radius:9px; background:var(--bg); color:var(--dim); transition:border-color .16s var(--ease),background .16s var(--ease),color .16s var(--ease); }
-.btw-chat-tab-wrap.active{ border-color:color-mix(in srgb,var(--accent) 58%,var(--border)); background:color-mix(in srgb,var(--accent) 10%,var(--surface)); color:var(--text); }
-.btw-chat-tab{ min-width:0; max-width:154px; display:flex; align-items:center; gap:7px; padding:7px 6px 7px 9px; border:0; background:transparent; color:inherit; font:inherit; font-size:11px; cursor:pointer; }
-.btw-chat-label{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
-.btw-chat-state{ width:6px; height:6px; flex:none; border-radius:50%; background:var(--border-strong); }
-.btw-chat-state.running,.btw-chat-state.interrupting,.btw-chat-state.draining{ background:var(--accent); box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 14%,transparent); }
-.btw-chat-question{ flex:none; border-radius:999px; padding:1px 5px; color:var(--accent); background:color-mix(in srgb,var(--accent) 14%,transparent); font-size:9px; line-height:14px; }
-.btw-chat-close{ width:26px; height:28px; flex:none; display:grid; place-items:center; border:0; border-left:1px solid transparent; background:transparent; color:var(--dim); cursor:pointer; }
-.btw-chat-close:hover{ color:var(--danger); }
-.btw-body{ flex:1; min-height:0; display:flex; flex-direction:column; overflow:hidden; padding:4px 2px; }
-.btw-empty{ flex:1; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:28px 20px; color:var(--dim); font-size:13px; line-height:1.7; text-align:center; }
-.btw-composer{ position:relative; flex:none; padding:9px 12px 8px; border-top:1px solid var(--border); background:var(--bg); }
-.btw-composer-notice{ position:absolute; left:50%; bottom:calc(100% + 8px); z-index:3; transform:translateX(-50%); padding:7px 11px; border:1px solid var(--border); border-radius:9px; background:var(--surface); box-shadow:var(--shadow-sm); color:var(--dim); font-size:11px; white-space:nowrap; }
-.btw-queued{ display:flex; gap:6px; padding:0 0 8px; overflow-x:auto; scrollbar-width:none; }
-.btw-queued::-webkit-scrollbar{ display:none; }
-.btw-queued .qchip{ flex:none; max-width:min(280px,72vw); }
-.btw-queued .qx{ display:inline-flex; align-items:center; justify-content:center; }
-.btw-runbar{ display:flex; padding:0 0 8px; }
-.btw-runbar .seg{ margin-left:auto; }
-.btw-input{ display:flex; align-items:flex-end; gap:8px; }
-.btw-input textarea{ flex:1; resize:none; border:1px solid var(--border-strong); border-radius:12px; background:var(--bg); color:var(--text); font:inherit; font-size:14px; padding:9px 12px; max-height:120px; line-height:1.4; }
-.btw-input textarea:focus{ outline:none; border-color:var(--accent); }
-.btw-input textarea:disabled{ background:color-mix(in srgb,var(--dim) 10%,var(--bg)); color:var(--dim); border-color:var(--border); cursor:not-allowed; opacity:.65; }
-.btw-readonly-notice{ color:var(--dim); font-size:12px; line-height:1.5; margin-bottom:8px; overflow-wrap:anywhere; }
-.btw-send{ flex:none; width:38px; height:38px; border-radius:10px; border:0; background:var(--accent); color:#fff; display:inline-flex; align-items:center; justify-content:center; cursor:pointer; }
-.btw-send.interrupt{ background:var(--danger); }
-.btw-send:disabled{ opacity:.4; cursor:default; }
-.btw-controls{ display:flex; align-items:center; justify-content:flex-end; gap:12px; min-width:0; padding:7px 2px 0; color:var(--faint); font-size:10px; }
-.btw-controls>span{ margin-right:auto; white-space:nowrap; }
-.btw-controls .hint-ctl{ max-width:42%; overflow:hidden; text-overflow:ellipsis; }
-@media (max-width:980px){ .btw-panel{ top:auto; left:10px; right:10px; bottom:calc(10px + var(--keyboard-inset,0px)); width:auto; max-height:min(82vh,calc(var(--app-height,100dvh) - 20px)); animation:panel-in-m .24s var(--ease); } }
-
/* Shared tabs for the right-side artifact and btw surfaces. */
.panel-tabs{ display:flex; gap:3px; flex:1; min-width:0; overflow-x:auto; scrollbar-width:none; }
.panel-tabs::-webkit-scrollbar{ display:none; }
-.ptab{ display:inline-flex; align-items:center; gap:5px; border:0; background:none; padding:5px 11px; border-radius:8px; font:inherit; font-size:13px; font-weight:650; color:var(--dim); cursor:pointer; white-space:nowrap; transition:color .12s, background .12s; }
-.ptab.on{ color:var(--accent); background:var(--accent-weak); }
+.ptab{ display:inline-flex; align-items:center; gap:5px; border:0; background:none; padding:5px 11px; border-radius:8px; font:inherit; font-size:13px; font-weight:var(--font-weight-650); color:var(--dim); cursor:pointer; white-space:nowrap; transition:color .12s, background .12s; }
+.ptab.on{ color:var(--accent-text,var(--accent)); background:var(--accent-weak); }
.ptab:not(.on):hover{ color:var(--text); }
/* desktop: an open right panel PUSHES the chat left (split), not covers it.
@@ -991,9 +976,9 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
/* EMPTY STATE */
.empty{ flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center;
text-align:center; padding:40px; gap:6px; }
-.empty .glyph{ color:var(--accent); display:grid; place-items:center; margin-bottom:14px; }
+.empty .glyph{ color:var(--accent-text,var(--accent)); display:grid; place-items:center; margin-bottom:14px; }
.empty .glyph svg{ width:48px; height:48px; }
-.empty h2{ font-family:var(--serif); font-weight:500; font-size:25px; margin:0; letter-spacing:-.01em; }
+.empty h2{ font-family:var(--serif); font-weight:var(--font-weight-500); font-size:25px; margin:0; letter-spacing:-.01em; }
.empty p{ color:var(--dim); margin:0; max-width:280px; font-size:14px; }
.empty .spinner{ width:30px; height:30px; border-radius:50%; margin-bottom:14px;
border:3px solid var(--border); border-top-color:var(--accent); animation:spin .7s linear infinite; }
@@ -1012,7 +997,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.paste-card-icon{ display:grid; width:26px; height:26px; flex:none; place-items:center;
color:var(--dim); background:transparent; }
.paste-card-body{ display:flex; min-width:0; flex:1; flex-direction:column; gap:2px; }
-.paste-card-preview{ overflow:hidden; color:var(--text); font-size:12.5px; font-weight:550;
+.paste-card-preview{ overflow:hidden; color:var(--text); font-size:12.5px; font-weight:var(--font-weight-550);
text-overflow:ellipsis; white-space:nowrap; }
.paste-card-meta{ color:var(--faint); font-size:10.5px; }
.paste-card>.attach-x{ position:absolute; top:5px; right:5px; opacity:.48;
@@ -1032,13 +1017,13 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
background:transparent; color:var(--dim); cursor:pointer; }
.paste-preview textarea{ flex:1; min-height:0; resize:none; margin:12px 14px; padding:12px;
border:1px solid var(--border); border-radius:10px; outline:0; background:var(--raised);
- color:var(--text); font:12px/1.6 var(--mono); white-space:pre-wrap; overflow-wrap:anywhere; }
+ color:var(--text); font:var(--font-weight-400) 12px/1.6 var(--mono); white-space:pre-wrap; overflow-wrap:anywhere; }
.paste-preview textarea:focus{ border-color:var(--accent-line); box-shadow:0 0 0 3px var(--accent-weak); }
.paste-preview footer{ display:flex; justify-content:flex-end; gap:8px; padding:10px 14px;
border-top:1px solid var(--border); }
.paste-preview footer button{ min-width:70px; padding:7px 12px; border:1px solid var(--border);
border-radius:9px; background:var(--surface); color:var(--dim); font-size:12px; }
-.paste-preview footer button.primary{ border-color:var(--accent); background:var(--accent); color:white; }
+.paste-preview footer button.primary{ border-color:var(--accent); background:var(--accent); color:var(--on-accent,#fff); }
.paste-preview footer button:disabled{ opacity:.45; cursor:default; }
@media(max-width:979px){
.paste-card{ width:min(86vw,300px); }
@@ -1051,7 +1036,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
background:var(--surface); box-shadow:var(--shadow-sm); }
.work-compose-caption{ display:flex; align-items:center; gap:9px; min-width:0; padding:0 2px 9px; color:var(--dim); }
.work-compose-caption>span:nth-child(2){ display:flex; flex:1; min-width:0; flex-direction:column; }
-.work-compose-caption b{ color:var(--text); font-size:12.5px; font-weight:600; }
+.work-compose-caption b{ color:var(--text); font-size:12.5px; font-weight:var(--font-weight-600); }
.work-compose-caption small{ overflow:hidden; color:var(--faint); font-size:10.5px; text-overflow:ellipsis; white-space:nowrap; }
.work-compose-icon{ width:28px; height:28px; flex:none; display:grid; place-items:center; border-radius:9px;
color:var(--accent-ink); background:var(--accent-weak); }
@@ -1077,7 +1062,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.capabilities-sheet>header small{ color:var(--dim); }
.capabilities-head-actions{ display:flex; gap:6px; }
.capabilities-tabs{ display:flex; flex:none; gap:4px; padding:9px 14px; overflow-x:auto; border-bottom:1px solid var(--border); }
-.capabilities-tabs button{ flex:none; padding:7px 11px; border-radius:9px; color:var(--dim); font-size:12px; font-weight:650; }
+.capabilities-tabs button{ flex:none; padding:7px 11px; border-radius:9px; color:var(--dim); font-size:12px; font-weight:var(--font-weight-650); }
.capabilities-tabs button.active{ color:var(--accent-ink); background:var(--accent-weak); box-shadow:inset 0 0 0 1px var(--accent-line); }
.capabilities-body{ flex:1; min-height:0; padding:18px; overflow:auto; overscroll-behavior:contain; }
.capabilities-note,.capabilities-errors{
@@ -1087,7 +1072,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.capabilities-errors{ display:flex; flex-direction:column; gap:4px; color:var(--danger); }
.capabilities-group{ margin:0 0 20px; }
.capabilities-group h3{ display:flex; align-items:center; gap:8px; margin:0 0 8px; font-size:15px; }
-.capabilities-group h3 span{ color:var(--dim); font-size:12px; font-weight:500; }
+.capabilities-group h3 span{ color:var(--dim); font-size:12px; font-weight:var(--font-weight-500); }
.capabilities-group article{
display:flex; align-items:flex-start; justify-content:space-between; gap:18px;
padding:12px 2px; border-bottom:1px solid var(--border);
@@ -1104,7 +1089,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
}
.capabilities-item-actions button.danger{ color:var(--danger); border-color:color-mix(in srgb,var(--danger) 40%,transparent); }
.capabilities-create{ margin-bottom:18px; padding:12px; border:1px solid var(--border); border-radius:14px; background:var(--raised); }
-.capabilities-create>button{ color:var(--accent-ink); font-size:13px; font-weight:700; }
+.capabilities-create>button{ color:var(--accent-ink); font-size:13px; font-weight:var(--font-weight-700); }
.capabilities-create form{ display:grid; gap:9px; margin-top:12px; }
.capabilities-create input,.capabilities-create textarea,.capabilities-create select{
width:100%; min-height:40px; padding:9px 11px; border:1px solid var(--border); border-radius:9px;
@@ -1137,16 +1122,16 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.device-pairing{ display:grid; grid-template-columns:1fr auto; align-items:start; gap:12px; padding:16px; border:1px solid var(--border); border-radius:16px; background:var(--raised); }
.device-pairing b{ font-size:14px; }
.device-pairing p{ margin:4px 0 0; color:var(--dim); font-size:12px; line-height:1.5; }
-.device-pairing>button,.device-code>button{ padding:8px 11px; border:1px solid var(--accent-line); border-radius:9px; color:var(--accent-ink); background:var(--accent-weak); font-size:12px; font-weight:650; }
+.device-pairing>button,.device-code>button{ padding:8px 11px; border:1px solid var(--accent-line); border-radius:9px; color:var(--accent-ink); background:var(--accent-weak); font-size:12px; font-weight:var(--font-weight-650); }
.device-pairing>button.subtle{ color:var(--dim); border-color:var(--border); background:var(--surface); }
.device-code{ grid-column:1/-1; display:flex; flex-direction:column; align-items:flex-start; gap:8px; padding-top:12px; border-top:1px solid var(--border); }
.device-code>span,.device-code>small{ color:var(--dim); font-size:11px; }
-.device-code>strong{ color:var(--accent-ink); font:700 20px/1.2 var(--mono); letter-spacing:.06em; }
-.device-code>code{ display:block; width:100%; padding:10px 12px; overflow:auto; border:1px solid var(--border); border-radius:9px; background:var(--surface); font:11px/1.45 var(--mono); white-space:nowrap; }
+.device-code>strong{ color:var(--accent-ink); font:var(--font-weight-700) 20px/1.2 var(--mono); letter-spacing:.06em; }
+.device-code>code{ display:block; width:100%; padding:10px 12px; overflow:auto; border:1px solid var(--border); border-radius:9px; background:var(--surface); font:var(--font-weight-400) 11px/1.45 var(--mono); white-space:nowrap; }
.device-pairing-live{ grid-column:1/-1; padding-top:10px; border-top:1px solid var(--border); }
.device-list{ margin-top:20px; }
.device-list h3{ display:flex; gap:8px; margin:0 0 9px; font-size:14px; }
-.device-list h3 span{ color:var(--dim); font-size:12px; font-weight:500; }
+.device-list h3 span{ color:var(--dim); font-size:12px; font-weight:var(--font-weight-500); }
.device-list article{ position:relative; margin-bottom:9px; overflow:hidden; border:1px solid var(--border); border-radius:14px; background:var(--surface); }
.device-list article.current{ border-color:var(--accent-line); box-shadow:inset 3px 0 0 var(--accent); }
.device-main{ display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:11px; width:100%; padding:13px 14px; text-align:left; }
@@ -1178,7 +1163,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.work-compose-foot{ position:relative; display:flex; align-items:center; gap:4px; min-height:37px;
padding-top:8px; border-top:1px solid var(--border); }
.work-compose-tool,.work-settings>summary{ display:flex; align-items:center; gap:6px; min-height:30px;
- padding:5px 8px; border-radius:8px; color:var(--dim); font-size:11.5px; font-weight:600; cursor:pointer; }
+ padding:5px 8px; border-radius:8px; color:var(--dim); font-size:11.5px; font-weight:var(--font-weight-600); cursor:pointer; }
.work-compose-tool:hover,.work-settings>summary:hover{ color:var(--text); background:var(--raised); }
.work-compose-tool:disabled{ opacity:.45; cursor:default; }
.work-settings{ position:relative; }
@@ -1190,7 +1175,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
padding:9px 10px; border-radius:9px; color:var(--dim); text-align:left; font-size:12px; }
.work-settings-pop>button:hover{ color:var(--text); background:var(--raised); }
.work-settings-pop>button:disabled{ opacity:.45; cursor:default; background:transparent; }
-.work-settings-pop>button b{ max-width:160px; overflow:hidden; color:var(--text); font-weight:600;
+.work-settings-pop>button b{ max-width:160px; overflow:hidden; color:var(--text); font-weight:var(--font-weight-600);
text-overflow:ellipsis; white-space:nowrap; }
.work-ctx-pop{ right:0; left:auto; bottom:calc(100% + 8px); }
.work-ctx-details{ display:flex; flex-direction:column; gap:6px; padding-top:10px; border-top:1px solid var(--border); }
@@ -1223,8 +1208,8 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
animation:popin .16s var(--ease); overscroll-behavior:contain; touch-action:pan-y; }
@keyframes popin{ from{ opacity:0; transform:translateY(6px) } to{ opacity:1; transform:none } }
.composer-notice{ position:absolute; left:50%; transform:translateX(-50%); bottom:calc(100% + 10px);
- z-index:22; width:max-content; max-width:92%; background:var(--accent); color:#fff;
- padding:7px 14px; border-radius:999px; font-size:13px; font-weight:500; box-shadow:var(--shadow-lg); }
+ z-index:22; width:max-content; max-width:92%; background:var(--accent); color:var(--on-accent,#fff);
+ padding:7px 14px; border-radius:999px; font-size:13px; font-weight:var(--font-weight-500); box-shadow:var(--shadow-lg); }
.queued{ display:none; flex-wrap:wrap; gap:6px; padding:4px 4px 8px; }
.queued.show{ display:flex; }
.qchip{ display:inline-flex; align-items:center; gap:2px; max-width:100%; min-width:0;
@@ -1240,7 +1225,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
padding:0; border:0; border-radius:5px; background:transparent; color:var(--faint); }
.qchip .qx:hover{ background:var(--danger-weak); color:var(--danger); }
.qchip .qx svg{ width:12px; height:12px; }
-.qchip .qbadge{ flex:none; font-size:10px; font-weight:700; color:var(--accent-ink); background:var(--accent-weak); padding:1px 6px; border-radius:20px; }
+.qchip .qbadge{ flex:none; font-size:10px; font-weight:var(--font-weight-700); color:var(--accent-ink); background:var(--accent-weak); padding:1px 6px; border-radius:20px; }
.qchip.error .qbadge{ color:var(--danger); background:var(--danger-weak); }
.queued-query-sheet{ min-height:min(420px,70dvh); }
.queued-query-head{ display:flex; align-items:flex-start; justify-content:space-between;
@@ -1268,9 +1253,9 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
padding:10px 18px calc(12px + env(safe-area-inset-bottom)); border-top:1px solid var(--divider); }
.queued-query-actions button{ display:inline-flex; min-height:38px; align-items:center;
justify-content:center; gap:6px; border-radius:10px; padding:0 15px;
- font-size:13px; font-weight:600; }
+ font-size:13px; font-weight:var(--font-weight-600); }
.queued-query-actions button:disabled{ opacity:.5; cursor:default; }
-.queued-query-primary{ background:var(--accent); color:#fff; }
+.queued-query-primary{ background:var(--accent); color:var(--on-accent,#fff); }
.queued-query-secondary{ border:1px solid var(--border); background:var(--surface); color:var(--dim); }
.attach{ display:none; flex-wrap:wrap; gap:6px; padding:4px 4px 8px; }
.attach.show{ display:flex; }
@@ -1297,10 +1282,11 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
border:2.5px dashed var(--accent); border-radius:22px; background:var(--surface);
box-shadow:0 12px 40px rgba(0,0,0,.22); }
.drop-card .dc-ic{ display:grid; place-items:center; width:64px; height:64px; border-radius:50%;
- background:var(--accent-weak); color:var(--accent); }
+ background:var(--accent-weak); color:var(--accent-text,var(--accent)); }
.drop-card .dc-ic svg{ width:36px; height:36px; }
-.drop-card .dc-tx{ font-size:15.5px; font-weight:600; color:var(--text); }
+.drop-card .dc-tx{ font-size:15.5px; font-weight:var(--font-weight-600); color:var(--text); }
.drop-card .dc-sub{ font-size:12.5px; color:var(--dim); }
+@media (min-width:981px){ .shell.panel-open .drop-overlay-main{ right:calc(var(--panel-w) + 28px); } }
.ubub-imgs{ display:flex; flex-wrap:wrap; justify-content:flex-end; align-items:flex-end; gap:8px; width:fit-content; max-width:100%; margin-bottom:6px; }
.ubub-image-trigger{ display:block; position:relative; flex:0 1 auto; width:var(--user-image-width); max-width:100%; padding:0; border:1px solid var(--border);
border-radius:10px; background:var(--soft); overflow:hidden; cursor:zoom-in; touch-action:manipulation; }
@@ -1357,13 +1343,13 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
}
.ubub-files{ display:flex; flex-wrap:wrap; gap:6px; max-width:82%; margin-left:auto; margin-bottom:6px; }
.ubub-file{ display:inline-flex; align-items:center; gap:6px; background:var(--accent-weak); border:1px solid var(--accent-line); border-radius:8px; padding:5px 9px; font-size:12.5px; color:var(--text); }
-.ubub-file svg{ width:14px; height:14px; color:var(--accent); }
+.ubub-file svg{ width:14px; height:14px; color:var(--accent-text,var(--accent)); }
/* control bar (model + perm chips, idle only) */
.ctrlbar{ display:none; align-items:center; gap:8px; padding:2px 2px 9px; flex-wrap:wrap; }
.ctrlbar.show{ display:flex; }
/* frameless bottom-bar controls (model / effort) — no box, just text */
-.hint-ctl{ border:0; background:none; padding:0; font:inherit; font-weight:600; font-size:12px;
+.hint-ctl{ border:0; background:none; padding:0; font:inherit; font-weight:var(--font-weight-600); font-size:12px;
color:var(--text); cursor:pointer; white-space:nowrap; transition:color .14s; }
.hint-ctl:hover{ color:var(--accent-ink); }
.hint-ctl:disabled{ opacity:.45; cursor:default; color:inherit; }
@@ -1371,7 +1357,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
border:1px solid var(--accent-line); border-radius:999px; padding:1px 8px; }
/* Codex Fast mode keeps a stable text-only footprint in both states. */
.fast-chip{ flex:none; color:var(--dim); }
-.fast-chip.on{ color:var(--accent); }
+.fast-chip.on{ color:var(--accent-text,var(--accent)); }
/* context-usage ring */
.hint-ring{ border:0; background:none; padding:0; display:inline-flex; cursor:pointer; line-height:0; flex:none; }
.hint-ring svg{ display:block; transition:transform .14s; }
@@ -1457,12 +1443,12 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.codex-context-control p[role="alert"]{ color:var(--danger); }
.codex-context-input{ display:flex; gap:6px; margin-top:6px; }
.codex-context-input input{ width:100%; min-width:0; padding:8px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text); }
-.codex-context-input button{ flex:none; padding:8px 12px; background:var(--accent); color:white; border-radius:8px; }
+.codex-context-input button{ flex:none; padding:8px 12px; background:var(--accent); color:var(--on-accent,#fff); border-radius:8px; }
.codex-context-default{ align-self:flex-start; color:var(--accent-ink); padding:5px 0; }
.auto-compact-pop .auto-compact-control{ margin:0; padding:0; border:0; }
.auto-compact-head{ display:flex; align-items:center; justify-content:space-between;
gap:10px; color:var(--dim); font-size:11.5px; }
-.auto-compact-head b{ color:var(--text); font-size:11px; font-weight:650; }
+.auto-compact-head b{ color:var(--text); font-size:11px; font-weight:var(--font-weight-650); }
.auto-compact-options{ display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:5px; }
.auto-compact-options button{ min-width:0; padding:6px 5px; border:1px solid var(--border);
border-radius:8px; background:var(--raised); color:var(--dim); font-size:10.5px;
@@ -1476,10 +1462,10 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.auto-compact-custom label{ display:flex; align-items:center; flex:1; min-width:0;
border:1px solid var(--border); border-radius:8px; background:var(--bg); overflow:hidden; }
.auto-compact-custom input{ min-width:0; width:100%; padding:7px 4px 7px 8px;
- border:0; outline:0; background:transparent; color:var(--text); font:11px var(--mono); }
+ border:0; outline:0; background:transparent; color:var(--text); font:var(--font-weight-400) 11px var(--mono); }
.auto-compact-custom label span{ flex:none; padding-right:8px; color:var(--faint); font-size:9.5px; }
.auto-compact-custom>button{ padding:7px 10px; border-radius:8px;
- background:var(--accent); color:#fff; font-size:10.5px; }
+ background:var(--accent); color:var(--on-accent,#fff); font-size:10.5px; }
.auto-compact-custom>button:disabled{ opacity:.45; cursor:default; }
.auto-compact-meta{ display:flex; flex-direction:column; gap:3px; color:var(--faint);
font-size:10px; line-height:1.45; }
@@ -1491,17 +1477,17 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.auto-compact-sheet .auto-compact-options button{ min-height:38px; font-size:12px; }
.auto-compact-sheet .auto-compact-meta{ font-size:11px; }
-/* run bar (running only) */
+/* Composer activity: send mode and independently running background tasks. */
.runbar{ display:none; align-items:center; gap:8px; padding:2px 2px 9px; }
.runbar.show{ display:flex; }
-.stopbtn{ display:inline-flex; align-items:center; gap:7px; font-size:13px; font-weight:600;
+.stopbtn{ display:inline-flex; align-items:center; gap:7px; font-size:13px; font-weight:var(--font-weight-600);
color:var(--danger); background:var(--danger-weak); border:1px solid transparent;
padding:7px 13px 7px 11px; border-radius:11px; transition:.14s; }
.stopbtn:hover{ filter:brightness(.97); }
.stopbtn svg{ width:13px; height:13px; }
.seg{ display:flex; margin-left:auto; background:var(--surface); border:1px solid var(--border);
border-radius:11px; padding:3px; gap:2px; }
-.seg button{ display:inline-flex; align-items:center; gap:6px; font-size:12.5px; font-weight:600;
+.seg button{ display:inline-flex; align-items:center; gap:6px; font-size:12.5px; font-weight:var(--font-weight-600);
color:var(--dim); padding:6px 11px; border-radius:8px; transition:.14s; }
.seg button svg{ width:14px; height:14px; }
.seg button.on{ background:var(--accent-weak); color:var(--accent-ink); }
@@ -1520,13 +1506,13 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
field-sizing:content; }
.inrow textarea::placeholder{ color:var(--faint); }
.sendbtn{ width:38px; height:38px; border-radius:50%; display:grid; place-items:center;
- background:var(--accent); color:#fff; flex:none; transition:.16s; }
+ background:var(--accent); color:var(--on-accent,#fff); flex:none; transition:.16s; }
.sendbtn:disabled{ background:var(--border-strong); color:var(--faint); cursor:default; }
.sendbtn svg{ width:19px; height:19px; }
-.sendbtn.interrupt{ background:var(--danger); }
+.sendbtn.interrupt{ background:var(--danger); color:var(--on-danger,#fff); }
.hint{ display:flex; align-items:center; gap:12px; font-size:11px; color:var(--faint); padding:7px 2px 1px; }
.hint-right{ position:relative; display:flex; align-items:center; gap:14px; margin-left:auto; flex:none; }
-.hint-control-scope{ color:var(--faint); font-size:10px; font-weight:600; white-space:nowrap; }
+.hint-control-scope{ color:var(--faint); font-size:10px; font-weight:var(--font-weight-600); white-space:nowrap; }
.hint kbd{ font-family:var(--mono); font-size:10px; background:var(--raised);
border:1px solid var(--border); border-radius:4px; padding:0 4px; }
.hint-mode{ min-width:0; max-width:min(280px,45vw); overflow:hidden; text-overflow:ellipsis;
@@ -1594,27 +1580,27 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.sheet-search svg{ width:17px; height:17px; flex:none; }
.sheet-search input{ border:none; background:none; outline:none; color:var(--text); width:100%; font-size:16px; }
.sheet-scroll{ overflow-y:auto; padding:6px 10px 14px; }
-.sheet-title{ font-size:12.5px; font-weight:600; letter-spacing:.02em; color:var(--dim); padding:8px 20px 2px; }
+.sheet-title{ font-size:12.5px; font-weight:var(--font-weight-600); letter-spacing:.02em; color:var(--dim); padding:8px 20px 2px; }
.sheet-title .qa-ic{ display:inline-grid; place-items:center; width:18px; height:18px; border-radius:6px;
- background:var(--accent); color:#fff; margin-right:7px; vertical-align:-3px; }
+ background:var(--accent); color:var(--on-accent,#fff); margin-right:7px; vertical-align:-3px; }
.sheet-title .qa-ic svg{ width:13px; height:13px; }
-.qa-question{ font-size:14.5px; font-weight:600; color:var(--text); line-height:1.5; padding:6px 16px 2px; white-space:pre-wrap; overflow-wrap:anywhere; }
+.qa-question{ font-size:14.5px; font-weight:var(--font-weight-600); color:var(--text); line-height:1.5; padding:6px 16px 2px; white-space:pre-wrap; overflow-wrap:anywhere; }
.qa-options{ display:flex; flex-direction:column; gap:8px; padding:10px 16px 16px; }
.qa-opt{ display:flex; flex-direction:column; gap:3px; text-align:left; padding:11px 13px; border-radius:11px;
background:var(--surface); border:1px solid var(--border); color:var(--text); transition:.14s; }
.qa-opt:hover{ background:var(--accent-weak); border-color:var(--accent-line); }
.qa-opt.selected{ background:var(--accent-weak); border-color:var(--accent); }
-.qa-opt-label{ font-size:14px; font-weight:600; }
+.qa-opt-label{ font-size:14px; font-weight:var(--font-weight-600); }
.qa-opt-ds{ font-size:12.5px; color:var(--dim); }
-.qa-multi-submit{ min-height:42px; border-radius:11px; background:var(--accent); color:#fff;
- font-size:14px; font-weight:600; }
+.qa-multi-submit{ min-height:42px; border-radius:11px; background:var(--accent); color:var(--on-accent,#fff);
+ font-size:14px; font-weight:var(--font-weight-600); }
.qa-multi-submit:disabled{ opacity:.45; cursor:default; }
-.cmd-group{ font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase;
+.cmd-group{ font-size:11px; font-weight:var(--font-weight-600); letter-spacing:.06em; text-transform:uppercase;
color:var(--faint); padding:12px 12px 5px; }
.cmd-empty{ color:var(--dim); font-size:13px; padding:14px 12px 18px; }
.cmd-search{ display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:5px 12px; }
.cmd-search button{ min-height:38px; border:1px solid var(--border); border-radius:10px;
- color:var(--dim); background:var(--surface); font-size:13px; font-weight:600; }
+ color:var(--dim); background:var(--surface); font-size:13px; font-weight:var(--font-weight-600); }
.cmd-search button.sel{ color:var(--accent-ink); border-color:var(--accent-line);
background:var(--accent-weak); }
.cmd-search-note{ color:var(--faint); font-size:11.5px; line-height:1.45;
@@ -1626,17 +1612,17 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
@media (hover:hover){ .cmd:hover{ background:var(--accent-weak); } }
@media (hover:hover){ .cmd:disabled:hover{ background:transparent; } }
.cmd-ic{ width:34px; height:34px; border-radius:10px; background:var(--bg); border:1px solid var(--border);
- display:grid; place-items:center; flex:none; color:var(--accent); }
+ display:grid; place-items:center; flex:none; color:var(--accent-text,var(--accent)); }
.cmd.sel .cmd-ic{ border-color:var(--accent-line); }
@media (hover:hover){ .cmd:hover .cmd-ic{ border-color:var(--accent-line); } }
.cmd-ic svg{ width:17px; height:17px; }
.cmd-tx{ flex:1; min-width:0; }
.cmd-nm{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
- font-weight:600; font-size:14.5px; display:flex; align-items:center; gap:8px; }
-.cmd-nm .slash{ font-family:var(--mono); color:var(--accent-ink); font-weight:600; }
+ font-weight:var(--font-weight-600); font-size:14.5px; display:flex; align-items:center; gap:8px; }
+.cmd-nm .slash{ font-family:var(--mono); color:var(--accent-ink); font-weight:var(--font-weight-600); }
.cmd-ds{ font-size:12.5px; color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.cmd-kbd{ font-family:var(--mono); font-size:11px; color:var(--faint); flex:none; width:18px; text-align:center; }
-.cmd-check{ color:var(--accent); flex:none; display:grid; place-items:center; }
+.cmd-check{ color:var(--accent-text,var(--accent)); flex:none; display:grid; place-items:center; }
.cmd-check svg{ width:19px; height:19px; }
.cmd.danger .cmd-ic{ color:var(--danger); background:var(--danger-weak); border-color:transparent; }
.cmd.danger.sel{ background:var(--danger-weak); }
@@ -1704,7 +1690,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.login-brand{ display:inline-flex; align-items:center; gap:11px; margin-bottom:6px; }
.login-brand .dot{ width:13px; height:13px; border-radius:50%; background:var(--accent); box-shadow:0 0 0 5px var(--accent-weak); }
.login-brand .name{ font-family:var(--serif); font-size:29px; letter-spacing:-.015em; }
-.login-brand .name b{ font-weight:600; } .login-brand .name span{ color:var(--dim); }
+.login-brand .name b{ font-weight:var(--font-weight-600); } .login-brand .name span{ color:var(--dim); }
.login-tag{ color:var(--dim); font-size:14px; margin:0 0 26px; }
.login-field{ display:flex; align-items:center; gap:10px; background:var(--surface);
border:1.5px solid var(--border-strong); border-radius:14px; padding:0 14px; height:52px;
@@ -1722,7 +1708,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
border-radius:10px; color:var(--dim); transition:color .16s,background .16s; }
.login-reveal:hover,.login-reveal[aria-pressed="true"]{ color:var(--accent-ink); background:var(--accent-weak); }
.login-reveal:disabled{ opacity:.45; cursor:default; }
-.login-btn{ width:100%; height:50px; background:var(--accent); color:#fff; font-weight:600;
+.login-btn{ width:100%; height:50px; background:var(--accent); color:var(--on-accent,#fff); font-weight:var(--font-weight-600);
font-size:15.5px; border-radius:14px; transition:.16s; }
.login-btn:hover{ filter:brightness(1.05); }
.login-err{ color:var(--danger); font-size:13px; min-height:18px; margin:-4px 0 8px; }
@@ -1746,7 +1732,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
gap:0; border:0; border-radius:12px; background:var(--raised); }
.space-switch button{ min-height:38px; border:1px solid transparent; border-radius:9px; background:transparent;
display:flex; align-items:center; justify-content:center; gap:8px; color:var(--dim);
- font-size:14px; font-weight:500; transition:background .14s,color .14s,border-color .14s; }
+ font-size:14px; font-weight:var(--font-weight-500); transition:background .14s,color .14s,border-color .14s; }
.space-switch button svg{ flex:none; }
.space-switch button:hover{ color:var(--text); }
.space-switch button:focus-visible{ outline:2px solid var(--accent); outline-offset:-3px; }
@@ -1763,16 +1749,16 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.profile-tone{ width:7px; height:7px; border-radius:50%; flex:none; background:var(--profile-color); }
.brand{ display:flex; align-items:center; gap:10px; margin-right:auto; cursor:pointer; }
.brand .dot{ width:11px; height:11px; border-radius:50%; background:var(--accent); box-shadow:0 0 0 4px var(--accent-weak); flex:none; }
-.brand-mark{ color:var(--accent); display:grid; place-items:center; flex:none; }
+.brand-mark{ color:var(--accent-text,var(--accent)); display:grid; place-items:center; flex:none; }
.brand .name{ font-family:var(--serif); font-size:21px; letter-spacing:-.01em; }
-.brand .name b{ font-weight:600; } .brand .name span{ color:var(--dim); }
+.brand .name b{ font-weight:var(--font-weight-600); } .brand .name span{ color:var(--dim); }
.search{ margin:2px 18px 12px; display:flex; align-items:center; gap:9px;
background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:0 12px; height:42px; flex:none; color:var(--dim); }
.search input{ border:none; background:none; outline:none; color:var(--text); width:100%; font-size:16px; }
.search input::placeholder{ color:var(--faint); }
.search svg{ width:17px; height:17px; flex:none; }
.s-scroll{ flex:1; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:2px 12px 12px; position:relative; }
-.s-group{ font-size:11.5px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--faint); padding:14px 8px 7px; }
+.s-group{ font-size:11.5px; font-weight:var(--font-weight-600); letter-spacing:.06em; text-transform:uppercase; color:var(--faint); padding:14px 8px 7px; }
.scard{ display:block; width:100%; text-align:left; position:relative; padding:13px 14px; border-radius:16px; margin-bottom:0; transition:.14s; border:1px solid transparent; -webkit-tap-highlight-color:transparent; user-select:none; -webkit-user-select:none; }
.scard + .scard{ margin-top:5px; }
.scard.has-profile-ribbon + .scard.has-profile-ribbon{ margin-top:7px; }
@@ -1798,7 +1784,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
/* reserve room on the right for the absolute ⋯ button (top:8/right:8, 28px wide)
so the "当前"/"运行" pill never sits under it. */
.scard-top{ display:flex; align-items:center; gap:9px; margin-bottom:3px; padding-right:30px; }
-.scard-title{ font-weight:600; font-size:14.5px; letter-spacing:-.01em; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; text-align:left; }
+.scard-title{ font-weight:var(--font-weight-600); font-size:14.5px; letter-spacing:-.01em; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; text-align:left; }
@media (max-width:979px){
.scard-top.has-btw-completion{ display:grid; grid-template-columns:minmax(0,1fr) auto auto; row-gap:6px; }
.scard-top.has-btw-completion .pill.completed{ grid-column:1 / -1; justify-self:start; }
@@ -1814,7 +1800,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
0 2px 5px color-mix(in srgb,var(--profile-color) 9%,transparent),
inset 0 1px 0 color-mix(in srgb,#fff 58%,transparent);
color:color-mix(in srgb,var(--profile-color) 70%,var(--text));
- font-family:var(--mono); font-size:8.5px; font-weight:650; line-height:1;
+ font-family:var(--mono); font-size:8.5px; font-weight:var(--font-weight-650); line-height:1;
letter-spacing:.035em; cursor:inherit; }
.tone-0{ --profile-color:#7c879a; }
.tone-1{ --profile-color:#8b6fd6; }
@@ -1829,14 +1815,14 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.scard-location span{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.scard-time{ font-size:12px; color:var(--faint); flex:none; font-variant-numeric:tabular-nums; }
.scard-prev{ font-size:13px; color:var(--dim); line-height:1.45; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; text-align:left; }
-.pill{ display:inline-flex; align-items:center; gap:5px; font-size:11px; font-weight:600; padding:2px 8px 2px 6px; border-radius:20px; flex:none; }
+.pill{ display:inline-flex; align-items:center; gap:5px; font-size:11px; font-weight:var(--font-weight-600); padding:2px 8px 2px 6px; border-radius:20px; flex:none; }
.pill .sd{ width:6px; height:6px; border-radius:50%; }
.pill.idle{ color:var(--ok); background:var(--ok-weak); } .pill.idle .sd{ background:var(--ok); }
.pill.completed{ color:var(--accent-ink); background:var(--accent-weak); }
.pill.completed .sd{ background:var(--accent); }
.pill.offline{ color:var(--dim); background:var(--border); } .pill.offline .sd{ background:var(--faint); }
.s-foot{ flex:none; padding:10px 12px calc(10px + env(safe-area-inset-bottom)); border-top:1px solid var(--border); background:var(--sidebar); }
-.newbtn{ display:flex; align-items:center; justify-content:center; gap:8px; width:100%; background:var(--accent); color:#fff; font-weight:600; font-size:14.5px; padding:12px 16px; border-radius:14px; box-shadow:var(--shadow-sm); transition:.16s; }
+.newbtn{ display:flex; align-items:center; justify-content:center; gap:8px; width:100%; background:var(--accent); color:var(--on-accent,#fff); font-weight:var(--font-weight-600); font-size:14.5px; padding:12px 16px; border-radius:14px; box-shadow:var(--shadow-sm); transition:.16s; }
.newbtn:hover{ filter:brightness(1.05); }
.newbtn svg{ width:19px; height:19px; }
@@ -1847,8 +1833,9 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.scard-act{ width:28px; height:28px; border-radius:10px; display:grid; place-items:center; color:var(--faint); background:var(--surface); border:1px solid var(--border); }
.scard-act:hover{ background:var(--accent-weak); color:var(--accent-ink); }
.scard-act svg{ width:15px; height:15px; }
-.card-menu{ position:absolute; top:36px; right:8px; z-index:5; min-width:152px; background:var(--surface);
- border:1px solid var(--border); border-radius:14px; box-shadow:var(--shadow); padding:5px; }
+.card-menu{ position:fixed; z-index:40; width:max-content; min-width:152px; background:var(--surface);
+ border:1px solid var(--border); border-radius:14px; box-shadow:var(--shadow); padding:5px;
+ overflow-y:auto; overscroll-behavior:contain; visibility:hidden; }
.card-menu button{ display:flex; align-items:center; gap:9px; width:100%; text-align:left; padding:8px 10px; border-radius:8px; font-size:13px; color:var(--text); }
.card-menu button:hover{ background:var(--accent-weak); }
.card-menu button.danger{ color:var(--danger); }
@@ -1862,7 +1849,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.s-lift-scrim{ position:absolute; inset:0; z-index:4; background:rgba(20,16,10,.42); opacity:0; pointer-events:none; transition:opacity .18s; }
:root[data-theme="dark"] .s-lift-scrim{ background:rgba(0,0,0,.62); }
.s-lift-scrim.show{ opacity:1; pointer-events:auto; }
-.scard-rename{ flex:1; min-width:0; font:inherit; font-weight:600; font-size:16px; border:1px solid var(--accent); border-radius:7px; padding:2px 6px; background:var(--bg); color:var(--text); outline:none; user-select:text; -webkit-user-select:text; }
+.scard-rename{ flex:1; min-width:0; font:inherit; font-weight:var(--font-weight-600); font-size:16px; border:1px solid var(--accent); border-radius:7px; padding:2px 6px; background:var(--bg); color:var(--text); outline:none; user-select:text; -webkit-user-select:text; }
.sgroup-head{ display:flex; align-items:center; position:relative; }
.sgroup-toggle{ display:flex; align-items:center; gap:7px; flex:1; min-width:0; text-align:left; padding:14px 8px 7px; }
.sgroup-toggle .sgroup-icon{ transition:transform .18s; display:grid; place-items:center; color:var(--faint); flex:none; }
@@ -1880,8 +1867,8 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.sgroup-toggle:hover~.sgroup-path-tip{ opacity:1; visibility:visible; transform:translateY(0); }
}
.sgroup-toggle:focus-visible~.sgroup-path-tip{ opacity:1; visibility:visible; transform:translateY(0); }
-.sgroup-toggle .label{ font-size:11.5px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--faint); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
-.sgroup-toggle .count{ font-size:10.5px; color:var(--faint); font-weight:500; flex:none; }
+.sgroup-toggle .label{ font-size:11.5px; font-weight:var(--font-weight-600); letter-spacing:.06em; text-transform:uppercase; color:var(--faint); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.sgroup-toggle .count{ font-size:10.5px; color:var(--faint); font-weight:var(--font-weight-500); flex:none; }
@media (min-width:980px){
/* Animate two ordinary lengths instead of grid-template-columns. Chromium
can strand an interpolated 0px/1fr grid track; a fixed sidebar transform
@@ -1897,7 +1884,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
}
/* CONTEXT REPORT MODAL */
.ctx-overview{ padding:8px 20px 14px; }
-.ctx-pct{ font-size:30px; font-weight:600; font-family:var(--serif); letter-spacing:-.02em; }
+.ctx-pct{ font-size:30px; font-weight:var(--font-weight-600); font-family:var(--serif); letter-spacing:-.02em; }
.ctx-bar{ height:8px; background:var(--border); border-radius:4px; overflow:hidden; margin:10px 0 7px; }
.ctx-bar-fill{ height:100%; background:var(--accent); transition:width .3s; }
.ctx-numbers{ font-family:var(--mono); font-size:12px; color:var(--dim); }
@@ -1918,16 +1905,16 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
/* SESSION GROUP "+" — sits in the header row (flex), not absolutely positioned
(absolute broke when the group expanded: top:50% landed on the session cards). */
.sgroup-add{ flex:none; width:26px; height:26px; display:flex; align-items:center; justify-content:center; color:var(--dim); border-radius:50%; opacity:.85; transition:opacity .14s, background .14s, color .14s; }
-.sgroup-add:hover{ opacity:1; color:var(--accent); background:var(--accent-weak); }
+.sgroup-add:hover{ opacity:1; color:var(--accent-text,var(--accent)); background:var(--accent-weak); }
.sgroup-add svg{ width:14px; height:14px; }
/* NEW CHAT welcome page (centered composer a la Claude app / Codex) */
.newchat{ flex:1; display:flex; align-items:center; justify-content:center; padding:24px 20px; overflow-y:auto; }
.newchat-card{ width:100%; max-width:560px; display:flex; flex-direction:column; gap:14px; }
-.newchat-greet{ font-size:22px; font-weight:600; font-family:var(--serif); color:var(--text); text-align:center; letter-spacing:-.01em; }
+.newchat-greet{ font-size:22px; font-weight:var(--font-weight-600); font-family:var(--serif); color:var(--text); text-align:center; letter-spacing:-.01em; }
.work-private-note{ display:flex; align-items:center; justify-content:center; gap:7px; align-self:center;
max-width:520px; color:var(--dim); font-size:12px; line-height:1.5; text-align:center; }
-.work-private-note svg{ flex:none; color:var(--accent); }
+.work-private-note svg{ flex:none; color:var(--accent-text,var(--accent)); }
.work-project-bar{ display:flex; align-items:center; justify-content:center; gap:8px; flex-wrap:wrap; }
.work-project-bar select,.work-project-bar button{ min-height:36px; padding:7px 11px; border:1px solid var(--border);
border-radius:10px; background:var(--raised); color:var(--text); font-size:12.5px; }
@@ -1939,14 +1926,14 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
border:1px solid var(--border); border-radius:13px; background:var(--raised); color:var(--dim);
text-align:left; transition:background .14s,border-color .14s,color .14s,transform .14s; }
.work-starters button:hover{ color:var(--text); border-color:var(--accent-line); background:var(--surface); transform:translateY(-1px); }
-.work-starters button svg{ color:var(--accent); flex:none; }
+.work-starters button svg{ color:var(--accent-text,var(--accent)); flex:none; }
.newchat-context{ display:flex; align-items:center; justify-content:center; align-self:center;
gap:8px; width:100%; max-width:100%; flex-wrap:wrap; }
.newchat-profile{ display:flex; align-items:center; gap:6px; max-width:100%; min-height:34px;
padding:5px 7px 5px 10px; border:1px solid var(--border); border-radius:999px;
background:var(--raised); color:var(--faint); font-size:11px; }
.newchat-profile > b{ min-width:0; max-width:180px;
- color:var(--text); font:600 12px/1.2 var(--sans); }
+ color:var(--text); font:var(--font-weight-600) 12px/1.2 var(--sans); }
.newchat-profile:focus-within{ border-color:var(--accent-line); }
.newchat-profile-error{ display:flex; align-items:center; justify-content:center; gap:6px;
flex-basis:100%; color:var(--danger); font-size:11.5px; line-height:1.4; text-align:center; }
@@ -1975,7 +1962,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
text-overflow:ellipsis; white-space:nowrap; }
.newchat-access:hover{ border-color:var(--border-strong); color:var(--text); background:var(--surface); }
.newchat-access:disabled{ opacity:.55; cursor:default; }
-.newchat-send{ display:flex; align-items:center; gap:7px; background:var(--accent); color:#fff; font-weight:600; font-size:14px; padding:10px 18px; border-radius:12px; transition:filter .14s, opacity .14s; }
+.newchat-send{ display:flex; align-items:center; gap:7px; background:var(--accent); color:var(--on-accent,#fff); font-weight:var(--font-weight-600); font-size:14px; padding:10px 18px; border-radius:12px; transition:filter .14s, opacity .14s; }
.newchat-send:hover{ filter:brightness(1.05); }
.newchat-send:disabled{ opacity:.45; cursor:not-allowed; }
.newchat-send svg{ width:16px; height:16px; }
@@ -1989,10 +1976,10 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
border-radius:20px; background:var(--surface); box-shadow:var(--shadow-lg); }
.work-manager>header{ display:flex; align-items:center; gap:12px; padding:16px 18px 12px; border-bottom:1px solid var(--border); }
.work-manager>header>div{ display:flex; flex-direction:column; gap:2px; flex:1; min-width:0; }
-.work-manager>header span{ font:600 20px/1.2 var(--serif); }
+.work-manager>header span{ font:var(--font-weight-600) 20px/1.2 var(--serif); }
.work-manager>header small{ color:var(--dim); font-size:11.5px; }
.work-manager>nav{ display:flex; gap:4px; padding:8px 12px; border-bottom:1px solid var(--border); overflow-x:auto; }
-.work-manager>nav button{ flex:none; padding:8px 13px; border-radius:9px; color:var(--dim); font-size:13px; font-weight:600; }
+.work-manager>nav button{ flex:none; padding:8px 13px; border-radius:9px; color:var(--dim); font-size:13px; font-weight:var(--font-weight-600); }
.work-manager>nav button.active{ color:var(--accent-ink); background:var(--accent-weak); }
.work-manager-body{ flex:1; min-height:0; overflow-y:auto; padding:16px; display:grid; align-content:start; gap:14px; }
.work-artifacts-scrim{ z-index:49; }
@@ -2003,7 +1990,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.work-artifacts-sheet>header{ display:flex; align-items:center; gap:12px; padding:14px 14px 11px 16px;
border-bottom:1px solid var(--border); }
.work-artifacts-sheet>header>div{ display:flex; flex:1; min-width:0; flex-direction:column; gap:2px; }
-.work-artifacts-sheet>header span{ font:600 18px/1.2 var(--serif); }
+.work-artifacts-sheet>header span{ font:var(--font-weight-600) 18px/1.2 var(--serif); }
.work-artifacts-sheet>header small{ color:var(--dim); font-size:11.5px; }
.work-artifacts-list{ min-height:0; overflow-y:auto; overscroll-behavior:contain; padding:8px; }
.work-artifacts-list>button{ display:flex; width:100%; align-items:center; gap:10px; min-height:62px; padding:9px 10px;
@@ -2015,7 +2002,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
color:var(--accent-ink); background:var(--accent-weak); }
.work-artifact-main{ display:flex; flex:1; min-width:0; flex-direction:column; }
.work-artifact-main b{ overflow:hidden; color:var(--text); font-size:12.5px; text-overflow:ellipsis; white-space:nowrap; }
-.work-artifact-main small{ overflow:hidden; color:var(--faint); font:10px/1.4 var(--mono); text-overflow:ellipsis; white-space:nowrap; }
+.work-artifact-main small{ overflow:hidden; color:var(--faint); font:var(--font-weight-400) 10px/1.4 var(--mono); text-overflow:ellipsis; white-space:nowrap; }
.work-artifact-meta{ display:flex; flex:none; flex-direction:column; align-items:flex-end; }
.work-artifact-meta b{ color:var(--dim); font-size:10.5px; }
.work-artifact-meta small,.work-artifact-unavailable{ color:var(--faint); font-size:10px; white-space:nowrap; }
@@ -2027,7 +2014,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.work-form input:focus,.work-form textarea:focus,.work-form select:focus,.work-project-picker:focus{ border-color:var(--accent-line); }
.work-schedule-profile{ display:grid; gap:6px; color:var(--dim); font-size:11.5px; }
.work-form>.primary,.work-form-actions>button,.file-action{ justify-self:start; padding:9px 14px; border-radius:10px;
- background:var(--accent); color:#fff; font-size:13px; font-weight:650; cursor:pointer; }
+ background:var(--accent); color:var(--on-accent,#fff); font-size:13px; font-weight:var(--font-weight-650); cursor:pointer; }
.work-form button:disabled{ opacity:.45; cursor:not-allowed; }
.work-form small{ color:var(--dim); font-size:11.5px; line-height:1.5; }
.work-form-actions{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
@@ -2038,8 +2025,8 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
transition:border-color .14s,box-shadow .14s,background .14s; }
.date-time-trigger:hover,.date-time-trigger:focus-visible{ border-color:var(--accent-line); box-shadow:0 0 0 3px var(--accent-weak); }
.date-time-trigger>span:nth-child(2){ display:flex; min-width:0; flex:1; flex-direction:column; gap:1px; }
-.date-time-trigger small{ color:var(--faint); font-size:10px; font-weight:600; letter-spacing:.04em; }
-.date-time-trigger b{ overflow:hidden; font-size:12.5px; font-weight:560; text-overflow:ellipsis; white-space:nowrap; }
+.date-time-trigger small{ color:var(--faint); font-size:10px; font-weight:var(--font-weight-600); letter-spacing:.04em; }
+.date-time-trigger b{ overflow:hidden; font-size:12.5px; font-weight:var(--font-weight-560); text-overflow:ellipsis; white-space:nowrap; }
.date-time-trigger.has-value b{ color:var(--text); }
.date-time-trigger-icon{ width:30px; height:30px; flex:none; display:grid; place-items:center; border-radius:9px;
background:var(--accent-weak); color:var(--accent-ink); }
@@ -2059,25 +2046,25 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.date-time-quick button:hover{ color:var(--accent-ink); border-color:var(--accent-line); background:var(--accent-weak); }
.date-time-week,.date-time-days{ display:grid; grid-template-columns:repeat(7,1fr); gap:3px; }
.date-time-week{ margin-bottom:3px; }
-.date-time-week span{ padding:3px 0; color:var(--faint); font-size:10px; font-weight:650; text-align:center; }
+.date-time-week span{ padding:3px 0; color:var(--faint); font-size:10px; font-weight:var(--font-weight-650); text-align:center; }
.date-time-days button{ height:32px; min-width:0; display:grid; place-items:center; border:1px solid transparent;
border-radius:9px; color:var(--dim); font-size:11.5px; font-variant-numeric:tabular-nums; }
.date-time-days button:hover{ color:var(--text); background:var(--raised); }
.date-time-days button.outside{ color:var(--faint); opacity:.55; }
.date-time-days button.today{ border-color:var(--accent-line); color:var(--accent-ink); }
-.date-time-days button.selected{ border-color:var(--accent); background:var(--accent); color:#fff; font-weight:700; }
+.date-time-days button.selected{ border-color:var(--accent); background:var(--accent); color:var(--on-accent,#fff); font-weight:var(--font-weight-700); }
.date-time-clock{ display:flex; align-items:center; gap:7px; margin-top:11px; padding:10px; border-radius:12px; background:var(--raised); }
-.date-time-clock>span{ display:flex; align-items:center; gap:6px; margin-right:auto; color:var(--dim); font-size:11px; font-weight:650; }
+.date-time-clock>span{ display:flex; align-items:center; gap:6px; margin-right:auto; color:var(--dim); font-size:11px; font-weight:var(--font-weight-650); }
.date-time-clock label{ position:relative; }
.date-time-clock label>span{ position:absolute; top:3px; left:8px; color:var(--faint); font-size:8px; pointer-events:none; }
.date-time-clock select{ width:62px; min-width:0; height:42px; padding:13px 7px 3px; border:1px solid var(--border);
- border-radius:9px; background:var(--surface); color:var(--text); font:650 13px/1 var(--mono); text-align:center; }
-.date-time-clock i{ color:var(--faint); font-style:normal; font-weight:700; }
+ border-radius:9px; background:var(--surface); color:var(--text); font:var(--font-weight-650) 13px/1 var(--mono); text-align:center; }
+.date-time-clock i{ color:var(--faint); font-style:normal; font-weight:var(--font-weight-700); }
.date-time-popover>footer{ display:flex; align-items:center; gap:5px; margin-top:10px; padding-top:10px; border-top:1px solid var(--border); }
.date-time-popover>footer span{ flex:1; }
-.date-time-popover>footer button{ padding:7px 10px; border-radius:9px; color:var(--dim); font-size:11.5px; font-weight:600; }
+.date-time-popover>footer button{ padding:7px 10px; border-radius:9px; color:var(--dim); font-size:11.5px; font-weight:var(--font-weight-600); }
.date-time-popover>footer button:hover{ background:var(--raised); color:var(--text); }
-.date-time-popover>footer button.primary{ background:var(--accent); color:#fff; }
+.date-time-popover>footer button.primary{ background:var(--accent); color:var(--on-accent,#fff); }
.date-time-popover>footer button.date-time-clear{ color:var(--danger); }
.work-items{ display:grid; gap:7px; }
.work-items article{ display:flex; align-items:flex-start; gap:12px; padding:12px 13px; border:1px solid var(--border);
@@ -2110,7 +2097,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
:root[data-theme="dark"] .dp-overlay{ background:rgba(0,0,0,.62); }
.dp{ width:100%; max-width:460px; max-height:min(80vh,calc(var(--app-height,100dvh) - 40px)); background:var(--surface); border:1px solid var(--border); border-radius:18px; box-shadow:var(--shadow-lg); display:flex; flex-direction:column; overflow:hidden; }
.dp-head{ display:flex; align-items:center; justify-content:space-between; padding:16px 16px 8px; }
-.dp-title{ font-size:15px; font-weight:600; color:var(--text); }
+.dp-title{ font-size:15px; font-weight:var(--font-weight-600); color:var(--text); }
.dp-crumbs{ padding:4px 16px 10px; font-size:12px; font-family:var(--mono); color:var(--dim); word-break:break-all; line-height:1.4; }
.dp-list{ flex:1; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:0 8px 8px; }
.dp-row{ display:flex; align-items:center; gap:10px; width:100%; text-align:left; padding:11px 10px; border-radius:10px; color:var(--text); font-size:14px; transition:background .12s; }
@@ -2123,7 +2110,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
.dp-input{ width:100%; background:var(--raised); border:1px solid var(--border); border-radius:10px; padding:10px 12px; font-size:13px; font-family:var(--mono); color:var(--text); outline:none; }
.dp-input:focus{ border-color:var(--accent-line); }
.dp-error{ color:var(--danger); font-size:12px; line-height:1.4; }
-.dp-confirm{ display:flex; align-items:center; justify-content:center; gap:8px; width:100%; background:var(--accent); color:#fff; font-weight:600; font-size:14px; padding:12px; border-radius:12px; transition:filter .14s, opacity .14s; }
+.dp-confirm{ display:flex; align-items:center; justify-content:center; gap:8px; width:100%; background:var(--accent); color:var(--on-accent,#fff); font-weight:var(--font-weight-600); font-size:14px; padding:12px; border-radius:12px; transition:filter .14s, opacity .14s; }
.dp-confirm:hover{ filter:brightness(1.05); }
.dp-confirm:disabled{ opacity:.45; cursor:not-allowed; }
.dp-confirm svg{ width:15px; height:15px; }
@@ -2131,3 +2118,7 @@ html.panel-resizing,html.panel-resizing *{ cursor:col-resize !important; user-se
@media (max-width:540px){
.dp{ max-width:100%; max-height:min(88vh,calc(var(--app-height,100dvh) - 24px)); border-radius:16px; }
}
+
+/* Palette surfaces stay tinted when scrolling controls float over the chat. */
+:root[data-palette]:not([data-palette="classic"]) .scroll-bottom-btn{ background:color-mix(in srgb,var(--surface) 92%,transparent); }
+:root[data-palette]:not([data-palette="classic"]) :focus-visible{ outline-color:var(--accent-ink); }
diff --git a/web/src/notice-presentation.ts b/web/src/notice-presentation.ts
index f05cf18f..011ab9ed 100644
--- a/web/src/notice-presentation.ts
+++ b/web/src/notice-presentation.ts
@@ -68,8 +68,10 @@ export function conversationNotices(notices: Notice[]): Notice[] {
if (notice.notice_id.startsWith("compact-")) {
return {
...notice,
- title: "正在整理上下文",
- message: "整理完成后可继续使用当前会话。",
+ title: cleanProductText(notice.title),
+ message: notice.title === "上下文压缩完成"
+ ? "可以继续使用当前会话。"
+ : "压缩进度和结果可在处理记录中查看。",
detail: null,
};
}
diff --git a/web/src/problem-presentation.ts b/web/src/problem-presentation.ts
index d0ac7d16..27688764 100644
--- a/web/src/problem-presentation.ts
+++ b/web/src/problem-presentation.ts
@@ -12,7 +12,7 @@ const CODEX_UPDATE_INTERRUPTION =
const CODEX_CONNECTION_INTERRUPTION =
"与 Codex 的连接中断,本轮未确认完成。请检查已有结果后继续。";
// Exact authored causes only; never expose an arbitrary upstream diagnostic.
-const STEER_REJECTION_MESSAGES = /^(?:该会话(?:未启动,无法引导当前任务|当前为只读状态,无法从 Remote 引导)|Claude 当前不支持无打断引导;请使用打断并发送或排队。|Codex (?:(?:自动压缩|Review|当前阶段)不支持引导,或任务已经切换;本次未发送。|正在核对当前回合归属,本次引导未发送;请稍后重试。|当前回合不支持引导,请等待后重试。)|消息内容为空,请输入内容或添加附件。|附件不符合要求,请调整后重试。)$/;
+const STEER_REJECTION_MESSAGES = /^(?:该会话(?:未启动,无法引导当前任务|当前为只读状态,无法从 Remote 引导)|本次引导已取消。|Claude 当前无法接收引导,本次未发送;请稍后重试或排队。|Codex (?:(?:自动压缩|Review|当前阶段)不支持引导,或任务已经切换;本次未发送。|正在核对当前回合归属,本次引导未发送;请稍后重试。|当前回合不支持引导,请等待后重试。)|消息内容为空,请输入内容或添加附件。|附件不符合要求,请调整后重试。)$/;
const CODEX_USAGE_LIMIT_FAILURE =
"本轮使用的 Codex 账号额度已用完。可切换账号、补充额度,或等待恢复后重试。";
const CODEX_USAGE_LIMIT_RETRY =
@@ -145,7 +145,7 @@ export function presentCommandProblem(
: "本次引导未发送,请稍后重试。";
}
case "steer_outcome_unknown":
- return "引导已发出,Codex 尚未确认是否生效。请先查看后续结果。";
+ return "引导已发出,尚未确认是否生效。请先查看后续结果。";
default:
return "操作未完成,请稍后重试。";
}
diff --git a/web/src/protocol.ts b/web/src/protocol.ts
index 573c24c5..a0f14017 100644
--- a/web/src/protocol.ts
+++ b/web/src/protocol.ts
@@ -123,8 +123,10 @@ export interface Ping extends Base { type: "ping"; n: number }
export interface Pong extends Base { type: "pong"; n: number }
export interface CommandAck extends Base { type: "command_ack"; cmd_id: string; client_id: string }
export interface ReplayStart extends Base { type: "replay_start"; from_seq: number; to_seq: number; truncated: boolean; rebuild?: boolean; generation?: string | null }
-export interface ReplayEnd extends Base { type: "replay_end"; to_seq: number; truncated: boolean }
-export interface Snapshot extends Base { type: "snapshot"; cc_session_id?: string | null; state: State; tail_text: string; cwd?: string | null; generation?: string | null; control?: SessionControl | null }
+export interface ReplayEnd extends Base { type: "replay_end"; to_seq: number; truncated: boolean; turn_usage?: TurnUsage[] }
+export interface TokenUsage { input_tokens?: number | null; output_tokens?: number | null; cache_read_tokens?: number | null; cache_write_tokens?: number | null }
+export interface TurnUsage extends Base { type: "turn_usage"; turn_id: string; usage: TokenUsage }
+export interface Snapshot extends Base { type: "snapshot"; cc_session_id?: string | null; state: State; tail_text: string; cwd?: string | null; generation?: string | null; control?: SessionControl | null; turn_usage?: TurnUsage[] }
export interface StateEvent extends Base {
type: "state";
state: State;
@@ -190,10 +192,15 @@ export interface SessionMigrated extends Base {
cwd: string;
request_id: string;
}
-export interface UserMsg extends Base { type: "user_msg"; msg_id: string; client_msg_id?: string | null; prompt: string; images?: QueryImg[] | null; files?: { filename: string }[] | null }
+export interface TimedMessage { task_id: string; title: string; scheduled_at: number }
+export interface TimedTaskInfo {
+ task_id: string; title: string; next_message_at: number; interval_seconds: number;
+ sent_count: number; total_count: number; valid_until: number;
+}
+export interface UserMsg extends Base { type: "user_msg"; msg_id: string; client_msg_id?: string | null; timed_task?: TimedMessage | null; prompt: string; images?: QueryImg[] | null; files?: { filename: string }[] | null }
export interface TurnSteered extends Base { type: "turn_steered"; msg_id: string; turn_id: string; prompt: string; images?: QueryImg[] | null; files?: { filename: string }[] | null }
export interface AssistantMsgStart extends Base { type: "assistant_msg_start"; message_id: string; turn_id?: string | null; background?: boolean | null; channel?: AssistantChannel }
-export interface Delta extends Base { type: "delta"; message_id: string; turn_id?: string | null; background?: boolean | null; text: string; channel?: AssistantChannel }
+export interface Delta extends Base { replace?: boolean; type: "delta"; message_id: string; turn_id?: string | null; background?: boolean | null; text: string; channel?: AssistantChannel }
export interface ToolUse extends Base {
type: "tool_use";
message_id: string;
@@ -315,6 +322,7 @@ export interface ClaudeProfileInfo {
}
export interface SessionInfo {
session_id: string;
+ timed_tasks?: TimedTaskInfo[];
summary?: string | null;
last_modified?: string | null;
first_prompt?: string | null;
@@ -482,7 +490,7 @@ export interface GetHistory extends Base { type: "get_history"; session_id: stri
export interface ConversationImageRef { image_id: string; media_type: QueryImg["media_type"]; width: number; height: number; byte_size: number }
export type ProcessDetailState = "none" | "present" | "unknown";
export type TurnDetailReason = "process" | "prompt_truncated" | "answer_truncated" | "image_deferred";
-export interface ConversationTurn { id: string; clientMsgId?: string | null; prompt: string; blocks: unknown[]; done: boolean; forkPointId?: string | null; checkpointId?: string | null; interrupted?: boolean | null; error?: string | null; images?: QueryImg[] | null; imageRefs?: ConversationImageRef[] | null; files?: QueryFile[] | null; ts?: number | null; doneTs?: number | null; durationMs?: number | null; processDetailState?: ProcessDetailState; detailReasons?: TurnDetailReason[]; processStartedTs?: number | null; processDoneTs?: number | null; detailEventCount: number; detailLoaded: boolean; fileChanges?: TurnChangeSummary | null }
+export interface ConversationTurn { id: string; timedTask?: TimedMessage | null; clientMsgId?: string | null; prompt: string; blocks: unknown[]; done: boolean; forkPointId?: string | null; checkpointId?: string | null; interrupted?: boolean | null; error?: string | null; images?: QueryImg[] | null; imageRefs?: ConversationImageRef[] | null; files?: QueryFile[] | null; ts?: number | null; doneTs?: number | null; durationMs?: number | null; processDetailState?: ProcessDetailState; detailReasons?: TurnDetailReason[]; processStartedTs?: number | null; processDoneTs?: number | null; detailEventCount: number; detailLoaded: boolean; fileChanges?: TurnChangeSummary | null }
export interface CodexTerminalFence { turn_id: string; status: "completed" | "interrupted" | "failed"; duration_ms?: number | null; completed_at?: number | null }
export interface History extends Base { type: "history"; session_id: string; revision: string; generation?: string | null; continuity_revision?: string | null; build_seq?: number; live_seq?: number | null; authoritative?: boolean; error?: string | null; events: ServerEvent[]; turns?: ConversationTurn[]; detail?: "summary" | "full"; has_more: boolean; oldest_id?: string | null; newest_id?: string | null; before?: string | null; control?: SessionControl | null; external?: boolean; takeover_pending?: boolean; in_progress?: boolean; compaction_continuation_turn_ids?: string[]; terminal_fences?: CodexTerminalFence[]; reset?: boolean }
export interface GetTurnDetail extends Base { type: "get_turn_detail"; session_id: string; turn_id: string; client_id?: string | null; revision?: string | null; before?: string | null; limit?: number | null }
@@ -709,9 +717,9 @@ export type ServerEvent = FilesListed | CodexContext
| DirList
| UserMsg | TurnSteered | AssistantMsgStart | Delta | ToolUse | ToolDelta | ToolResult | AssistantMsgEnd
| ProcessEvent | BackgroundProcessSync | TurnPlan | TurnDiff | TurnFileChanges | TurnBinding
- | TurnEnd | ErrorMsg | WrapperDisconnected | WrapperReconnected | Hello;
+ | TurnUsage | TurnEnd | ErrorMsg | WrapperDisconnected | WrapperReconnected | Hello;
-export const PROTOCOL_VERSION = 67;
+export const PROTOCOL_VERSION = 71;
export const MIN_AUTO_COMPACT_TOKENS = 100_000;
export const MAX_AUTO_COMPACT_TOKENS = 1_000_000;
diff --git a/web/src/recoverable-read.ts b/web/src/recoverable-read.ts
index 567c0a95..26ed2432 100644
--- a/web/src/recoverable-read.ts
+++ b/web/src/recoverable-read.ts
@@ -1,5 +1,5 @@
-type Schedule = (callback: () => void, delayMs: number) => number;
-type Cancel = (timer: number) => void;
+type Schedule = (callback: () => void, delayMs: number) => Timer;
+type Cancel = (timer: Timer) => void;
/** A small, bounded repair cycle for a non-authoritative history/detail read.
*
@@ -8,21 +8,20 @@ type Cancel = (timer: number) => void;
* broken source into a polling loop. A later explicit read starts a fresh
* cycle after this one is exhausted.
*/
-export class RecoverableReadCoordinator {
+export class RecoverableReadCoordinator {
private readonly state = new Map();
- private readonly timers = new Map();
- private readonly schedule: Schedule;
- private readonly cancel: Cancel;
+ private readonly schedule: Schedule;
+ private readonly cancel: Cancel;
private readonly delayMs: number;
private readonly maxAttempts: number;
private readonly backoff: number;
constructor(
- schedule: Schedule,
- cancel: Cancel,
+ schedule: Schedule,
+ cancel: Cancel,
delayMs = 250,
maxAttempts = 3,
backoff = 4,
@@ -35,8 +34,8 @@ export class RecoverableReadCoordinator {
}
retry(key: string, read: () => void, delayMs = this.delayMs): boolean {
- const state = this.state.get(key) ?? { attempts: 0, scheduled: false };
- if (state.scheduled) return false;
+ const state = this.state.get(key) ?? { attempts: 0 };
+ if (state.timer !== undefined) return false;
// Callers using a long custom watchdog intentionally ask for one probe,
// not the ordinary short flush-repair sequence.
const maxAttempts = delayMs === this.delayMs ? this.maxAttempts : 1;
@@ -44,33 +43,26 @@ export class RecoverableReadCoordinator {
this.state.delete(key);
return false;
}
- state.scheduled = true;
this.state.set(key, state);
const attemptDelay = delayMs === this.delayMs
? delayMs * this.backoff ** state.attempts
: delayMs;
- const timer = this.schedule(() => {
- this.timers.delete(key);
- const current = this.state.get(key);
- if (current !== state || !current.scheduled) return;
- current.scheduled = false;
- current.attempts += 1;
+ state.timer = this.schedule(() => {
+ if (this.state.get(key) !== state) return;
+ state.timer = undefined;
+ state.attempts += 1;
read();
}, attemptDelay);
- this.timers.set(key, timer);
return true;
}
complete(key: string): void {
- const timer = this.timers.get(key);
+ const timer = this.state.get(key)?.timer;
if (timer !== undefined) this.cancel(timer);
- this.timers.delete(key);
this.state.delete(key);
}
clear(): void {
- for (const timer of this.timers.values()) this.cancel(timer);
- this.timers.clear();
- this.state.clear();
+ for (const key of this.state.keys()) this.complete(key);
}
}
diff --git a/web/src/reducer.ts b/web/src/reducer.ts
index 63299efb..fec11fbd 100644
--- a/web/src/reducer.ts
+++ b/web/src/reducer.ts
@@ -91,6 +91,8 @@ import {
type Turn,
} from "./domain/conversation.ts";
+import { rememberTurnUsage, type TurnUsageReadings } from "./turn-usage";
+
const DETAIL_PARSE_ERROR = "过程解析失败";
export type {
@@ -198,6 +200,7 @@ export interface Artifact {
}
export interface SessionRuntime {
+ turnUsage?: TurnUsageReadings;
turns: Turn[];
state: State;
// Display-only activity observed from a native/external client. It must not
@@ -520,7 +523,7 @@ export type Action =
| { type: "set_collaboration_mode"; mode: CollaborationModeName }
| { type: "set_context"; report: ContextReport }
| { type: "clear_context" }
- | { type: "begin_context_request"; sid: string; requestId: string }
+ | { type: "begin_context_request"; sid: string; requestId: string; refresh?: boolean }
| { type: "defer_context_request"; sid: string }
| { type: "begin_status_request"; sid: string; requestId: string }
| { type: "set_turns"; sid: string; turns: Turn[] }
@@ -1998,6 +2001,19 @@ function markTurnAsLive(
}
}
+function claimClaudeContinuation(
+ runtime: SessionRuntime, turn: Turn, event: ServerEvent, liveEvent: boolean,
+): void {
+ // An idle parent keeps its completion receipt. Actual main-agent content
+ // after a native injected prompt can nevertheless own the running spark.
+ // Child ProcessEvents and history/replay into an idle runtime are not proof.
+ if (!liveEvent || runtime.state === "idle" || !turn.done
+ || !("background" in event) || event.background !== true
+ || !("turn_id" in event) || !event.turn_id) return;
+ markTurnAsLive(runtime, turn.id, true, event.seq);
+ runtime.liveOwner = { turnId: turn.id, seq: event.seq ?? runtime.lastLiveSeq };
+}
+
const MAX_LIVE_DETAIL_TURN_IDS = 128;
function isStateVisibleProcessBlock(block: Block): boolean {
@@ -2155,6 +2171,7 @@ function switchControlGeneration(
runtime.pendingLiveBinding = null;
runtime.pendingTerminalFences = null;
runtime.legacyLiveFallbackBlocked = true;
+ runtime.turnUsage = undefined;
runtime.backgroundProcesses = [];
runtime.backgroundLevelEmpty = false;
runtime.backgroundLevelTs = undefined;
@@ -2502,7 +2519,7 @@ export function reduce(state: AppState, action: Action): AppState {
rt.contextReport = action.report;
if (action.report.available !== false
&& (action.report.source !== "recent_turn"
- || !rt.contextExactReport || rt.contextExactReport.source === "recent_turn")) {
+ || rt.contextExactReport?.source !== "native_estimate")) {
rt.contextExactReport = action.report;
}
});
@@ -2514,7 +2531,7 @@ export function reduce(state: AppState, action: Action): AppState {
case "begin_context_request":
return patch(state, action.sid, (rt) => {
rt.contextRequestId = action.requestId;
- rt.contextRefreshDeferred = false;
+ if (action.refresh !== false) rt.contextRefreshDeferred = false;
rt.contextError = null;
});
case "defer_context_request":
@@ -3399,6 +3416,9 @@ function reduceEvent(
// initial explicit switch.
return { ...patch(state, key, (rt) => {
switchControlGeneration(rt, e.generation);
+ for (const usage of e.turn_usage ?? []) {
+ rt.turnUsage = rememberTurnUsage(rt.turnUsage, usage);
+ }
rt.state = e.state;
rt.syncReady = true;
rt.ccSessionId = e.cc_session_id ?? rt.ccSessionId;
@@ -5509,7 +5529,10 @@ function reduceEvent(
return patch(state, e.sid, (rt) => {
rt.contextReport = e;
if (e.available !== false && (e.source !== "recent_turn"
- || !rt.contextExactReport || rt.contextExactReport.source === "recent_turn")) {
+ || rt.contextExactReport?.source !== "native_estimate")
+ && !(e.max_tokens <= 0 && (rt.contextExactReport?.max_tokens ?? 0) > 0)) {
+ // A recovering worker may know only the total. Keep the last complete
+ // reading until capacity returns; model/window changes clear it above.
rt.contextExactReport = e;
}
// Reports are broadcast so every viewer benefits from the fresh value,
@@ -5525,7 +5548,8 @@ function reduceEvent(
&& e.source === "control";
if (matchesRequest || satisfiesDeferred) {
rt.contextRequestId = null;
- rt.contextRefreshDeferred = false;
+ rt.contextRefreshDeferred = rt.contextRefreshDeferred
+ && e.source !== "control";
rt.contextError = null;
} else if (rt.contextRequestId === null
&& !rt.contextRefreshDeferred) {
@@ -5733,6 +5757,9 @@ function reduceEvent(
}
case "replay_end":
return { ...patch(state, e.sid, (rt) => {
+ for (const usage of e.turn_usage ?? []) {
+ rt.turnUsage = rememberTurnUsage(rt.turnUsage, usage);
+ }
rt.replaying = false;
rt.syncReady = true;
rt.truncated = rt.truncated || e.truncated;
@@ -5774,7 +5801,6 @@ function reduceEvent(
rt.contextRefreshDeferred = true;
rt.contextError = null;
} else {
- rt.contextRefreshDeferred = false;
rt.contextError = presentCommandProblem(e);
}
});
@@ -5936,6 +5962,7 @@ function reduceEvent(
const stamp = e.ts ? Math.round(e.ts * 1000) : undefined;
if (existing) {
if (!existing.prompt && e.prompt) existing.prompt = e.prompt;
+ if (e.timed_task) existing.timedTask = e.timed_task;
if (!existing.images && imgs) existing.images = imgs;
if (fileMeta) existing.files = fileMeta;
else if (existing.files) existing.files = existing.files.map(
@@ -5949,6 +5976,7 @@ function reduceEvent(
turns.push({
id: e.msg_id,
clientMsgId: e.client_msg_id ?? undefined,
+ timedTask: e.timed_task ?? undefined,
prompt: e.prompt,
images: imgs,
files: fileMeta,
@@ -6095,6 +6123,7 @@ function reduceEvent(
if (!detachedBackground) {
markTurnAsLive(rt, t.id, boundCompletedTurns, e.seq);
}
+ else claimClaudeContinuation(rt, t, e, boundCompletedTurns);
t.progress = undefined;
const block = mutableTurnBlocks(t).find((b) => b.kind === "text"
&& b.message_id === e.message_id) as TextBlock | undefined;
@@ -6136,6 +6165,7 @@ function reduceEvent(
if (!detachedBackground) {
markTurnAsLive(rt, t.id, boundCompletedTurns, e.seq);
}
+ else claimClaudeContinuation(rt, t, e, boundCompletedTurns);
t.progress = undefined;
let block = mutableTurnBlocks(t).find((b) => b.kind === "text"
&& b.message_id === e.message_id) as TextBlock | undefined;
@@ -6160,7 +6190,7 @@ function reduceEvent(
// use text containment here: repeated prose and bounded History prefixes
// are both legitimate content and cannot safely prove replay identity.
if (!block.done) {
- block.text = appendField(block.text, e.text, MAX_LIVE_TEXT_CHARS);
+ block.text = e.replace ? e.text.slice(0, MAX_LIVE_TEXT_CHARS) : appendField(block.text, e.text, MAX_LIVE_TEXT_CHARS);
}
if (block.channel !== "final" && e.text.length > 0) {
markTurnDetailAsLive(rt, t.id, boundCompletedTurns);
@@ -6187,6 +6217,7 @@ function reduceEvent(
if (!detachedBackground) {
markTurnAsLive(rt, t.id, boundCompletedTurns, e.seq);
}
+ else claimClaudeContinuation(rt, t, e, boundCompletedTurns);
markTurnDetailAsLive(rt, t.id, boundCompletedTurns);
t.progress = undefined;
const existing = mutableTurnBlocks(t).find((b) => b.kind === "tool"
@@ -6336,10 +6367,19 @@ function reduceEvent(
const turns = cloneTurns(rt.turns);
let owner: Turn | undefined;
let block: ProcessBlock | undefined;
+ const compactStart = e.kind === "compaction" && e.phase === "end"
+ && typeof e.input?.compaction_started_id === "string"
+ ? e.input.compaction_started_id : undefined;
for (const candidate of turns) {
const found = mutableTurnBlocks(candidate).find((b) => b.kind === "process"
- && b.item_id === e.item_id) as ProcessBlock | undefined;
- if (found) { owner = candidate; block = found; break; }
+ && (b.item_id === e.item_id
+ || b.item_id === compactStart && b.processKind === "compaction"
+ && !b.done && b.turn_id === e.turn_id)) as ProcessBlock | undefined;
+ if (found) {
+ owner = candidate; block = found;
+ block.item_id = e.item_id;
+ break;
+ }
}
// Background task/hook events may arrive after their originating turn
// ended and after a newer query opened. Prefer their explicit parent or
@@ -6375,7 +6415,7 @@ function reduceEvent(
block.title = e.title || block.title;
if (e.summary != null) block.summary = e.summary;
if (e.detail != null) block.detail = e.detail;
- if (e.input != null) block.input = e.input;
+ if (e.input != null && !compactStart) block.input = e.input;
if (e.output != null) block.output = e.output;
if (e.diff != null) block.diff = e.diff;
if (e.progress != null) block.progress = e.progress;
@@ -6503,6 +6543,10 @@ function reduceEvent(
if (boundCompletedTurns) limitTurnBlocks(t);
rt.turns = turns;
});
+ case "turn_usage":
+ return patch(state, e.sid, (rt) => {
+ rt.turnUsage = rememberTurnUsage(rt.turnUsage, e);
+ });
case "turn_binding":
return patch(state, e.sid, (rt) => {
if (rt.acceptancePending === e.msg_id) {
diff --git a/web/src/themes.css b/web/src/themes.css
new file mode 100644
index 00000000..3e333323
--- /dev/null
+++ b/web/src/themes.css
@@ -0,0 +1,133 @@
+/* Approved low-saturation palettes. The miniature cards and the application
+ share these tokens; engine-specific classic skins remain the default. */
+:root[data-engine][data-theme][data-palette="amber"],
+[data-preview-palette="amber"] {
+ --bg:#F6F4EF;
+ --surface:#FCFAF6;
+ --sidebar:#EDE8DF;
+ --raised:#F0ECE5;
+ --text:#302D28;
+ --dim:#696054;
+ --faint:#847B6F;
+ --border:#DED8CE;
+ --border-strong:#CEC4B6;
+ --divider:#E6E0D7;
+ --accent:#AA9982;
+ --accent-ink:#756048;
+ --accent-weak:#EBE2D4;
+ --accent-line:#CEBEA8;
+ --on-accent:#29241E;
+}
+
+:root[data-engine][data-theme][data-palette="moss"],
+[data-preview-palette="moss"] {
+ --bg:#F2F4EF;
+ --surface:#F8F9F5;
+ --sidebar:#E7EBE2;
+ --raised:#EBEFE6;
+ --text:#2C312A;
+ --dim:#5D6856;
+ --faint:#77816F;
+ --border:#D6DDD0;
+ --border-strong:#C3CEBA;
+ --divider:#E0E5DA;
+ --accent:#96A28B;
+ --accent-ink:#55654A;
+ --accent-weak:#E0E7D9;
+ --accent-line:#BCC9B1;
+ --on-accent:#232C1F;
+}
+
+:root[data-engine][data-theme][data-palette="rose"],
+[data-preview-palette="rose"] {
+ --bg:#F6F1F2;
+ --surface:#FCF8F9;
+ --sidebar:#EFE5E8;
+ --raised:#F1E9EC;
+ --text:#332D30;
+ --dim:#705F67;
+ --faint:#88757F;
+ --border:#E1D5DA;
+ --border-strong:#D3C0C8;
+ --divider:#E9DFE3;
+ --accent:#B69BA5;
+ --accent-ink:#795566;
+ --accent-weak:#EBDDE1;
+ --accent-line:#D3B8C2;
+ --on-accent:#30222A;
+}
+
+:root[data-engine][data-theme][data-palette="lagoon"],
+[data-preview-palette="lagoon"] {
+ --bg:#242B2D;
+ --surface:#2E383B;
+ --sidebar:#202729;
+ --raised:#333F42;
+ --text:#E2E6E5;
+ --dim:#ABB9BA;
+ --faint:#94A3A5;
+ --border:#414D50;
+ --border-strong:#536266;
+ --divider:#354144;
+ --accent:#9AB1B3;
+ --accent-ink:#B1C7C9;
+ --accent-weak:#364447;
+ --accent-line:#5C7377;
+ --on-accent:#202729;
+}
+
+:root[data-engine][data-theme][data-palette="bordeaux"],
+[data-preview-palette="bordeaux"] {
+ --bg:#2D282B;
+ --surface:#3A3338;
+ --sidebar:#262225;
+ --raised:#40373D;
+ --text:#E8E2E5;
+ --dim:#B8ACB2;
+ --faint:#A999A2;
+ --border:#4B4147;
+ --border-strong:#64535D;
+ --divider:#3D343A;
+ --accent:#B7A0AA;
+ --accent-ink:#CFB7C2;
+ --accent-weak:#463B42;
+ --accent-line:#7A606E;
+ --on-accent:#292126;
+}
+
+:root[data-engine][data-theme][data-palette="heritage"],
+[data-preview-palette="heritage"] {
+ --bg:#272D28;
+ --surface:#343D35;
+ --sidebar:#222824;
+ --raised:#3A443B;
+ --text:#E1E6DE;
+ --dim:#B2BDAC;
+ --faint:#9EAE97;
+ --border:#475146;
+ --border-strong:#5A6956;
+ --divider:#394238;
+ --accent:#AAB79F;
+ --accent-ink:#C2CFB8;
+ --accent-weak:#404A40;
+ --accent-line:#697961;
+ --on-accent:#222824;
+}
+
+:root[data-engine][data-theme][data-palette]:not([data-palette="classic"]) {
+ --accent-text:var(--accent-ink);
+}
+:root[data-palette]:not([data-palette="classic"])[data-theme="light"] {
+ --ok:#4A7158; --ok-weak:#E4EBE4; --warn:#856623; --warn-weak:#EDE6D8;
+ --danger:#A44F49; --danger-weak:#F0E1DF; --on-danger:#FFFFFF;
+ --shadow-sm:0 1px 2px rgb(40 40 40 / .08);
+ --shadow:0 8px 28px -12px rgb(40 40 40 / .18);
+ --shadow-lg:0 18px 54px -14px rgb(40 40 40 / .24);
+}
+:root[data-palette]:not([data-palette="classic"])[data-theme="dark"] {
+ --ok:#A1BAA7; --ok-weak:#344239; --warn:#C9B88E; --warn-weak:#443F33;
+ --danger:#D3A29D; --danger-weak:#48383A; --on-danger:#292126;
+ --shadow-sm:0 1px 2px rgb(0 0 0 / .08);
+ --shadow:0 8px 28px -12px rgb(0 0 0 / .18);
+ --shadow-lg:0 18px 54px -14px rgb(0 0 0 / .55);
+}
diff --git a/web/src/themes.ts b/web/src/themes.ts
new file mode 100644
index 00000000..32f9fe81
--- /dev/null
+++ b/web/src/themes.ts
@@ -0,0 +1,91 @@
+/** Appearance is local to each engine. Only the resolved light/dark mode is
+ * passed to diff/preview renderers; palette names never enter the protocol. */
+export type ThemeEngine = "claude" | "codex" | "dsh";
+export type ThemeMode = "light" | "dark";
+export type PaletteId = "amber" | "moss" | "rose" | "lagoon" | "bordeaux" | "heritage";
+export type ThemeChoice = "system" | ThemeMode | PaletteId;
+export type ThemePreferences = Partial>;
+export const THEME_STORAGE_KEY = "cc_remote_themes_v1";
+export const LEGACY_THEME_KEY = "cc_remote_theme";
+
+export interface ThemePalette {
+ id: PaletteId;
+ name: string;
+ description: string;
+ mode: ThemeMode;
+}
+
+export const THEME_PALETTES: readonly ThemePalette[] = [
+ {
+ id: "amber", name: "奶油琥珀", description: "燕麦白 · 暖灰褐", mode: "light",
+ },
+ {
+ id: "moss", name: "森林苔绿", description: "雾白 · 鼠尾草灰", mode: "light",
+ },
+ {
+ id: "rose", name: "玫瑰雾", description: "柔白 · 玫瑰灰", mode: "light",
+ },
+ {
+ id: "lagoon", name: "深海青", description: "炭灰 · 雾青", mode: "dark",
+ },
+ {
+ id: "bordeaux", name: "酒红夜色", description: "暖炭灰 · 烟粉", mode: "dark",
+ },
+ {
+ id: "heritage", name: "复古终端绿", description: "石墨灰 · 灰绿", mode: "dark",
+ },
+];
+
+export function themePalette(choice: ThemeChoice): ThemePalette | undefined {
+ return THEME_PALETTES.find(palette => palette.id === choice);
+}
+
+export function isThemeChoice(value: unknown): value is ThemeChoice {
+ return value === "system" || value === "light" || value === "dark"
+ || THEME_PALETTES.some(palette => palette.id === value);
+}
+
+export function themeLabel(choice: ThemeChoice): string {
+ return themePalette(choice)?.name ?? (choice === "system" ? "跟随系统" : choice === "dark" ? "经典深色" : "经典浅色");
+}
+
+export function resolveThemeMode(choice: ThemeChoice, systemDark: boolean): ThemeMode {
+ return themePalette(choice)?.mode ?? (choice === "dark" || (choice === "system" && systemDark) ? "dark" : "light");
+}
+
+export function readThemePreferences(storage?: Pick): ThemePreferences {
+ try {
+ const legacy = storage?.getItem(LEGACY_THEME_KEY);
+ const fallback: ThemeChoice = legacy === "light" || legacy === "dark" ? legacy : "system";
+ let saved: unknown;
+ try { saved = JSON.parse(storage?.getItem(THEME_STORAGE_KEY) ?? "null"); }
+ catch { /* Fall back to the previous light/dark preference. */ }
+ const preferences: ThemePreferences = {};
+ for (const engine of ["claude", "codex", "dsh"] as const) {
+ const value = saved && typeof saved === "object" ? (saved as Record)[engine] : undefined;
+ preferences[engine] = isThemeChoice(value) ? value : fallback;
+ }
+ return preferences;
+ } catch { return {}; /* Private browsing may disable storage entirely. */ }
+}
+
+/** Merge the latest persisted map so another tab's engine choice is retained. */
+export function saveThemeChoice(storage: Pick | undefined,
+ current: ThemePreferences, engine: ThemeEngine, choice: ThemeChoice): ThemePreferences {
+ let next = { ...current, [engine]: choice };
+ try {
+ if (storage) {
+ next = { ...readThemePreferences(storage), ...current, [engine]: choice };
+ // Only the selected engine is ours to overwrite across tabs.
+ let saved: Partial> | null = null;
+ try { saved = JSON.parse(storage.getItem(THEME_STORAGE_KEY) ?? "null"); }
+ catch { /* Replace malformed preferences with this valid selection. */ }
+ for (const other of ["claude", "codex", "dsh"] as const) {
+ const value = saved?.[other];
+ if (other !== engine && isThemeChoice(value)) next[other] = value;
+ }
+ storage.setItem(THEME_STORAGE_KEY, JSON.stringify(next));
+ }
+ } catch { /* Selection remains usable in memory when storage is unavailable. */ }
+ return next;
+}
diff --git a/web/src/tool-command.ts b/web/src/tool-command.ts
new file mode 100644
index 00000000..ac5aecfc
--- /dev/null
+++ b/web/src/tool-command.ts
@@ -0,0 +1,26 @@
+/** Presentation only: never execute or evaluate the native shell envelope. */
+export function displayCommand(command: unknown): string {
+ if (Array.isArray(command) && command.every((part) => typeof part === "string")) {
+ if (command.length === 3 && /(?:^|\/)(?:bash|zsh|sh|dash|fish)$/.test(command[0])
+ && /^-[il]*c$/.test(command[1])) return command[2];
+ return command.map((part) => /^[\w./=-]+$/.test(part)
+ ? part : JSON.stringify(part)).join(" ");
+ }
+ if (typeof command !== "string") return "";
+ const match = command.match(/^(?:\S*\/)?(?:bash|zsh|sh|dash|fish)\s+-[il]*c\s+([\s\S]+)$/);
+ if (!match) return command;
+ const argument = match[1];
+ if (argument.startsWith("'") && argument.endsWith("'")) {
+ const inner = argument.slice(1, -1);
+ if (!inner.replaceAll("'\\''", "").includes("'")) {
+ return inner.replaceAll("'\\''", "'");
+ }
+ }
+ if (argument.startsWith('"')) {
+ try {
+ const decoded: unknown = JSON.parse(argument);
+ if (typeof decoded === "string") return decoded;
+ } catch { /* Keep ambiguous shell quoting verbatim. */ }
+ }
+ return command;
+}
diff --git a/web/src/tool-details.ts b/web/src/tool-details.ts
new file mode 100644
index 00000000..3014a9a4
--- /dev/null
+++ b/web/src/tool-details.ts
@@ -0,0 +1,100 @@
+import { displayCommand } from "./tool-command.ts";
+export { displayCommand } from "./tool-command.ts";
+import { filePathsFromInput } from "./file-changes.ts";
+
+type Input = Record;
+
+export interface ToolInputField { label: string; text: string }
+
+export function readableToolInput(input: Input, omit: string[] = []): ToolInputField[] {
+ const fields: ToolInputField[] = [];
+ const add = (label: string, keys: string[], transform?: (value: unknown) => string) => {
+ for (const key of keys) {
+ if (omit.includes(key)) continue;
+ const raw = input[key];
+ const text = transform ? transform(raw) : typeof raw === "string" ? raw : "";
+ if (text.trim()) { fields.push({ label, text }); break; }
+ }
+ };
+ add("命令", ["command", "cmd"], displayCommand);
+ add("工作目录", ["cwd", "workdir"]);
+ add("文件", ["file_path", "path", "file_paths"], (raw) =>
+ typeof raw === "string" ? raw : Array.isArray(raw)
+ && raw.every((item) => typeof item === "string") ? raw.join("\n") : "");
+ if (!fields.some((field) => field.label === "文件") && !omit.includes("changes")) {
+ const paths = filePathsFromInput(input);
+ if (paths.length) fields.push({ label: "文件", text: paths.join("\n") });
+ }
+ add("搜索内容", ["pattern", "query", "search_term"]);
+ add("搜索范围", ["glob", "include"]);
+ add("地址", ["url"]);
+ add("说明", ["description"]);
+ return fields;
+}
+
+const record = (value: unknown): value is Input =>
+ !!value && typeof value === "object" && !Array.isArray(value);
+
+/** Unwrap known transport envelopes only. Arbitrary program JSON stays data. */
+function unwrap(value: unknown, depth = 0): string | null {
+ if (depth > 6) return null;
+ if (Array.isArray(value)) {
+ if (!value.length || value.length > 64) return null;
+ const texts = value.map((item) => unwrap(item, depth + 1));
+ return texts.every((text) => text !== null) ? texts.join("\n\n") : null;
+ }
+ if (!record(value)) return null;
+ if (value.status === "fulfilled" && "value" in value) {
+ return unwrap(value.value, depth + 1);
+ }
+ if (value.status === "rejected" && "reason" in value) {
+ return `工具执行失败\n${typeof value.reason === "string"
+ ? value.reason : JSON.stringify(value.reason, null, 2)}`;
+ }
+ if (value.type === "text" && typeof value.text === "string") {
+ return unwrapString(value.text, depth + 1);
+ }
+ if (Array.isArray(value.content) && value.content.length > 0
+ && value.content.every((item) => record(item) && item.type === "text")) {
+ const text = unwrap(value.content, depth + 1);
+ return text === null ? null : value.isError === true ? `工具执行失败\n${text}` : text;
+ }
+ if (typeof value.output === "string" && (
+ typeof value.chunk_id === "string" || typeof value.wall_time_seconds === "number"
+ || typeof value.exit_code === "number" || typeof value.session_id === "number"
+ )) {
+ const text = unwrapString(value.output, depth + 1);
+ const failed = typeof value.exit_code === "number" && value.exit_code !== 0;
+ return failed ? `${text}\n退出码:${value.exit_code}`.trim()
+ : text || (value.session_id != null ? "命令仍在运行,等待输出。" : "命令执行完成,没有文本输出。");
+ }
+ return null;
+}
+
+function unwrapString(text: string, depth: number): string {
+ if (depth > 6) return text;
+ try { return unwrap(JSON.parse(text), depth + 1) ?? text; }
+ catch { return text; }
+}
+
+export function readableToolOutput(output: string): { text: string; unwrapped: boolean } {
+ // Large native outputs already have transport bounds; do not repeatedly
+ // allocate/reparse a multi-megabyte result while streaming.
+ if (output.length > 512 * 1024) return { text: output, unwrapped: false };
+ try {
+ const parsed: unknown = JSON.parse(output);
+ const text = unwrap(parsed);
+ return text === null
+ ? { text: JSON.stringify(parsed, null, 2), unwrapped: false }
+ : { text, unwrapped: true };
+ } catch {
+ const lines = output.trim().split("\n");
+ if (lines.length > 1 && lines.length <= 64) {
+ try {
+ const text = unwrap(lines.map((line) => JSON.parse(line)));
+ if (text !== null) return { text, unwrapped: true };
+ } catch { /* Ordinary terminal output stays byte-for-byte readable. */ }
+ }
+ return { text: output, unwrapped: false };
+ }
+}
diff --git a/web/src/tool-presentation.ts b/web/src/tool-presentation.ts
index 7de99031..053aaf73 100644
--- a/web/src/tool-presentation.ts
+++ b/web/src/tool-presentation.ts
@@ -1,4 +1,5 @@
import type { ToolBlock } from "./domain/conversation";
+import { displayCommand } from "./tool-command";
import {
filePathsFromInput,
mutatedFilePaths,
@@ -41,7 +42,7 @@ export function presentTool(block: ToolBlock): ToolPresentation {
const input = block.input;
const file = mutatedFilePaths(block.tool, input)[0]
|| value(input, "file_path", "path");
- const command = value(input, "command", "cmd");
+ const command = displayCommand(input.command ?? input.cmd);
const pattern = value(input, "pattern", "query");
const url = value(input, "url");
const explicit = block.title?.trim();
diff --git a/web/src/turn-usage.ts b/web/src/turn-usage.ts
new file mode 100644
index 00000000..603541da
--- /dev/null
+++ b/web/src/turn-usage.ts
@@ -0,0 +1,31 @@
+import type { TokenUsage, TurnUsage } from "./protocol";
+import type { Turn } from "./domain/conversation";
+
+export type TurnUsageReadings = Record;
+
+export function rememberTurnUsage(readings: TurnUsageReadings | undefined,
+ incoming: TurnUsage): TurnUsageReadings {
+ const previous = Object.hasOwn(readings ?? {}, incoming.turn_id)
+ ? readings![incoming.turn_id] : undefined;
+ if (previous && (incoming.seq ?? 0) < (previous.seq ?? 0)) return readings!;
+ // Replacement snapshots, never arithmetic deltas: replay is idempotent.
+ const entries = Object.entries(readings ?? {}).filter(([id]) => id !== incoming.turn_id);
+ entries.push([incoming.turn_id, incoming]);
+ return Object.fromEntries(entries.slice(-32));
+}
+
+export function usageForTurn(turn: Turn, readings: TurnUsageReadings | undefined): TokenUsage | undefined {
+ if (!readings) return undefined;
+ for (const id of [turn.id, turn.historyTurnId, turn.clientMsgId,
+ turn.liveTaskId, turn.codexTurnId, turn.forkPointId]) {
+ if (id && Object.hasOwn(readings, id)) return readings[id].usage;
+ }
+ return undefined;
+}
+
+export function compactTokens(value: number | null | undefined): string {
+ if (value == null || !Number.isSafeInteger(value) || value < 0) return "—";
+ const unit = value >= 999_950 ? 1_000_000 : value >= 1_000 ? 1_000 : 1;
+ if (unit === 1) return String(value);
+ return `${(value / unit).toFixed(1).replace(/\.0$/, "")}${unit === 1_000 ? "k" : "m"}`;
+}
diff --git a/web/src/typography.css b/web/src/typography.css
new file mode 100644
index 00000000..dfd561e6
--- /dev/null
+++ b/web/src/typography.css
@@ -0,0 +1,34 @@
+/* Use these weights for app text, including font shorthands, so the reading
+ preference also reaches lazy dialogs. The normal scale preserves existing
+ typography exactly; bold text raises each weight without flattening it.
+ A fine text stroke also strengthens fallback fonts with limited weights.
+ Embedded previews keep their own document and typography. */
+:root{
+ --font-weight-400:400;
+ --font-weight-500:500;
+ --font-weight-550:550;
+ --font-weight-560:560;
+ --font-weight-600:600;
+ --font-weight-650:650;
+ --font-weight-700:700;
+ --font-weight-750:750;
+ --reading-text-stroke:0px;
+}
+:root[data-bold-text="true"]{
+ --font-weight-400:700;
+ --font-weight-500:750;
+ --font-weight-550:775;
+ --font-weight-560:780;
+ --font-weight-600:800;
+ --font-weight-650:850;
+ --font-weight-700:900;
+ --font-weight-750:950;
+ --reading-text-stroke:0.3px;
+}
+
+body,select{font-weight:var(--font-weight-400)}
+body{-webkit-text-stroke:var(--reading-text-stroke) currentColor}
+/* Icons and inline diagrams retain their own geometry. Iframes are separate
+ documents and never inherit the app's reading preference. */
+svg{-webkit-text-stroke:0}
+h1,h2,h3,h4,h5,h6,th{font-weight:var(--font-weight-700)}
diff --git a/web/src/use-attachment-drop.ts b/web/src/use-attachment-drop.ts
new file mode 100644
index 00000000..1b77c8f0
--- /dev/null
+++ b/web/src/use-attachment-drop.ts
@@ -0,0 +1,38 @@
+import { useEffect, useRef, useState } from "react";
+
+/** The drop location owns the files, independently of keyboard/session focus. */
+export function useAttachmentDrop(
+ surface: "main" | "btw", disabled: boolean,
+ onFiles: (files: FileList) => void,
+): boolean {
+ const latest = useRef({ disabled, onFiles });
+ latest.current = { disabled, onFiles };
+ const [over, setOver] = useState(false);
+ useEffect(() => {
+ const clear = () => setOver(false);
+ const drag = (event: DragEvent) => {
+ if (event.type === "dragleave" || event.type === "dragend") {
+ if (!event.relatedTarget) clear();
+ return;
+ }
+ if (!Array.from(event.dataTransfer?.types ?? []).includes("Files")) return;
+ // Locked panes also suppress the browser's navigation to a dropped file.
+ event.preventDefault();
+ const side = event.target instanceof Element
+ && !!event.target.closest('[data-attachment-target="btw"]');
+ const active = surface === (side ? "btw" : "main") && !latest.current.disabled;
+ if (event.type === "drop") {
+ clear();
+ if (active && event.dataTransfer?.files.length) latest.current.onFiles(event.dataTransfer.files);
+ } else setOver(active);
+ };
+ const events = ["dragenter", "dragover", "dragleave", "drop", "dragend"] as const;
+ for (const name of events) window.addEventListener(name, drag);
+ window.addEventListener("blur", clear);
+ return () => {
+ for (const name of events) window.removeEventListener(name, drag);
+ window.removeEventListener("blur", clear);
+ };
+ }, [surface]);
+ return over && !disabled;
+}
diff --git a/web/src/use-bold-text.ts b/web/src/use-bold-text.ts
new file mode 100644
index 00000000..58fbc191
--- /dev/null
+++ b/web/src/use-bold-text.ts
@@ -0,0 +1,34 @@
+import { useCallback, useEffect, useLayoutEffect, useState } from "react";
+
+const STORAGE_KEY = "cc_remote_bold_text";
+
+function readBoldText(): boolean {
+ try { return window.localStorage.getItem(STORAGE_KEY) === "1"; }
+ catch { return false; }
+}
+
+/** A browser-wide reading preference, shared by every engine and theme. */
+export function useBoldText() {
+ const [boldText, setValue] = useState(readBoldText);
+
+ useLayoutEffect(() => {
+ document.documentElement.dataset.boldText = String(boldText);
+ }, [boldText]);
+
+ useEffect(() => {
+ const update = (event: StorageEvent) => {
+ if (event.key === STORAGE_KEY || event.key === null) setValue(readBoldText());
+ };
+ window.addEventListener("storage", update);
+ return () => window.removeEventListener("storage", update);
+ }, []);
+
+ const setBoldText = useCallback((enabled: boolean) => {
+ setValue(enabled);
+ // Opaque preview frames and storage-blocked browsers still update in memory.
+ try { window.localStorage.setItem(STORAGE_KEY, enabled ? "1" : "0"); }
+ catch { /* This setting remains usable for the current page. */ }
+ }, []);
+
+ return { boldText, setBoldText };
+}
diff --git a/web/src/use-theme.ts b/web/src/use-theme.ts
new file mode 100644
index 00000000..76be53da
--- /dev/null
+++ b/web/src/use-theme.ts
@@ -0,0 +1,55 @@
+import { useCallback, useEffect, useLayoutEffect, useState } from "react";
+import {
+ LEGACY_THEME_KEY, THEME_STORAGE_KEY, readThemePreferences,
+ resolveThemeMode, saveThemeChoice, themePalette,
+ type ThemeChoice, type ThemeEngine,
+} from "./themes";
+
+function browserStorage(): Storage | undefined {
+ try { return typeof window === "undefined" ? undefined : window.localStorage; }
+ catch { return undefined; }
+}
+
+export function useTheme(engine: ThemeEngine) {
+ const [preferences, setPreferences] = useState(() => readThemePreferences(browserStorage()));
+ const [systemDark, setSystemDark] = useState(() => typeof window !== "undefined"
+ && typeof window.matchMedia === "function" && window.matchMedia("(prefers-color-scheme: dark)").matches);
+ const choice = preferences[engine] ?? "system";
+ const mode = resolveThemeMode(choice, systemDark);
+
+ useEffect(() => {
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
+ const update = () => setSystemDark(media.matches);
+ update();
+ media.addEventListener("change", update);
+ return () => media.removeEventListener("change", update);
+ }, []);
+ useEffect(() => {
+ const update = (event: StorageEvent) => {
+ if (event.key === THEME_STORAGE_KEY || event.key === LEGACY_THEME_KEY || event.key === null) {
+ setPreferences(readThemePreferences(browserStorage()));
+ }
+ };
+ window.addEventListener("storage", update);
+ return () => window.removeEventListener("storage", update);
+ }, []);
+
+ useLayoutEffect(() => {
+ const root = document.documentElement;
+ root.dataset.engine = engine;
+ root.dataset.theme = mode;
+ const palette = themePalette(choice);
+ root.dataset.palette = palette?.id ?? "classic";
+ root.style.colorScheme = mode;
+ const background = getComputedStyle(root).getPropertyValue("--bg").trim();
+ document.querySelectorAll('meta[name="theme-color"]').forEach(meta => {
+ meta.content = background;
+ });
+ }, [engine, choice, mode]);
+
+ const selectTheme = useCallback((next: ThemeChoice) => {
+ // Write only on a user selection, never on mount or a system appearance change.
+ setPreferences(current => saveThemeChoice(browserStorage(), current, engine, next));
+ }, [engine]);
+ return { choice, mode, selectTheme };
+}
diff --git a/web/src/ws.ts b/web/src/ws.ts
index 81145fe6..65cfad82 100644
--- a/web/src/ws.ts
+++ b/web/src/ws.ts
@@ -31,6 +31,7 @@ import {
} from "./outbox.ts";
import { probeSession, shouldReconnectAfterSessionProbe } from "./session-auth.ts";
import { uuid } from "./util.ts";
+import { RecoverableReadCoordinator } from "./recoverable-read.ts";
export type ConnState = "connecting" | "connected" | "reconnecting" | "disconnected";
@@ -48,6 +49,9 @@ export interface EventOwnership {
interface ListRequestOwnership {
ownership: EventOwnership;
order: number;
+ // Mutations can also return SessionList; their failures must stay visible.
+ readOnly: boolean;
+ deferred?: boolean;
}
interface InvalidatedListRefresh {
@@ -190,6 +194,8 @@ export class RelayWs {
private readonly pendingListOwnershipByRequest =
new Map();
private readonly latestAcceptedListOrderByScope = new Map();
+ private readonly listReadRetries = new RecoverableReadCoordinator(
+ (callback, delay) => setTimeout(callback, delay), timer => clearTimeout(timer));
private readonly invalidatedListRefreshByScope =
new Map();
private readonly invalidatedListScopeByRequest = new Map();
@@ -244,6 +250,7 @@ export class RelayWs {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.cancelProtocolRecovery();
this.stopHeartbeat();
+ this.listReadRetries.clear();
this.invalidatedListRefreshByScope.clear();
this.invalidatedListScopeByRequest.clear();
const ws = this.ws;
@@ -477,6 +484,7 @@ export class RelayWs {
this.activeEngine = engine;
this.activeSpace = space;
if (changed) {
+ this.listReadRetries.clear();
this.surfaceEpoch += 1;
this.surfaceEpochByScope[
sessionScopeKey(this.machineId, engine, space)
@@ -821,8 +829,8 @@ export class RelayWs {
});
}
- sendSetServiceTier(service_tier: string): void {
- this.send({ v: PROTOCOL_VERSION, type: "set_service_tier", service_tier, ts: nowTs(), ...this.sidObj() });
+ sendSetServiceTier(service_tier: string, sid?: string): boolean {
+ return this.send({ v: PROTOCOL_VERSION, type: "set_service_tier", service_tier, ts: nowTs(), ...this.sidObj(sid) });
}
sendSetCollaborationMode(mode: "default" | "plan"): void {
@@ -1270,11 +1278,15 @@ export class RelayWs {
}
sendListSessions(engine?: "claude" | "codex", space: Space = "code"): boolean {
- const targetEngine = engine ?? "claude";
- const obj: Record = { v: PROTOCOL_VERSION, type: "list_sessions", ts: nowTs() };
- if (engine && engine !== "claude") obj.engine = engine;
- if (space !== "code") obj.space = space;
- return this.sendListRefreshingCommand(obj, targetEngine, space) !== null;
+ return this.sendSessionListRead(engine ?? "claude", space) !== null;
+ }
+
+ private sendSessionListRead(
+ engine: "claude" | "codex", space: Space, retry = false,
+ ): string | null {
+ return this.sendListRefreshingCommand({
+ v: PROTOCOL_VERSION, type: "list_sessions", ts: nowTs(), engine, space,
+ }, engine, space, uuid(), retry);
}
/** Session mutations return a SessionList correlated to the mutation's own
@@ -1283,21 +1295,22 @@ export class RelayWs {
* time or be dropped as unowned. */
private sendListRefreshingCommand(
obj: Record, engine: "claude" | "codex", space: Space,
- commandId = uuid(),
+ commandId = uuid(), retry = false,
): string | null {
const ownership = this.ownershipSnapshot(engine, space);
+ if (!retry) this.listReadRetries.complete(ownership.scopeKey);
this.pendingListOwnershipByRequest.set(commandId, {
ownership,
order: ++this.listRequestOrder,
+ readOnly: obj.type === "list_sessions",
});
- while (this.pendingListOwnershipByRequest.size > OUTBOX_MAX_COMMANDS) {
- const oldest = this.pendingListOwnershipByRequest.keys().next().value;
- if (typeof oldest !== "string") break;
- this.pendingListOwnershipByRequest.delete(oldest);
+ // One insertion can evict at most one entry from this bounded map.
+ if (this.pendingListOwnershipByRequest.size > OUTBOX_MAX_COMMANDS) {
+ this.pendingListOwnershipByRequest.delete(this.pendingListOwnershipByRequest.keys().next().value!);
}
- const queued = this.sendTracked(obj, commandId) !== null;
+ const queued = this.sendTracked(obj, commandId);
if (!queued) this.pendingListOwnershipByRequest.delete(commandId);
- return queued ? commandId : null;
+ return queued;
}
private refreshInvalidatedSessionList(
@@ -1313,14 +1326,7 @@ export class RelayWs {
inFlight.dirty = true;
return;
}
- const obj: Record = {
- v: PROTOCOL_VERSION,
- type: "list_sessions",
- ts: nowTs(),
- };
- if (engine !== "claude") obj.engine = engine;
- if (space !== "code") obj.space = space;
- const requestId = this.sendListRefreshingCommand(obj, engine, space);
+ const requestId = this.sendSessionListRead(engine, space);
if (!requestId) return;
this.invalidatedListRefreshByScope.set(scopeKey, {
requestId, engine, space, dirty: false,
@@ -1832,6 +1838,7 @@ export class RelayWs {
// sharing the same order and a truly newer accepted list wins.
this.latestAcceptedListOrderByScope.set(
scopeKey, listedRequest.order);
+ this.listReadRetries.complete(scopeKey);
for (const session of msg.sessions) {
const sessionOwnership: EventOwnership = {
...listedOwnership,
@@ -1861,6 +1868,29 @@ export class RelayWs {
}
}
if (msg.type === "error" && msg.request_id) {
+ const listedRequest = this.pendingListOwnershipByRequest.get(
+ msg.request_id);
+ if (msg.code === "busy" && listedRequest?.readOnly) {
+ // A temporary catalog guard is a deferred background read. Keep
+ // the current sidebar/focus intact and retry only this surface;
+ // never turn it into an unrelated global operation-failed banner.
+ const { ownership, order } = listedRequest;
+ const { scopeKey, engine, space } = ownership;
+ if (!listedRequest.deferred
+ && this.acceptsOwnership(ownership, socketGeneration)
+ && engine === this.activeEngine && space === this.activeSpace
+ && order > (this.latestAcceptedListOrderByScope.get(scopeKey) ?? 0)) {
+ listedRequest.deferred = true;
+ this.listReadRetries.retry(scopeKey, () => {
+ // Surface changes cancel this coordinator; also reject a
+ // replaced connection before issuing its retry.
+ if (this.acceptsOwnership(ownership, socketGeneration)) {
+ this.sendSessionListRead(engine, space, true);
+ }
+ });
+ }
+ return;
+ }
// A failed mutation can deliberately send Error followed by its
// authoritative SessionList so the sidebar reconciles an uncertain
// outcome. Keep bounded list ownership for that possible second
@@ -2052,6 +2082,7 @@ export class RelayWs {
};
ws.onclose = (ev: CloseEvent) => {
if (socketGeneration !== this.connectionGeneration || this.ws !== ws) return;
+ this.listReadRetries.clear();
if (this.ws === ws) this.ws = null;
this.stopHeartbeat();
if (ev.code === 4406) {
diff --git a/web/tests/auto-compact.test.ts b/web/tests/auto-compact.test.ts
index 852b72c0..f24711ab 100644
--- a/web/tests/auto-compact.test.ts
+++ b/web/tests/auto-compact.test.ts
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createServer } from "vite";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
import {
normalizeAutoCompactSelection,
@@ -62,7 +64,7 @@ assert.doesNotMatch(newChatSource, /auto-compact-chip/,
assert.doesNotMatch(btwSource, /压缩 ·/,
"BTW autocompact must remain command-only");
assert.match(appSource,
- /const requestContext = \(\) => \{[\s\S]{0,620}defer_context_request[\s\S]{0,420}sendContextRequestTo\(focusedSid, true\)/,
+ /const requestContext = \(\) => \{[\s\S]{0,1000}sendContextRequestTo\(focusedSid, true\)/,
"opening the context popover must explicitly request the native reading");
assert.doesNotMatch(appSource,
/runtime\?\.contextRequestId\s*\|\|\s*runtime\?\.contextRefreshDeferred/,
@@ -385,8 +387,8 @@ try {
});
assert.equal(contextState.runtimes[sid].contextReport?.total_tokens, 180,
"the lightweight ring may track the latest turn estimate");
- assert.equal(contextState.runtimes[sid].contextExactReport?.total_tokens, 160,
- "a recent-turn estimate must not overwrite the popover's exact reading");
+ assert.equal(contextState.runtimes[sid].contextExactReport?.total_tokens, 180,
+ "newer Claude usage must replace the older control reading in the popover");
contextState = reduce(contextState, {
type: "begin_context_request",
@@ -408,8 +410,8 @@ try {
"a busy native read must remain queued for the next terminal boundary");
assert.equal(contextState.runtimes[sid].contextError, null,
"a deferred refresh is not a user-visible failure");
- assert.equal(contextState.runtimes[sid].contextExactReport?.total_tokens, 160,
- "deferral must preserve the last exact report");
+ assert.equal(contextState.runtimes[sid].contextExactReport?.total_tokens, 180,
+ "deferral must preserve the latest usable report");
contextState = reduce(contextState, {
type: "event",
@@ -440,6 +442,22 @@ try {
assert.equal(contextState.runtimes[sid].contextRefreshDeferred, true,
"a cached old-generation report must not consume an exact refresh intent");
+ let pollingDeferred = reduce(contextState, {
+ type: "begin_context_request", sid, requestId: "cache-poll", refresh: false,
+ });
+ assert.equal(pollingDeferred.runtimes[sid].contextRefreshDeferred, true);
+ pollingDeferred = reduce(pollingDeferred, {
+ type: "event", event: event({
+ type: "context_report", sid, request_id: "cache-poll",
+ total_tokens: 198, max_tokens: 1_000, percentage: 19.8,
+ source: "recent_turn", categories: [],
+ }),
+ });
+ assert.equal(pollingDeferred.runtimes[sid].contextRequestId, null);
+ assert.equal(pollingDeferred.runtimes[sid].contextRefreshDeferred, true,
+ "a matching cache-only poll must not cancel an idle-only native refresh");
+ assert.equal(pollingDeferred.runtimes[sid].contextExactReport?.total_tokens, 198);
+
const deferredSatisfied = reduce(contextState, {
type: "event",
event: event({
@@ -500,6 +518,62 @@ try {
assert.equal(unrequestedContextState.runtimes[sid].contextReport?.total_tokens,
160);
+ let compactState = {
+ ...initialState, focusedSid: "compact-session",
+ runtimes: { "compact-session": createRuntime() },
+ };
+ const compactEvent = (body: Record) => ({
+ type: "event", event: event({ sid: "compact-session", ...body }),
+ });
+ compactState = reduce(compactState, compactEvent({
+ type: "context_report", source: "control", total_tokens: 600_000,
+ max_tokens: 800_000, percentage: 75, categories: [],
+ }));
+ compactState = reduce(compactState, compactEvent({
+ type: "context_report", source: "recent_turn", total_tokens: 8_000,
+ max_tokens: 800_000, percentage: 1, categories: [],
+ }));
+ compactState = reduce(compactState, compactEvent({
+ type: "context_report", available: false, total_tokens: 0,
+ max_tokens: 0, percentage: 0, categories: [],
+ }));
+ assert.equal(compactState.runtimes["compact-session"].contextExactReport.total_tokens, 8_000,
+ "a transient read failure retains the new post-compact count, never the old control sample");
+ compactState = reduce(compactState, compactEvent({
+ type: "context_report", source: "recent_turn", total_tokens: 9_000,
+ max_tokens: 0, percentage: 0, categories: [],
+ }));
+ assert.equal(compactState.runtimes["compact-session"].contextExactReport.total_tokens, 8_000,
+ "temporary capacity loss keeps the complete last reading until native summary recovers");
+
+ const start = { type: "process", kind: "compaction", item_id: "compact-status",
+ phase: "start", status: "running", turn_id: "compact-turn", title: "压缩上下文" };
+ compactState = reduce(compactState, compactEvent(start));
+ const { ProcessActivity } = await reducerHarness.ssrLoadModule(
+ "/src/components/ProcessTimeline.tsx");
+ const getCompactBlock = () => compactState.runtimes["compact-session"].turns
+ .flatMap((turn: { blocks: unknown[] }) => turn.blocks)[0];
+ const runningMarkup = renderToStaticMarkup(createElement(ProcessActivity, {
+ block: getCompactBlock(),
+ }));
+ assert.match(runningMarkup, /process-compaction-running/);
+ assert.match(runningMarkup, /正在压缩上下文/);
+ const end = { ...start, item_id: "native-boundary", phase: "end", status: "succeeded",
+ summary: "手动压缩 · 600,000 → 8,000 tokens", duration_ms: 20_000,
+ input: { compaction_started_id: "compact-status" } };
+ compactState = reduce(compactState, compactEvent(end));
+ compactState = reduce(compactState, compactEvent(end));
+ assert.equal(compactState.runtimes["compact-session"].turns.length, 1);
+ assert.equal(compactState.runtimes["compact-session"].turns[0].blocks.length, 1,
+ "a completed native boundary replaces its exact status placeholder once");
+ const compactBlock = getCompactBlock() as { item_id: string; done: boolean; input?: unknown };
+ assert.equal(compactBlock.item_id, "native-boundary");
+ assert.equal(compactBlock.done, true);
+ assert.equal(compactBlock.input, undefined, "internal binding metadata stays out of the UI");
+ const completedMarkup = renderToStaticMarkup(createElement(ProcessActivity, { block: compactBlock }));
+ assert.doesNotMatch(completedMarkup, /process-compaction-running/);
+ assert.match(completedMarkup, /600,000 → 8,000 tokens/);
+
const defaultNewChat = reduce(initialState, {
type: "enter_new_chat",
cwd: "/repo",
diff --git a/web/tests/background-tasks.spec.ts b/web/tests/background-tasks.spec.ts
new file mode 100644
index 00000000..b7ca295a
--- /dev/null
+++ b/web/tests/background-tasks.spec.ts
@@ -0,0 +1,133 @@
+import { expect, test, type Page } from "@playwright/test";
+
+const url = "/tests/history-browser.html?background-tasks=1";
+const trigger = (page: Page) => page.getByRole("button", { name: /后台任务,\d+ 项进行中/ });
+const dialog = (page: Page) => page.getByRole("dialog", { name: "后台任务", exact: true });
+
+test("background tasks keep the composer compact and open readable details", async ({ page }, testInfo) => {
+ await page.goto(url);
+ const chip = trigger(page);
+ await expect(chip).toHaveText("后台任务 · 2");
+ await expect(dialog(page)).toHaveCount(0);
+ const before = await page.locator(".thread-shell").boundingBox();
+ const chipBox = (await chip.boundingBox())!;
+ const modeBox = (await page.locator(".runbar .seg").boundingBox())!;
+ expect(Math.abs(chipBox.y + chipBox.height / 2 - modeBox.y - modeBox.height / 2)).toBeLessThan(2);
+ expect(chipBox.x + chipBox.width).toBeLessThan(modeBox.x);
+ expect(chipBox.height).toBeLessThanOrEqual(40);
+ await chip.click();
+ const panel = dialog(page);
+ await expect(panel).toBeVisible();
+ await expect(panel.getByText("检查构建进度和板载温度", { exact: true })).toBeVisible();
+ await expect(panel.getByText(/运行中 · 2分/)).toBeVisible();
+ if ((page.viewportSize()?.width ?? 0) <= 600) {
+ await page.keyboard.press("Tab");
+ await expect(panel.getByRole("button", { name: "关闭后台任务" })).toBeFocused();
+ await page.keyboard.press("Tab");
+ await expect(panel.locator("summary")).toBeFocused();
+ await page.keyboard.press("Tab");
+ await expect(panel.getByRole("button", { name: /核对构建产物/ })).toBeFocused();
+ }
+ await panel.locator("summary").click();
+ await expect(panel.getByText("$ make verify", { exact: true })).toBeVisible();
+ const after = await page.locator(".thread-shell").boundingBox();
+ expect(after).toEqual(before);
+ const panelBox = (await panel.boundingBox())!;
+ const mobile = (page.viewportSize()?.width ?? 0) <= 600;
+ if (mobile) {
+ await expect(panel).toHaveAttribute("aria-modal", "true");
+ expect(Math.abs(panelBox.x + panelBox.width / 2 - page.viewportSize()!.width / 2)).toBeLessThan(2);
+ const viewport = await page.locator(".thread-shell").boundingBox();
+ const visualCenter = await page.evaluate(() => (window.visualViewport?.offsetTop ?? 0)
+ + (window.visualViewport?.height ?? window.innerHeight) / 2);
+ const center = panelBox.y + panelBox.height / 2;
+ // Short chat areas may use the full visible viewport to keep details usable.
+ expect(Math.min(Math.abs(center - viewport!.y - viewport!.height / 2),
+ Math.abs(center - visualCenter))).toBeLessThan(2);
+ } else {
+ await expect(panel).toHaveAttribute("data-placement", "above");
+ expect(panelBox.y + panelBox.height).toBeLessThan(chipBox.y);
+ expect(Math.abs(panelBox.x - chipBox.x)).toBeLessThan(2);
+ }
+ await page.screenshot({ path: testInfo.outputPath("background-tasks-open.png"), animations: "disabled" });
+ await page.keyboard.press("Escape");
+ await expect(panel).toHaveCount(0);
+ await expect(chip).toBeFocused();
+});
+
+test("background tasks follow native terminal events and empty snapshots", async ({ page }) => {
+ await page.goto(url);
+ await page.getByRole("button", { name: "回复结束", exact: true }).click();
+ await expect(page.locator(".runbar .seg")).toHaveCount(0);
+ await expect(trigger(page)).toHaveText("后台任务 · 2");
+ await trigger(page).click();
+ // Native updates can arrive while a modal is open, without a user click.
+ await page.getByRole("button", { name: "构建完成", exact: true }).evaluate(button => (button as HTMLButtonElement).click());
+ await expect(trigger(page)).toHaveText("后台任务 · 1");
+ await expect(dialog(page)).toBeVisible();
+ await expect(dialog(page).getByText("检查构建进度和板载温度", { exact: true })).toHaveCount(0);
+ await page.getByRole("button", { name: "代理完成", exact: true }).evaluate(button => (button as HTMLButtonElement).click());
+ await expect(trigger(page)).toHaveCount(0);
+ await expect(dialog(page)).toHaveCount(0);
+ await expect(page.locator(".runbar")).toHaveCount(0);
+ await expect(page.locator(".thread")).toContainText("构建还在后台运行");
+
+ await page.getByRole("button", { name: "多个任务", exact: true }).click();
+ await expect(trigger(page)).toHaveText("后台任务 · 12");
+ await expect(dialog(page)).toHaveCount(0);
+ await trigger(page).click();
+ const list = dialog(page).locator(".background-task-list");
+ expect(await list.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true);
+ await page.getByRole("button", { name: "空快照", exact: true }).evaluate(button => (button as HTMLButtonElement).click());
+ await expect(trigger(page)).toHaveCount(0);
+ await expect(dialog(page)).toHaveCount(0);
+});
+
+test("background tasks preserve draft input and reset open state on session changes", async ({ page }) => {
+ await page.goto(url);
+ const input = page.locator(".inrow textarea");
+ await input.fill("继续检查这次构建");
+ await trigger(page).click();
+ await dialog(page).getByRole("button", { name: "关闭后台任务", exact: true }).click();
+ await expect(input).toHaveValue("继续检查这次构建");
+ await trigger(page).click();
+ await page.getByRole("button", { name: "切换会话", exact: true }).evaluate(button => (button as HTMLButtonElement).click());
+ await expect(dialog(page)).toHaveCount(0);
+ await expect(trigger(page)).toHaveText("后台任务 · 2");
+ await trigger(page).click();
+ await dialog(page).getByRole("button", { name: /核对构建产物/ }).click();
+ await expect(page.getByTestId("background-opened")).toHaveText("agent");
+ await expect(dialog(page)).toHaveCount(0);
+});
+
+test("background tasks stay within the mobile viewport after keyboard resize", async ({ page }) => {
+ test.skip((page.viewportSize()?.width ?? 0) > 600, "Mobile keyboard layout");
+ await page.goto(url);
+ await page.getByRole("button", { name: "多个任务", exact: true }).click();
+ await trigger(page).click();
+ const originalHeight = (await dialog(page).boundingBox())!.height;
+ const width = page.viewportSize()!.width;
+ const height = page.viewportSize()!.height;
+ await page.setViewportSize({ width, height: 380 });
+ await expect.poll(async () => {
+ const box = (await dialog(page).boundingBox())!;
+ return box.y >= 0 && box.y + box.height <= 380;
+ }).toBe(true);
+ await page.setViewportSize({ width, height });
+ await expect.poll(async () => (await dialog(page).boundingBox())!.height).toBeCloseTo(originalHeight, 0);
+ await expect(dialog(page).locator(".background-task-list")).toBeVisible();
+});
+
+test("background tasks show a small desktop hover preview and honor reduced motion", async ({ page }) => {
+ test.skip((page.viewportSize()?.width ?? 0) <= 600, "Desktop hover");
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await page.goto(url + "&theme=light");
+ await trigger(page).hover();
+ await expect(page.getByRole("tooltip")).toContainText("检查构建进度和板载温度");
+ await expect(dialog(page)).toHaveCount(0);
+ expect(await page.locator(".background-task-indicator").evaluate(node => getComputedStyle(node).animationName)).toBe("none");
+ await trigger(page).click();
+ await page.locator(".inrow textarea").click();
+ await expect(dialog(page)).toHaveCount(0);
+ await expect(page.locator(".inrow textarea")).toBeFocused();
+});
diff --git a/web/tests/claude-background-process.test.ts b/web/tests/claude-background-process.test.ts
index ca22b33b..b591237b 100644
--- a/web/tests/claude-background-process.test.ts
+++ b/web/tests/claude-background-process.test.ts
@@ -23,6 +23,7 @@ try {
await harness.ssrLoadModule("/src/reducer.ts");
const { ChatView } = await harness.ssrLoadModule(
"/src/components/ChatView.tsx");
+ const { claudeContinuations } = await harness.ssrLoadModule("/src/claude-continuations.ts");
const event = (body: Record): ServerEvent => ({
v: PROTOCOL_VERSION,
ts: 10,
@@ -149,27 +150,6 @@ try {
"untrusted edge volume cannot grow the detached-task dock past protocol bounds",
);
- const dockMarkup = renderToStaticMarkup(createElement(ChatView, {
- sid: "background-dock",
- turns: [],
- engine: "claude",
- backgroundProcesses: [{
- kind: "process",
- item_id: "bash-task",
- processKind: "task",
- phase: "snapshot",
- status: "running",
- title: "Run verification",
- command: "make verify",
- background: true,
- done: false,
- }],
- }));
- assert.match(dockMarkup, /后台任务正在运行/);
- assert.match(dockMarkup, /Run verification/);
- assert.match(dockMarkup, /make verify/,
- "the detached Bash card reuses the expandable command UI");
-
const followupMarkup = renderToStaticMarkup(createElement(ChatView, {
sid: "followup-history",
engine: "claude",
@@ -213,7 +193,7 @@ try {
}));
assert.match(
followupMarkup,
- /Build is running\.[\s\S]*Build completed[\s\S]*Claude 随后继续回复[\s\S]*Build passed\./,
+ /Build is running\.[\s\S]*Claude 继续处理[\s\S]*Build passed\./,
"a task-completion follow-up keeps its source-time narrative boundary",
);
@@ -244,9 +224,47 @@ try {
}));
assert.match(
concurrentFollowupMarkup,
- /Task A completed[\s\S]*A report\.[\s\S]*Task B completed[\s\S]*B report\./,
+ /Claude 继续处理[\s\S]*A report\.[\s\S]*Claude 继续处理[\s\S]*B report\./,
"each detached completion labels its own later reply segment",
);
+
+ const blocks: Block[] = [
+ { kind: "text", message_id: "original", channel: "final", text: "Interim", done: true },
+ { kind: "process", item_id: "child", processKind: "agent", phase: "end",
+ title: "Long private report stays in child details", status: "succeeded", done: true, background: true },
+ { kind: "text", message_id: "thinking-a", channel: "thinking", text: "Fixture thought A", done: true, background: true, startedTs: 20_000 },
+ { kind: "tool", message_id: "tool-a-message", tool_use_id: "tool-a", tool: "Bash", input: {}, done: true, background: true },
+ { kind: "text", message_id: "answer-a", channel: "final", text: "Result A", done: true, background: true },
+ { kind: "text", message_id: "thinking-b", channel: "thinking", text: "", done: false, background: true, startedTs: 30_000 },
+ ];
+ const narrative = claudeContinuations(blocks, blocks.filter((block) => block.kind === "text" && block.channel === "final"));
+ assert.equal(narrative.original.length, 2);
+ assert.equal(narrative.continuations.length, 2);
+ assert.equal(narrative.continuations[0].blocks.length, 3);
+ assert.equal(narrative.continuations[0].answers[0].message_id, "answer-a");
+ assert.equal(narrative.continuations[1].id, "thinking-b",
+ "a main continuation is visible from its first empty stream frame");
+
+ let continuationState = {
+ ...initialState, focusedSid: sid,
+ runtimes: { [sid]: { ...createRuntime(), turns: [{ id: "settled-parent", prompt: "run", done: true, blocks: [] }] } },
+ };
+ const send = (body: Record) => {
+ continuationState = reduce(continuationState, { type: "event", event: event({ sid, ...body }) });
+ };
+ send({ type: "state", state: "running" });
+ send({ type: "process", turn_id: "settled-parent", item_id: "child", kind: "agent",
+ phase: "end", status: "succeeded", title: "Child done", background: true });
+ assert.equal(continuationState.runtimes[sid].liveOwner, null,
+ "a completed child cannot claim the parent's running owner");
+ send({ type: "assistant_msg_start", turn_id: "settled-parent", message_id: "continuation", channel: "thinking", background: true });
+ assert.equal(continuationState.runtimes[sid].liveOwner?.turnId, "settled-parent");
+ assert.equal(continuationState.runtimes[sid].turns[0].done, true,
+ "the prior native completion receipt remains settled");
+ send({ type: "state", state: "idle" });
+ send({ type: "delta", turn_id: "settled-parent", message_id: "continuation", channel: "thinking", text: "Late replay", background: true });
+ assert.equal(continuationState.runtimes[sid].liveOwner, null,
+ "replay into an idle session cannot revive the spark");
} finally {
await harness.close();
}
diff --git a/web/tests/fixtures/background-tasks.tsx b/web/tests/fixtures/background-tasks.tsx
new file mode 100644
index 00000000..b0a0a046
--- /dev/null
+++ b/web/tests/fixtures/background-tasks.tsx
@@ -0,0 +1,92 @@
+import { useEffect, useReducer, useRef, useState } from "react";
+import { Composer } from "../../src/components/Composer";
+import { ChatView } from "../../src/components/ChatView";
+import BackgroundTaskControl from "../../src/components/BackgroundTaskControl";
+import { ComposerDraftStore } from "../../src/composer-drafts";
+import { createRuntime, initialState, reduce, type AppState } from "../../src/reducer";
+import { PROTOCOL_VERSION, type ServerEvent } from "../../src/protocol";
+import { useMobileViewport } from "../../src/use-mobile-viewport";
+
+function event(sid: string, payload: Record): ServerEvent {
+ return { v: PROTOCOL_VERSION, ts: Date.now() / 1000, sid, ...payload } as ServerEvent;
+}
+
+function initial(): AppState {
+ let state: AppState = { ...initialState, runtimes: {} };
+ for (const sid of ["background-a", "background-b"]) {
+ state.runtimes[sid] = createRuntime();
+ for (const payload of [
+ { type: "user_msg", msg_id: "parent", prompt: "检查构建进度和板载温度" },
+ { type: "state", state: "running" },
+ { type: "process", item_id: "build", kind: "task", phase: "start", status: "running",
+ turn_id: "parent", title: "检查构建进度和板载温度", command: "make verify",
+ background: true, ts: Date.now() / 1000 - 154 },
+ { type: "process", item_id: "agent", kind: "agent", phase: "start", status: "running",
+ turn_id: "parent", title: "核对构建产物", summary: "后台检查仍在继续", background: true },
+ { type: "assistant_msg_start", message_id: "answer", turn_id: "parent", channel: "final" },
+ { type: "delta", message_id: "answer", turn_id: "parent", channel: "final",
+ text: "构建还在后台运行,我会在完成后继续核对结果。" },
+ { type: "assistant_msg_end", message_id: "answer", turn_id: "parent", channel: "final" },
+ ]) state = reduce(state, { type: "event", event: event(sid, payload) });
+ }
+ return state;
+}
+
+export function BackgroundTasksFixture() {
+ useMobileViewport();
+ const [state, dispatch] = useReducer(reduce, undefined, initial);
+ const [sid, setSid] = useState("background-a");
+ const [opened, setOpened] = useState("");
+ const drafts = useRef(new ComposerDraftStore());
+ const runtime = state.runtimes[sid];
+ useEffect(() => {
+ document.documentElement.dataset.engine = "claude";
+ document.documentElement.dataset.theme = new URLSearchParams(location.search).get("theme") ?? "dark";
+ }, []);
+ const emit = (payload: Record) => dispatch({ type: "event", event: event(sid, payload) });
+ const complete = (itemId: string, kind: "task" | "agent") => emit({
+ type: "process", item_id: itemId, kind, phase: "end", status: "succeeded",
+ turn_id: "parent", title: itemId === "build" ? "检查构建进度和板载温度" : "核对构建产物",
+ summary: "任务已完成", background: true,
+ });
+ return
+
+ {
+ emit({ type: "turn_end", turn_id: "parent",
+ result: { subtype: "success", duration_ms: 12000, is_error: false } });
+ emit({ type: "state", state: "idle" });
+ }}>回复结束
+ complete("build", "task")}>构建完成
+ complete("agent", "agent")}>代理完成
+ emit({ type: "background_process_sync", items: [] })}>空快照
+ setSid(value => value === "background-a" ? "background-b" : "background-a")}>切换会话
+ emit({ type: "background_process_sync", items: Array.from({ length: 12 }, (_, i) => ({
+ item_id: `task-${i}`, kind: "task", status: "running", title: `后台任务 ${i + 1}:${"正在检查构建输出与温度。".repeat(4)}`,
+ command: "make verify", started_at: Date.now() / 1000 - 60,
+ })) })}>多个任务
+
+
+
+ setOpened(id)} />
+ 0
+ ? setOpened(id)} /> : null}
+ connState="connected" wrapperOnline sendMode={runtime.sendMode}
+ setSendMode={mode => dispatch({ type: "set_send_mode", sid, mode })}
+ queue={[]} pendingSend={null} failedDeferred={[]}
+ unconfirmedQueued={[]} unconfirmedReplaceable={[]}
+ queueCapacity={{}} replaceQueueCapacity={{}}
+ model="claude-fable-5-1" effort="max" perm="bypassPermissions"
+ permissionProfile={null} permissionProfiles={null} webSearch={null}
+ collaborationMode="default" engine="claude" editPrompt={null}
+ onEditConsumed={() => {}} onSendQuery={() => false} onSteerQuery={() => false}
+ onInterrupt={() => {}} onEnqueue={() => false} onSetPending={() => false}
+ onRemoveQueued={() => {}} onInspectQueued={() => {}} onSetModel={() => {}}
+ onSetEffort={() => {}} onSetPerm={() => {}} onSetPermissionProfile={() => {}}
+ onGetPermissionProfiles={() => {}} onSetWebSearch={() => {}}
+ onSetCollaborationMode={() => {}} onClear={() => {}} onContext={() => {}}
+ contextReport={null} />
+ ;
+}
diff --git a/web/tests/fixtures/mermaid-artifact.tsx b/web/tests/fixtures/mermaid-artifact.tsx
new file mode 100644
index 00000000..226fd8fb
--- /dev/null
+++ b/web/tests/fixtures/mermaid-artifact.tsx
@@ -0,0 +1,17 @@
+import { useState } from "react";
+import { ArtifactPanel } from "../../src/components/ArtifactPanel";
+import { WorkArtifactsSheet } from "../../src/components/WorkArtifactsSheet";
+
+export function MermaidArtifactFixture() {
+ const [file, setFile] = useState(null);
+ const extension = new URLSearchParams(location.search).get("ext") ?? "mmd";
+ const path = `camera_navigation_pipeline.${extension}`;
+ const content = "flowchart TD\n camera[双目相机] --> decoder[硬件解码]\n decoder --> planner[路径规划]\n";
+ return
+ {}} />
+ {file && {}} onClose={() => setFile(null)} />}
+ ;
+}
diff --git a/web/tests/fixtures/turn-usage.tsx b/web/tests/fixtures/turn-usage.tsx
new file mode 100644
index 00000000..3b9e90fd
--- /dev/null
+++ b/web/tests/fixtures/turn-usage.tsx
@@ -0,0 +1,34 @@
+import { useReducer } from "react";
+import { ChatView } from "../../src/components/ChatView";
+import { createRuntime, initialState, reduce } from "../../src/reducer";
+import { PROTOCOL_VERSION, type ServerEvent } from "../../src/protocol";
+
+export function TurnUsageFixture() {
+ const params = new URLSearchParams(location.search);
+ const engine = params.get("engine") === "claude" ? "claude" : "codex";
+ document.documentElement.dataset.engine = engine;
+ document.documentElement.dataset.theme = params.get("theme") === "dark" ? "dark" : "light";
+ const [state, dispatch] = useReducer(reduce, { ...initialState, focusedSid: "session", runtimes: {
+ session: { ...createRuntime(), state: "running" as const, turns: [{
+ id: "user", forkPointId: "native", prompt: "检查任务进度", done: false,
+ blocks: [{ kind: "text" as const, message_id: "reply", text: "正在检查代码和任务状态。", done: false, channel: "commentary" as const }],
+ }], turnUsage: { native: { v: PROTOCOL_VERSION, type: "turn_usage" as const, ts: 1,
+ turn_id: "native", sid: "session", seq: 1,
+ usage: { input_tokens: 184_001, output_tokens: 2400, cache_read_tokens: 180_000, cache_write_tokens: 500 } } } },
+ } });
+ const runtime = state.runtimes.session;
+ const send = (event: Partial) => dispatch({ type: "event", event: {
+ v: PROTOCOL_VERSION, sid: "session", ts: 2, ...event,
+ } as ServerEvent });
+ return
+
+ send({ type: "turn_usage", turn_id: "native", seq: 2,
+ usage: { input_tokens: 186_800, output_tokens: 5820, cache_read_tokens: 182_000, cache_write_tokens: 500 } })}>更新用量
+ send({ type: "turn_end", turn_id: "native", seq: 3,
+ result: { subtype: "success", duration_ms: 1000, is_error: false } })}>结束任务
+
+
+
+ ;
+}
diff --git a/web/tests/history-browser.fixture.tsx b/web/tests/history-browser.fixture.tsx
index 42326378..8edb77c3 100644
--- a/web/tests/history-browser.fixture.tsx
+++ b/web/tests/history-browser.fixture.tsx
@@ -7,6 +7,9 @@ import {
useState,
} from "react";
import { createRoot } from "react-dom/client";
+import { TurnUsageFixture } from "./fixtures/turn-usage";
+import { BackgroundTasksFixture } from "./fixtures/background-tasks";
+import { MermaidArtifactFixture } from "./fixtures/mermaid-artifact";
import "../src/index.css";
import "../src/App.css";
@@ -31,6 +34,7 @@ import type {
ThreadGoal,
} from "../src/protocol";
import { PROTOCOL_VERSION } from "../src/protocol";
+import { TimedMessageTag } from "../src/components/TimedMessageTag";
import {
ChatView,
} from "../src/components/ChatView";
@@ -57,6 +61,7 @@ import {
} from "../src/components/QueuedQueryDialog";
import { DirPicker } from "../src/components/DirPicker";
import { HeaderMenu } from "../src/components/HeaderMenu";
+import { useBoldText } from "../src/use-bold-text";
import { UsageActivitySheet } from "../src/components/UsageActivitySheet";
import { displayHistoryProjection } from "../src/history-recovery";
import { summaryHistoryTurns } from "../src/history-summary";
@@ -534,6 +539,7 @@ function UsageActivityBrowserFixture({
engine: "claude" | "codex";
}) {
const [activityOpen, setActivityOpen] = useState(false);
+ const { boldText, setBoldText } = useBoldText();
const report = useMemo(fixtureUsageReport, []);
useEffect(() => {
document.documentElement.dataset.engine = engine;
@@ -544,12 +550,14 @@ function UsageActivityBrowserFixture({
true}
onOpenUsageActivity={() => setActivityOpen(true)}
- onToggleTheme={() => {}}
+ onSelectTheme={() => {}}
onLogout={() => {}}
/>
@@ -2087,6 +2095,7 @@ function HistoryConversationBrowserFixture() {
export function HistoryBrowserFixture() {
const params = new URLSearchParams(window.location.search);
+ if (params.has("background-tasks")) return ;
const planUi = params.get("plan-ui");
if (params.has("profile-sidebar")) return ;
if (planUi) return ;
@@ -2121,6 +2130,11 @@ function ProfileSidebarFixture() {
);
const [newProfileId, setNewProfileId] = useState("none");
const [activeSessionId, setActiveSessionId] = useState("profile-sidebar-active");
+ const [timedTasks, setTimedTasks] = useState(() => params.has("timed-tasks") ? [{
+ task_id: "timer-test", title: "每分钟向当前会话发送测试",
+ next_message_at: Date.now() / 1000 + 42, interval_seconds: 60,
+ sent_count: 2, total_count: 3, valid_until: Date.now() / 1000 + 90,
+ }] : []);
useEffect(() => {
const root = document.documentElement;
const previousEngine = root.dataset.engine;
@@ -2150,6 +2164,7 @@ function ProfileSidebarFixture() {
codex_profile_label: "Stack",
}, {
session_id: "profile-sidebar-default",
+ timed_tasks: timedTasks,
summary: "cc-remote 派生",
cwd: "/repo/cc-remote",
state: "idle",
@@ -2162,6 +2177,13 @@ function ProfileSidebarFixture() {
return (
<>
+ {params.has("timed-tasks") &&
+
setTimedTasks([])}>结束定时任务
+
测试
+
测试
+
}
+ : rootParams.has("artifact-mermaid")
+ ?
+ : rootParams.has("artifact-audio")
?
: rootParams.has("artifact-html")
?
diff --git a/web/tests/history-browser.spec.ts b/web/tests/history-browser.spec.ts
index 43bac6b7..d7110f2c 100644
--- a/web/tests/history-browser.spec.ts
+++ b/web/tests/history-browser.spec.ts
@@ -184,17 +184,19 @@ type PanelRelayEvent = T extends ServerEvent
async function mockRightPanelRelay(
page: import("@playwright/test").Page,
{ visible = false, retained = true, engine = "codex", seedTurns = [],
- secondParent = false, btwReadOnly = false, imageAssets = false, imageData, externalPreview, historyReply }: {
+ secondParent = false, parentState = "idle", btwReadOnly = false, imageAssets = false, imageData, externalPreview, historyReply, listReply }: {
visible?: boolean;
retained?: boolean;
engine?: "codex" | "claude";
seedTurns?: NonNullable["turns"]>;
secondParent?: boolean;
+ parentState?: "idle" | "running";
btwReadOnly?: boolean;
imageAssets?: boolean;
imageData?: { data: string; width: number; height: number };
externalPreview?: "allow" | "replace";
historyReply?: (command: Record) => PanelRelayEvent> | null;
+ listReply?: (command: Record) => PanelRelayEvent | undefined;
} = {},
) {
const parentSid = "layout-parent";
@@ -226,7 +228,7 @@ async function mockRightPanelRelay(
}));
const snapshot = (sid: string) => {
emit({ type: "snapshot", sid, cc_session_id: sid,
- state: sid === btwSid && !btwReadOnly ? "running" : "idle", tail_text: "",
+ state: sid === btwSid ? (btwReadOnly ? "idle" : "running") : parentState, tail_text: "",
cwd: "/tmp/layout", generation: "layout-generation",
...(sid === btwSid && btwReadOnly ? { control: {
v: PROTOCOL_VERSION, ts: 1, type: "session_control" as const,
@@ -247,13 +249,15 @@ async function mockRightPanelRelay(
engine, created_at: 1, state: "running" }] : [] });
snapshot(parentSid);
} else if (command.type === "list_sessions") {
+ const response = listReply?.(command);
+ if (response) { emit(response); return; }
emit({ type: "session_list",
engine: command.engine === "codex" ? "codex" : "claude",
space: command.space === "work" ? "work" : "code",
request_id: String(command.cmd_id),
sessions: command.space === "work" ? [] : [{ session_id: parentSid,
engine, space: "code", summary: "Layout parent",
- cwd: "/tmp/layout", state: "idle", last_modified: "100" },
+ cwd: "/tmp/layout", state: parentState, last_modified: "100" },
...(secondParent ? [{ session_id: "layout-other", engine,
space: "code" as const, summary: "Second parent", cwd: "/tmp/other",
state: "idle" as const, last_modified: "50" }] : [])] });
@@ -353,6 +357,61 @@ for (const running of [false, true]) {
});
}
+for (const side of [false, true]) {
+ test(`side chat scope Claude native steering keeps output and queue separate (${side ? "BTW" : "main"})`, async ({ page }) => {
+ const relay = await mockRightPanelRelay(page, {
+ engine: "claude", retained: side, visible: side, parentState: "running",
+ });
+ await page.goto("/");
+ const sid = side ? "btw-layout-child" : "layout-parent";
+ const surface = page.locator(side ? ".btw-panel" : ".composer");
+ const input = surface.locator("textarea");
+ await expect(input).toBeVisible();
+ await expect(surface.getByRole("button", { name: "引导", exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "打断并发送", exact: true })).toHaveCount(0);
+ relay.emit({ type: "user_msg", sid, msg_id: "claude-root", prompt: "检查输入" });
+ relay.emit({ type: "state", sid, state: "running", msg_id: "claude-root" });
+ relay.emit({ type: "assistant_msg_start", sid, message_id: "claude-old-output",
+ turn_id: "claude-root", channel: "commentary" });
+ relay.emit({ type: "delta", sid, message_id: "claude-old-output",
+ turn_id: "claude-root", channel: "commentary", text: "原任务输出" });
+ await input.fill("补充检查网络");
+ await input.press("Enter");
+ await expect.poll(() => relay.commands.filter(c => c.type === "steer").length).toBe(1);
+ const command = relay.commands.find(c => c.type === "steer")!;
+ expect(command.sid).toBe(sid);
+ await expect(input).toHaveValue("");
+ // Native next waits for its input boundary; old deltas still belong to the
+ // old row while command acceptance and user echo are separate events.
+ relay.emit({ type: "delta", sid, message_id: "claude-old-output",
+ turn_id: "claude-root", channel: "commentary", text: "继续完整显示。" });
+ relay.emit({ type: "assistant_msg_end", sid, message_id: "claude-old-output",
+ turn_id: "claude-root", channel: "commentary" });
+ relay.emit({ type: "turn_steered", sid, msg_id: String(command.msg_id),
+ turn_id: "claude-native-guide", prompt: String(command.prompt) });
+ relay.emit({ type: "assistant_msg_start", sid, message_id: "claude-guided-output",
+ turn_id: String(command.msg_id), channel: "final" });
+ relay.emit({ type: "delta", sid, message_id: "claude-guided-output",
+ turn_id: String(command.msg_id), channel: "final", text: "已经收到网络检查引导。" });
+ await expect(page.getByText("已经收到网络检查引导。", { exact: true })).toBeVisible();
+ if (!side) {
+ const original = page.locator('.turn[data-turn-id="claude-root"]');
+ await original.locator(".turn-process-head").click();
+ await expect(original)
+ .toContainText("原任务输出继续完整显示。");
+ await expect(page.locator(`.turn[data-turn-id="${String(command.msg_id)}"]`))
+ .not.toContainText("原任务输出");
+ }
+ await surface.getByRole("button", { name: "排队", exact: true }).click();
+ await input.fill("完成后再检查磁盘");
+ await input.press("Enter");
+ await expect.poll(() => relay.commands.filter(c => c.type === "query"
+ && c.delivery === "queue" && c.sid === sid).length).toBe(1);
+ expect(relay.commands.filter(c => c.type === "interrupt")).toHaveLength(0);
+ expect(relay.commands.filter(c => c.type === "steer")).toHaveLength(1);
+ });
+}
+
for (const browsing of [false, true]) {
test(`turn regressions App keeps painted history on post-send alias revision (${browsing ? "browsing" : "live"})`, async ({ page }, testInfo) => {
const sid = "layout-parent";
@@ -804,6 +863,92 @@ test("session workspace refreshes native context during a running turn", async (
expect(relay.commands.some(c => ["query", "steer", "new_session"].includes(String(c.type)))).toBe(false);
});
+test("session workspace Claude harness switch keeps history during a deferred private-fork catalog read", async ({ page }) => {
+ let deferClaude = false;
+ let busyResponses = 0;
+ const relay = await mockRightPanelRelay(page, {
+ retained: false, engine: "claude",
+ seedTurns: [{ id: "kept-turn", prompt: "保留原会话内容", done: true, blocks: [] }],
+ listReply: command => {
+ if (command.engine === "codex") return {
+ type: "session_list", engine: "codex", space: "code",
+ request_id: String(command.cmd_id), sessions: [{
+ session_id: "codex-parent", engine: "codex", space: "code",
+ summary: "Codex parent", cwd: "/tmp/codex", state: "idle",
+ }],
+ };
+ if (deferClaude && busyResponses++ === 0) return {
+ type: "error", code: "busy", request_id: String(command.cmd_id),
+ message: "临时 btw 会话正在初始化,请稍后刷新会话列表",
+ };
+ },
+ });
+ await page.goto("/");
+ await expect(page.getByText("保留原会话内容", { exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "切换新会话引擎" }).click();
+ await page.getByRole("menuitemradio", { name: "Codex", exact: true }).click();
+ await expect.poll(() => relay.commands.some(c => c.type === "switch_session"
+ && c.session_id === "codex-parent")).toBe(true);
+ deferClaude = true;
+ await page.getByRole("button", { name: "切换新会话引擎" }).click();
+ await page.getByRole("menuitemradio", { name: "Claude", exact: true }).click();
+ await expect.poll(() => busyResponses).toBeGreaterThanOrEqual(2);
+ await expect(page.getByText("保留原会话内容", { exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "切换新会话引擎" })).toContainText("Claude");
+ await expect(page.getByText("当前操作暂时无法执行,请稍后重试。", { exact: true })).toHaveCount(0);
+ expect(relay.commands.some(c => ["query", "steer", "interrupt", "new_session"].includes(String(c.type)))).toBe(false);
+});
+
+test("session workspace Claude context refreshes while running and keeps its last reading silently", async ({ page }) => {
+ const relay = await mockRightPanelRelay(page, {
+ retained: false, engine: "claude", parentState: "running",
+ });
+ await page.goto("/");
+ const reads = () => relay.commands.filter(c => c.type === "get_context");
+ await expect.poll(() => reads().length).toBeGreaterThan(0);
+ // Cold restored workers need an actual summary to recover their capacity.
+ expect(reads().at(-1)?.refresh).toBe(true);
+ const report: PanelRelayEvent> = {
+ type: "context_report", sid: "layout-parent", model: "claude-fable-5-1",
+ total_tokens: 242701, max_tokens: 0, percentage: 0,
+ categories: [], source: "recent_turn",
+ };
+ relay.emit({ ...report, request_id: String(reads().at(-1)!.cmd_id) });
+ const ring = page.getByRole("button", { name: "上下文占用", exact: true });
+ const before = reads().length;
+ await ring.click();
+ await expect.poll(() => reads().length).toBeGreaterThan(before);
+ const opened = reads().at(-1)!;
+ expect(opened.refresh).toBe(true);
+ const fullReport = { ...report, max_tokens: 500000,
+ raw_max_tokens: 1000000, percentage: 48.5402, source: "control" as const };
+ relay.emit({ ...fullReport, request_id: String(opened.cmd_id) });
+ const popover = page.getByRole("dialog", { name: "上下文占用", exact: true });
+ await expect(popover).toContainText("242,701 / 500,000 (49%)");
+ const count = reads().length;
+ await expect.poll(() => reads().length, { timeout: 8000 }).toBeGreaterThan(count);
+ const poll = reads().at(-1)!;
+ expect(poll.refresh).toBe(false);
+ relay.emit({ ...fullReport, request_id: String(poll.cmd_id),
+ source: "recent_turn", total_tokens: 339685, percentage: 67.937 });
+ await expect(popover).toContainText("339,685 / 500,000 (68%)");
+ const offset = await ring.locator(".hr-fill").getAttribute("stroke-dashoffset");
+ await ring.click();
+ const closedCount = reads().length;
+ await ring.click();
+ await expect.poll(() => reads().length).toBeGreaterThan(closedCount);
+ relay.emit({ ...report, request_id: String(reads().at(-1)!.cmd_id),
+ total_tokens: 0, available: false });
+ await expect(popover).toContainText("339,685 / 500,000 (68%)");
+ relay.emit({ ...report, total_tokens: 350000 });
+ await expect(popover).toContainText("339,685 / 500,000 (68%)");
+ await expect(ring.locator(".hr-fill")).toHaveAttribute("stroke-dashoffset", offset!);
+ await expect(popover.locator(".ctx-pop-status")).toHaveCount(0);
+ await expect(popover).not.toContainText("正在读取");
+ await expect(ring.locator("text")).toHaveCount(0);
+ expect(relay.commands.some(c => ["query", "steer", "interrupt", "new_session"].includes(String(c.type)))).toBe(false);
+});
+
test("turn regressions App routes file pages by exact request and opens the archived diff", async ({ page }) => {
const files = Array.from({ length: 65 }, (_, i) => ({
path: `/tmp/layout/file-${i}.py`, state: "available" as const, additions: 1, deletions: 1,
@@ -888,6 +1033,145 @@ test("policy refusal remains specific through live delivery and history reload",
}
});
+for (const [engine, delivery] of [
+ ["codex", "query"], ["codex", "steer"], ["codex", "queue"], ["claude", "query"],
+] as const) {
+ test(`side chat scope attachments send complete payloads through ${engine} ${delivery}`, async ({ page }) => {
+ const relay = await mockRightPanelRelay(page, { visible: true, engine });
+ await page.goto("/");
+ const panel = page.locator(".btw-panel");
+ await expect(panel.getByRole("textbox")).toBeEnabled();
+ await expect(panel.locator(".btw-runbar")).toBeVisible();
+ if (delivery === "query") {
+ relay.emit({ type: "state", sid: "btw-layout-child", state: "idle" });
+ await expect(panel.locator(".btw-runbar")).toHaveCount(0);
+ } else if (delivery === "queue") {
+ await panel.getByRole("button", { name: "排队", exact: true }).click();
+ }
+ await panel.getByRole("button", { name: "添加附件", exact: true }).click();
+ const chooser = page.getByRole("dialog", { name: "添加附件", exact: true });
+ await expect(chooser.getByRole("button", { name: /从相册选择图片/ })).toBeVisible();
+ await expect(chooser.getByRole("button", { name: /使用相机拍摄照片/ })).toBeVisible();
+ const picker = page.waitForEvent("filechooser");
+ await chooser.getByRole("button", { name: /添加文档、表格或其他文件/ }).click();
+ await (await picker).setFiles([
+ { name: "side.png", mimeType: "image/png", buffer: staticPng() },
+ { name: "side.txt", mimeType: "text/plain", buffer: Buffer.from("side evidence") },
+ ]);
+ await expect(panel.locator(".attach-image-preview")).toHaveCount(1);
+ await expect(panel.locator(".attach-file")).toHaveText("side.txt");
+ await expect(page.locator(".composer .attach-file, .composer .attach-image-preview")).toHaveCount(0);
+ // An attachment-only click sends payload; it must never stop the active turn.
+ await panel.getByRole("button", { name: "发送", exact: true }).click();
+ await expect.poll(() => relay.commands.filter(c => c.type === "query" || c.type === "steer").length).toBe(1);
+ const sent = relay.commands.find(c => c.type === "query" || c.type === "steer")!;
+ expect(sent.sid).toBe("btw-layout-child");
+ expect(sent.type).toBe(delivery === "steer" ? "steer" : "query");
+ expect(sent.delivery).toBe(delivery === "queue" ? "queue" : undefined);
+ expect(sent.images).toEqual([{ media_type: "image/png", data: staticPng().toString("base64") }]);
+ expect(sent.files).toEqual([{ filename: "side.txt", data: Buffer.from("side evidence").toString("base64") }]);
+ expect(relay.commands.some(c => c.type === "interrupt")).toBe(false);
+ await expect(panel.locator(".attach-image-preview, .attach-file")).toHaveCount(0);
+ });
+}
+
+test("side chat scope attachments follow the drop location and accept clipboard images", async ({ page }) => {
+ await page.setViewportSize({ width: 1400, height: 850 });
+ await mockRightPanelRelay(page, { visible: true });
+ await page.goto("/");
+ const panel = page.locator(".btw-panel");
+ await expect(panel.getByRole("textbox")).toBeEnabled();
+ const main = page.locator(".composer");
+ await main.getByRole("textbox").fill("main draft");
+ const transfer = await page.evaluateHandle(encoded => {
+ const data = new DataTransfer();
+ data.items.add(new File(["drop evidence"], "drop.txt", { type: "text/plain" }));
+ data.items.add(new File([Uint8Array.from(atob(encoded), c => c.charCodeAt(0))], "drop.png", { type: "image/png" }));
+ return data;
+ }, staticPng().toString("base64"));
+ await panel.dispatchEvent("dragover", { dataTransfer: transfer });
+ await expect(panel.locator(".drop-overlay")).toContainText("添加到侧边对话");
+ await expect(main.locator(".drop-overlay")).toHaveCount(0);
+ await panel.dispatchEvent("drop", { dataTransfer: transfer });
+ await expect(panel.locator(".attach-file")).toHaveText("drop.txt");
+ await expect(panel.locator(".attach-image-preview")).toHaveCount(1);
+ await expect(main.locator(".attach-file, .attach-image-preview")).toHaveCount(0);
+ await panel.getByRole("textbox").fill("side draft");
+ await main.dispatchEvent("dragover", { dataTransfer: transfer });
+ await expect(main.locator(".drop-overlay")).toBeVisible();
+ await expect(panel.locator(".drop-overlay")).toHaveCount(0);
+ await main.dispatchEvent("drop", { dataTransfer: transfer });
+ await expect(main.locator(".attach-file")).toHaveText("drop.txt");
+ await expect(page.locator(".drop-overlay")).toHaveCount(0);
+ await transfer.dispose();
+ await panel.getByRole("textbox").evaluate((textarea, encoded) => {
+ const data = new DataTransfer();
+ data.items.add(new File([Uint8Array.from(atob(encoded), c => c.charCodeAt(0))], "paste.png", { type: "image/png" }));
+ textarea.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }));
+ data.items.clear();
+ }, staticPng().toString("base64"));
+ await expect(panel.locator(".attach-image-preview")).toHaveCount(2);
+ await expect(main.locator(".attach-image-preview")).toHaveCount(1);
+ await expect(main.getByRole("textbox")).toHaveValue("main draft");
+ await expect(panel.getByRole("textbox")).toHaveValue("side draft");
+});
+
+test("side chat scope attachments stay with the original tab while importing", async ({ page }) => {
+ const relay = await mockRightPanelRelay(page, { visible: true });
+ await page.addInitScript(() => {
+ const original = FileReader.prototype.readAsDataURL;
+ FileReader.prototype.readAsDataURL = function(blob) {
+ Object.assign(window, { releaseSideImport: () => original.call(this, blob) });
+ };
+ });
+ await page.goto("/");
+ const panel = page.locator(".btw-panel");
+ await expect(panel.getByRole("textbox")).toBeEnabled();
+ await panel.getByLabel("添加文件", { exact: true }).setInputFiles({
+ name: "slow.txt", mimeType: "text/plain", buffer: Buffer.from("original tab"),
+ });
+ await expect.poll(() => page.evaluate(() => typeof Reflect.get(window, "releaseSideImport"))).toBe("function");
+ await panel.getByRole("textbox").fill("wait for import");
+ await panel.getByRole("textbox").press("Enter");
+ expect(relay.commands.some(c => c.type === "query" || c.type === "steer")).toBe(false);
+ relay.emit({ type: "btw_sync", generation: "layout-generation", revision: 2, sessions: [
+ { btw_sid: "btw-layout-child", parent_sid: "layout-parent", engine: "codex", created_at: 1, state: "running" },
+ { btw_sid: "btw-next-child", parent_sid: "layout-parent", engine: "codex", created_at: 2, state: "idle" },
+ ] });
+ await expect(panel.getByRole("tab")).toHaveCount(2);
+ await panel.getByRole("tab").nth(1).click();
+ await page.evaluate(() => Reflect.get(window, "releaseSideImport")());
+ // Completion of the import must write the old draft, not the selected tab.
+ await expect(panel.getByRole("button", { name: "添加附件", exact: true })).toBeEnabled();
+ await expect(panel.locator(".attach-file")).toHaveCount(0);
+ await panel.getByRole("tab").first().click();
+ await expect(panel.locator(".attach-file")).toHaveText("slow.txt");
+ await expect(panel.getByRole("textbox")).toHaveValue("wait for import");
+});
+
+test("side chat scope service tier toggles only its fork and waits for the reported value", async ({ page }) => {
+ const relay = await mockRightPanelRelay(page, { visible: true });
+ await page.goto("/");
+ const panel = page.locator(".btw-panel");
+ const control = panel.getByRole("button", { name: "BTW 服务档位", exact: true });
+ await expect(control).toBeEnabled();
+ relay.emit({ type: "fast", sid: "layout-parent", on: false });
+ relay.emit({ type: "fast", sid: "btw-layout-child", on: false });
+ await expect(control).toHaveText("标准");
+ await control.click();
+ await expect.poll(() => relay.commands.filter(c => c.type === "set_service_tier").length).toBe(1);
+ expect(relay.commands.find(c => c.type === "set_service_tier")).toMatchObject({ sid: "btw-layout-child", service_tier: "toggle" });
+ await expect(control).toHaveText("标准");
+ relay.emit({ type: "fast", sid: "btw-layout-child", on: true });
+ await expect(control).toHaveText("快速");
+ await expect(page.locator(".composer .fast-chip")).toHaveText("标准");
+ await panel.getByRole("textbox").fill("/fast");
+ await panel.getByRole("textbox").press("Enter");
+ await expect.poll(() => relay.commands.filter(c => c.type === "set_service_tier").length).toBe(2);
+ expect(relay.commands.filter(c => c.type === "set_service_tier").every(c => c.sid === "btw-layout-child")).toBe(true);
+ expect(relay.commands.some(c => c.type === "query" || c.type === "steer")).toBe(false);
+});
+
test("destroyed BTW stays readable after refresh with disabled input and a working new-chat button", async ({ page }, testInfo) => {
const relay = await mockRightPanelRelay(page, { visible: true, btwReadOnly: true });
await page.goto("/");
@@ -903,6 +1187,13 @@ test("destroyed BTW stays readable after refresh with disabled input and a worki
await expect(panel.locator(".btw-send")).toBeDisabled();
await expect(panel.locator(".btw-controls button").first()).toBeDisabled();
await expect(panel.locator(".btw-controls button").last()).toBeDisabled();
+ await expect(panel.getByRole("button", { name: "添加附件", exact: true })).toBeDisabled();
+ await panel.evaluate(node => {
+ const data = new DataTransfer();
+ data.items.add(new File(["locked"], "locked.txt", { type: "text/plain" }));
+ node.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: data }));
+ });
+ await expect(page.locator(".attach-file")).toHaveCount(0);
await expect(panel.getByRole("button", { name: "新建侧边对话" })).toBeEnabled();
await expect(panel.locator(".btw-chat-close")).toBeEnabled();
}
@@ -1016,6 +1307,222 @@ test("async question compact styling shows each question once on desktop and mob
await expect(card.getByLabel("你的回答", { exact: true })).toHaveValue("三指拖拽");
});
+test("active process text shimmers and readable tool output settles without replaying animation", async ({ page }, info) => {
+ const relay = await mockRightPanelRelay(page, { engine: "claude", historyReply: () => null });
+ await page.goto("/");
+ await expect.poll(() => relay.commands.some((c) => c.type === "get_history")).toBe(true);
+ const sid = "layout-parent";
+ relay.emit({ type: "user_msg", sid, msg_id: "shimmer-user", prompt: "检查输出显示" });
+ relay.emit({ type: "turn_binding", sid, msg_id: "shimmer-user", turn_id: "shimmer-user" });
+ relay.emit({ type: "state", sid, state: "running" });
+ relay.emit({ type: "tool_use", sid, message_id: "command-message", tool_use_id: "readable-command",
+ turn_id: "shimmer-user", tool: "Bash", category: "command", title: "运行命令",
+ input: { command: "/bin/zsh -lc 'rg -n needle src'", cwd: "/repo" } });
+ const turn = page.locator('.turn[data-turn-id="shimmer-user"]');
+ const label = turn.locator(".turn-process-label");
+ const tools = turn.locator(".tool-group-label");
+ await expect(label).toHaveClass(/is-active/);
+ await expect(tools).toHaveClass(/is-active/);
+ await expect(label).toHaveCSS("animation-name", "status-text-sweep");
+ await expect(turn.locator(".tool-group-ic, .turn-process-state")).toHaveCount(0);
+ const before = await label.evaluate((node) => getComputedStyle(node).backgroundPositionX);
+ await expect.poll(() => label.evaluate((node) => getComputedStyle(node).backgroundPositionX))
+ .not.toBe(before);
+ await turn.locator(".tool-group-h").click();
+ const card = turn.locator(".tool");
+ await expect(card.locator(".tool-arg")).toHaveText("rg -n needle src");
+ await card.locator(".tool-h").click();
+ await expect(card.locator(".tool-pre").first()).toHaveText("rg -n needle src");
+ const raw = JSON.stringify({ chunk_id: "transport-chunk", output: "src/main.ts:12: needle", exit_code: 0 });
+ relay.emit({ type: "tool_result", sid, tool_use_id: "readable-command", content: raw,
+ turn_id: "shimmer-user", is_error: false });
+ await expect(card.locator(".tool-pre").last()).toHaveText("src/main.ts:12: needle");
+ await expect(card).not.toContainText("transport-chunk");
+ await card.getByText("原始结果", { exact: true }).click();
+ await expect(card).toContainText("transport-chunk");
+ await card.getByText("原始结果", { exact: true }).click();
+ await expect(tools).not.toHaveClass(/is-active/);
+ await expect(label).toHaveClass(/is-active/);
+ await page.screenshot({ path: info.outputPath("text-shimmer-light.png") });
+ await page.evaluate(() => { document.documentElement.dataset.theme = "dark"; });
+ await page.screenshot({ path: info.outputPath("text-shimmer-dark.png") });
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await expect(label).toHaveCSS("animation-name", "none");
+ await expect(label).not.toHaveCSS("-webkit-text-fill-color", "rgba(0, 0, 0, 0)");
+ await page.emulateMedia({ reducedMotion: "no-preference" });
+ relay.emit({ type: "turn_end", sid, turn_id: "shimmer-user",
+ result: { subtype: "success", duration_ms: 5000, is_error: false } });
+ relay.emit({ type: "state", sid, state: "idle" });
+ await expect(label).toContainText("已处理");
+ await expect(turn.locator(".status-shimmer.is-active")).toHaveCount(0);
+});
+
+test("Claude resumes a separate live process after an idle background task", async ({ page }, info) => {
+ const relay = await mockRightPanelRelay(page, { engine: "claude", historyReply: () => null });
+ await page.goto("/");
+ await expect.poll(() => relay.commands.some((c) => c.type === "get_history")).toBe(true);
+ const sid = "layout-parent";
+ const turnId = "background-parent";
+ relay.emit({ type: "user_msg", sid, msg_id: turnId, prompt: "检查两个任务" });
+ relay.emit({ type: "turn_binding", sid, msg_id: turnId, turn_id: turnId });
+ relay.emit({ type: "state", sid, state: "running" });
+ relay.emit({ type: "process", sid, turn_id: turnId, item_id: "agent-a", kind: "agent",
+ phase: "start", status: "running", title: "检查结构", background: true });
+ relay.emit({ type: "assistant_msg_start", sid, turn_id: turnId, message_id: "interim", channel: "final" });
+ relay.emit({ type: "delta", sid, turn_id: turnId, message_id: "interim", channel: "final", text: "中间结果先给你,两路探索还在跑。" });
+ relay.emit({ type: "assistant_msg_end", sid, turn_id: turnId, message_id: "interim", channel: "final" });
+ relay.emit({ type: "turn_end", sid, turn_id: turnId, result: { subtype: "success", duration_ms: 3000, is_error: false } });
+ relay.emit({ type: "state", sid, state: "idle" });
+ const turn = page.locator(`.turn[data-turn-id="${turnId}"]`);
+ await expect(turn.locator(".turn-working")).toHaveCount(0);
+ await expect(turn.locator(".turn-process-head").first()).toHaveAttribute("aria-expanded", "false");
+ relay.emit({ type: "process", sid, turn_id: turnId, item_id: "agent-a", kind: "agent",
+ phase: "end", status: "succeeded", title: "检查结构", summary: "Raw agent report must stay in details", background: true });
+ await expect(turn.locator(".turn-working")).toHaveCount(0);
+ await expect(turn.locator(".background-followup")).toHaveCount(0);
+
+ relay.emit({ type: "state", sid, state: "running" });
+ relay.emit({ type: "assistant_msg_start", sid, turn_id: turnId, message_id: "follow-thinking", channel: "thinking", background: true });
+ relay.emit({ type: "delta", sid, turn_id: turnId, message_id: "follow-thinking", channel: "thinking", text: "Fixture continuation reasoning", background: true });
+ const continuation = turn.locator(".background-followup").first();
+ await expect(turn.locator(".turn-working")).toBeVisible();
+ await expect(continuation.locator(".turn-process-head")).toHaveAttribute("aria-expanded", "true");
+ await expect(continuation.locator(".turn-process-label")).toHaveClass(/is-active/);
+ await expect(continuation.locator(".process-reasoning")).toBeVisible();
+ await expect(turn.locator(".turn-process-head").first()).toHaveAttribute("aria-expanded", "false");
+ await expect(continuation).not.toContainText("Raw agent report");
+ relay.emit({ type: "assistant_msg_end", sid, turn_id: turnId, message_id: "follow-thinking", channel: "thinking", background: true });
+ relay.emit({ type: "tool_use", sid, turn_id: turnId, message_id: "follow-tool", tool_use_id: "verify", tool: "Bash", category: "command", input: { command: "verify camera" }, background: true });
+ await expect(continuation.locator(".tool-group-label")).toContainText("正在调用 1 个工具");
+ await page.screenshot({ path: info.outputPath("claude-live-continuation.png") });
+ relay.emit({ type: "tool_result", sid, turn_id: turnId, tool_use_id: "verify", content: "Verified", is_error: false, background: true });
+ relay.emit({ type: "assistant_msg_start", sid, turn_id: turnId, message_id: "follow-answer", channel: "final", background: true });
+ relay.emit({ type: "delta", sid, turn_id: turnId, message_id: "follow-answer", channel: "final", text: "第一路检查完成。", background: true });
+ relay.emit({ type: "assistant_msg_end", sid, turn_id: turnId, message_id: "follow-answer", channel: "final", background: true });
+ relay.emit({ type: "state", sid, state: "idle" });
+ await expect(turn.locator(".turn-working")).toHaveCount(0);
+ await expect(continuation.locator(".turn-process-head")).toHaveAttribute("aria-expanded", "false");
+ await expect(continuation).toContainText("第一路检查完成。");
+
+ relay.emit({ type: "state", sid, state: "running" });
+ relay.emit({ type: "assistant_msg_start", sid, turn_id: turnId, message_id: "second-thinking", channel: "thinking", background: true });
+ relay.emit({ type: "delta", sid, turn_id: turnId, message_id: "second-thinking", channel: "thinking", text: "Fixture second continuation", background: true });
+ await expect(turn.locator(".background-followup")).toHaveCount(2);
+ await expect(turn.locator(".background-followup").last().locator(".turn-process-head")).toHaveAttribute("aria-expanded", "true");
+ await expect(continuation.locator(".turn-process-head")).toHaveAttribute("aria-expanded", "false");
+ await expect(turn.locator(".turn-working")).toHaveCount(1);
+});
+
+test("Claude agent detail recovers stale revisions and follows its own live status", async ({ page }) => {
+ const relay = await mockRightPanelRelay(page, { engine: "claude" });
+ await page.goto("/");
+ await expect.poll(() => relay.commands.some((c) => c.type === "get_history")).toBe(true);
+ const sid = "layout-parent";
+ relay.emit({ type: "user_msg", sid, msg_id: "agent-parent", prompt: "运行子代理" });
+ relay.emit({ type: "process", sid, turn_id: "agent-parent", item_id: "agent-detail-run", kind: "agent",
+ phase: "start", status: "running", title: "检查相机链路", background: true });
+ await page.locator(".process-agent-card").click();
+ const panel = page.getByRole("complementary", { name: "协作代理详情" });
+ await expect(panel.locator(".agent-detail-working")).toBeVisible();
+ const requests = () => relay.commands.filter((c) => c.type === "get_agent_detail");
+ await expect.poll(() => requests().length).toBe(1);
+ const reply = (extra: Record = {}) => relay.emit({
+ type: "agent_detail", sid, session_id: sid, run_id: "agent-detail-run",
+ request_id: String(requests().at(-1)!.request_id), revision: "fresh-history",
+ detail_revision: "live-agent-1", authoritative: true, title: "检查相机链路",
+ status: "running", events: [], through_seq: 0, ...extra,
+ } as PanelRelayEvent>);
+ reply({ authoritative: false, error: "会话历史已更新,请重新打开协作代理" });
+ await expect(panel.getByRole("alert")).toContainText("会话记录已更新");
+ await expect(panel).not.toContainText("正在读取协作代理过程");
+ await panel.getByRole("button", { name: "重试", exact: true }).click();
+ await expect.poll(() => requests().length).toBe(2);
+ expect(requests().at(-1)!.revision).toBeUndefined();
+ expect(requests().at(-1)!.detail_revision).toBeUndefined();
+ reply();
+ await expect(panel.locator(".agent-detail-status")).toHaveText("运行中");
+ await expect(panel.locator(".agent-detail-working")).toBeVisible();
+ reply({ live: true, request_id: null, through_seq: 1, events: [
+ { v: PROTOCOL_VERSION, ts: 10, type: "assistant_msg_start", message_id: "child-note", channel: "commentary" },
+ { v: PROTOCOL_VERSION, ts: 10, type: "delta", message_id: "child-note", channel: "commentary", text: "子代理正在核对驱动接口" },
+ ] });
+ await expect(panel).toContainText("子代理正在核对驱动接口");
+ reply({ live: true, request_id: null, through_seq: 1, status: "succeeded", events: [] });
+ await expect(panel.locator(".agent-detail-status")).toHaveText("已完成");
+ await expect(panel.locator(".agent-detail-working")).toHaveCount(0);
+});
+
+test("Claude agent detail reads a fresh revision and refreshes active native history", async ({ page }) => {
+ await page.clock.install();
+ const relay = await mockRightPanelRelay(page, { engine: "claude" });
+ await page.goto("/");
+ await expect.poll(() => relay.commands.some((c) => c.type === "get_history")).toBe(true);
+ const sid = "layout-parent";
+ relay.emit({ type: "user_msg", sid, msg_id: "native-agent-parent", prompt: "查看原生子代理" });
+ relay.emit({ type: "process", sid, turn_id: "native-agent-parent", item_id: "native-agent", kind: "agent",
+ phase: "start", status: "running", title: "原生子代理", background: true });
+ await page.locator(".process-agent-card").click();
+ const panel = page.getByRole("complementary", { name: "协作代理详情" });
+ const requests = () => relay.commands.filter((c) => c.type === "get_agent_detail");
+ await expect.poll(() => requests().length).toBe(1);
+ expect(requests()[0].revision).toBeUndefined();
+ const reply = (status: "running" | "succeeded", detailRevision: string) => relay.emit({
+ type: "agent_detail", sid, session_id: sid, run_id: "native-agent",
+ request_id: String(requests().at(-1)!.request_id), revision: "new-native-history",
+ detail_revision: detailRevision, authoritative: true, title: "原生子代理",
+ status, events: [], through_seq: 0,
+ });
+ reply("running", "source-version-1");
+ await expect(panel.locator(".agent-detail-status")).toHaveText("运行中");
+ await expect(panel.getByRole("alert")).toHaveCount(0);
+ await page.clock.fastForward(3500);
+ await expect.poll(() => requests().length).toBe(2);
+ expect(requests().at(-1)!.detail_revision).toBeUndefined();
+ reply("succeeded", "source-version-2");
+ await expect(panel.locator(".agent-detail-status")).toHaveText("已完成");
+ await expect(panel.locator(".agent-detail-working")).toHaveCount(0);
+ await page.clock.fastForward(7000);
+ expect(requests()).toHaveLength(2);
+});
+
+for (const extension of ["mmd", "MERMAID"]) {
+ test(`Work Mermaid artifact opens a diagram and its source (${extension})`, async ({ page }) => {
+ await page.goto(`/tests/history-browser.html?artifact-mermaid=1&ext=${extension}`);
+ const sheet = page.getByRole("dialog", { name: "Artifacts", exact: true });
+ const file = sheet.getByRole("button", { name: new RegExp(`camera_navigation_pipeline\\.${extension}`) });
+ await expect(file).toBeEnabled();
+ await expect(file).toContainText("Mermaid 图表");
+ await file.click();
+ const panel = page.locator(".artifact-panel");
+ await expect(panel.locator(".mermaid-svg svg")).toBeVisible();
+ await expect(panel.locator(".mermaid-svg")).toContainText("双目相机");
+ await expect(panel.locator(".mermaid-svg")).toContainText("路径规划");
+ await panel.getByRole("button", { name: "源码", exact: true }).click();
+ await expect(panel.locator(".mermaid-svg")).toHaveCount(0);
+ await expect(panel.locator(".artifact-body")).toContainText("flowchart TD");
+ await panel.getByRole("button", { name: "预览", exact: true }).click();
+ await expect(panel.locator(".mermaid-svg svg")).toBeVisible();
+ });
+}
+
+test("Claude agent detail stops waiting on a lost response and retries", async ({ page }) => {
+ await page.clock.install();
+ const relay = await mockRightPanelRelay(page, { engine: "claude" });
+ await page.goto("/");
+ await expect.poll(() => relay.commands.some((c) => c.type === "get_history")).toBe(true);
+ relay.emit({ type: "user_msg", sid: "layout-parent", msg_id: "agent-timeout-parent", prompt: "检查" });
+ relay.emit({ type: "process", sid: "layout-parent", turn_id: "agent-timeout-parent", item_id: "timeout-agent", kind: "agent",
+ phase: "start", status: "running", title: "检查慢请求", background: true });
+ await page.locator(".process-agent-card").click();
+ const panel = page.getByRole("complementary", { name: "协作代理详情" });
+ await expect(panel).toContainText("正在读取协作代理过程");
+ await page.clock.fastForward(31_000);
+ await expect(panel.getByRole("alert")).toContainText("读取协作代理超时");
+ await expect(panel).not.toContainText("正在读取协作代理过程");
+ await panel.getByRole("button", { name: "重试", exact: true }).click();
+ await expect.poll(() => relay.commands.filter((c) => c.type === "get_agent_detail").length).toBe(2);
+});
+
test("generated image live snapshot renders outside collapsed process and duplicate events stay idempotent", async ({ page }) => {
const relay = await mockRightPanelRelay(page, { imageAssets: true });
await page.goto("/");
@@ -1670,6 +2177,64 @@ test("async question dialog retains an IME draft when control becomes read-only"
expect(relay.commands.filter(c => ["query", "steer"].includes(String(c.type)))).toHaveLength(0);
});
+for (const managed of [false, true]) {
+test(`Claude native compaction animates once and settles at the persisted boundary (${managed ? "maintenance" : "turn"})`, async ({ page }, testInfo) => {
+ const relay = await mockRightPanelRelay(page, {
+ engine: "claude", retained: false, historyReply: () => null,
+ });
+ await page.goto("/");
+ await expect.poll(() => relay.commands.some(c => c.type === "get_history")).toBe(true);
+ await page.evaluate(() => { document.documentElement.dataset.theme = "dark"; });
+ const sid = "layout-parent";
+ if (!managed) {
+ relay.emit({ type: "user_msg", sid, msg_id: "compact-request", prompt: "/compact" });
+ relay.emit({ type: "turn_binding", sid, msg_id: "compact-request", turn_id: "compact-turn" });
+ relay.emit({ type: "state", sid, state: "running" });
+ }
+ const start: PanelRelayEvent> = {
+ type: "process", sid, turn_id: managed ? undefined : "compact-turn", item_id: "compact-status",
+ kind: "compaction", phase: "start", status: "running", title: "压缩上下文",
+ };
+ relay.emit(start);
+ relay.emit(start);
+ const turn = page.locator(`[data-turn-id="${managed ? "compact-status" : "compact-request"}"]`);
+ const running = turn.locator(".process-compaction-running");
+ await expect(running).toHaveCount(1);
+ await expect(running).toBeVisible();
+ await expect(running).toHaveText("正在压缩上下文");
+ const bar = running.locator(".compact-motion i").first();
+ await page.emulateMedia({ reducedMotion: "no-preference" });
+ await expect(bar).toHaveCSS("animation-name", "compact-fold");
+ const firstTransform = await bar.evaluate(node => getComputedStyle(node).transform);
+ await expect.poll(() => bar.evaluate(node => getComputedStyle(node).transform)).not.toBe(firstTransform);
+ await expect(running).toHaveCSS("border-radius", "16px");
+ await page.screenshot({ path: testInfo.outputPath("claude-compacting-dark.png") });
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await expect(bar).toHaveCSS("animation-name", "none");
+ const end: typeof start = { ...start, item_id: "native-boundary", phase: "end",
+ status: "succeeded", summary: "手动压缩 · 600,000 → 8,000 tokens", duration_ms: 20_000,
+ input: { compaction_started_id: "compact-status" } };
+ relay.emit(end);
+ relay.emit(end);
+ if (!managed) {
+ relay.emit({ type: "turn_end", sid, turn_id: "compact-turn", result: { subtype: "success", is_error: false } });
+ relay.emit({ type: "state", sid, state: "idle" });
+ }
+ await expect(running).toHaveCount(0);
+ const head = turn.locator(".turn-process-head");
+ await expect(head).toContainText("已处理");
+ if (await head.getAttribute("aria-expanded") === "false") await head.click();
+ const completed = turn.locator(".process-activity");
+ await expect(completed).toHaveCount(1);
+ await completed.locator("summary").click();
+ await expect(completed).toContainText("手动压缩 · 600,000 → 8,000 tokens");
+ await expect(completed).not.toContainText("compaction_started_id");
+ await expect(turn.locator(".turn-working")).toHaveCount(0);
+ await page.screenshot({ path: testInfo.outputPath("claude-compacted-dark.png") });
+ expect(relay.commands.filter(c => ["query", "steer", "interrupt"].includes(String(c.type)))).toHaveLength(0);
+});
+}
+
for (const staleProcess of [false, true]) {
test(`turn regressions compaction steer clears phantom detail failure across history and reload (${staleProcess ? "foreign process" : "clock only"})`, async ({ page }, testInfo) => {
const seedTurns: NonNullable["turns"]> = [{
@@ -2109,7 +2674,7 @@ test("async question unknown steer outcome keeps the draft and prevents duplicat
const steer = relay.commands.find(c => c.type === "steer")!;
relay.emit({ type: "error", sid: "layout-parent", msg_id: String(steer.msg_id),
code: "steer_outcome_unknown", message: "private transport detail" });
- await expect(dialog.getByRole("status")).toHaveText("引导已发出,Codex 尚未确认是否生效。请先查看后续结果。");
+ await expect(dialog.getByRole("status")).toHaveText("引导已发出,尚未确认是否生效。请先查看后续结果。");
await expect(dialog.getByLabel("你的回答", { exact: true })).toHaveValue("只发一次");
await expect(dialog.getByRole("button", { name: "发送回答" })).toBeDisabled();
await dialog.getByRole("form").evaluate((form: HTMLFormElement) => form.requestSubmit());
@@ -2645,6 +3210,7 @@ test("right panel layout preserves Claude agent detail priority over side chats"
items: [{ item_id: "layout-agent", kind: "agent", status: "running",
title: "Layout child agent" }] });
const agentCard = page.getByRole("button", { name: "Layout child agent" });
+ await page.getByRole("button", { name: /后台任务,\d+ 项进行中/ }).click();
await agentCard.click();
await expect(page.locator(".agent-detail-panel")).toBeVisible();
await expect(page.locator(".btw-panel")).toHaveCount(0);
@@ -2654,6 +3220,7 @@ test("right panel layout preserves Claude agent detail priority over side chats"
await expectRightPanelSpace(page, true);
await page.getByRole("button", { name: "收起侧边对话", exact: true }).click();
await expectRightPanelSpace(page, false);
+ await page.getByRole("button", { name: /后台任务,\d+ 项进行中/ }).click();
await agentCard.click();
await expect(page.locator(".agent-detail-panel")).toBeVisible();
await expectRightPanelSpace(page, true);
@@ -2673,6 +3240,7 @@ async function coverBtwPanel(
relay.emit({ type: "background_process_sync", sid: "layout-parent",
items: [{ item_id: "layout-agent", kind: "agent", status: "running",
title: "Layout child agent" }] });
+ await page.getByRole("button", { name: /后台任务,\d+ 项进行中/ }).click();
await page.getByRole("button", { name: "Layout child agent" }).click();
await expect(page.locator(".agent-detail-panel")).toBeVisible();
} else {
@@ -5195,7 +5763,7 @@ test("session cache rejects stale Claude and replay-orphan rows", async ({
savedAt: Date.now(),
}, missingAnswerV25Sid);
tx.objectStore("sessions").put({
- v: 26,
+ v: 27,
turns: [{
id: "active-before-steer",
prompt: "first prompt",
@@ -9412,7 +9980,7 @@ test("profile session card manual unread survives refresh until explicit opening
await page.goto("/tests/history-browser.html?profile-sidebar=code");
const active = page.locator(".scard").filter({ hasText: "看看当前仓库" });
await active.getByRole("button", { name: "更多操作" }).click();
- await active.getByRole("button", { name: "标记为未读" }).click();
+ await page.locator(".card-menu").getByRole("button", { name: "标记为未读" }).click();
await expect(active).toHaveClass(/active/);
await expect(active.locator(".pill.completed")).toHaveText("未读");
await page.reload();
@@ -9460,7 +10028,7 @@ test("profile session card manual unread stays usable when storage is unavailabl
await page.goto(`/tests/history-browser.html?profile-sidebar=code&unread-storage=${mode}&machine=storage-${mode}`);
const active = page.locator(".scard").filter({ hasText: "看看当前仓库" });
await active.getByRole("button", { name: "更多操作" }).click();
- await active.getByRole("button", { name: "标记为未读" }).click();
+ await page.locator(".card-menu").getByRole("button", { name: "标记为未读" }).click();
await expect(active.locator(".pill.completed")).toHaveText("未读");
await page.evaluate(() => {
// A storage event must not crash or erase local state if access is denied;
@@ -9472,7 +10040,7 @@ test("profile session card manual unread stays usable when storage is unavailabl
await active.click();
await expect(active.locator(".pill.completed")).toHaveCount(0);
await active.getByRole("button", { name: "更多操作" }).click();
- await active.getByRole("button", { name: "标记为未读" }).click();
+ await page.locator(".card-menu").getByRole("button", { name: "标记为未读" }).click();
await expect(active.locator(".pill.completed")).toHaveText("未读");
await page.reload();
await expect(active.locator(".pill.completed")).toHaveCount(0);
@@ -9488,12 +10056,12 @@ test("profile session card manual unread still synchronizes across tabs", async
const active = page.locator(".scard").filter({ hasText: "看看当前仓库" });
const otherActive = other.locator(".scard").filter({ hasText: "看看当前仓库" });
await active.getByRole("button", { name: "更多操作" }).click();
- await active.getByRole("button", { name: "标记为未读" }).click();
+ await page.locator(".card-menu").getByRole("button", { name: "标记为未读" }).click();
await expect(otherActive.locator(".pill.completed")).toHaveText("未读");
await otherActive.click();
await expect(active.locator(".pill.completed")).toHaveCount(0);
await active.getByRole("button", { name: "更多操作" }).click();
- await active.getByRole("button", { name: "标记为未读" }).click();
+ await page.locator(".card-menu").getByRole("button", { name: "标记为未读" }).click();
await expect(otherActive.locator(".pill.completed")).toHaveText("未读");
await other.evaluate(() => localStorage.clear());
await expect(active.locator(".pill.completed")).toHaveCount(0);
@@ -9645,6 +10213,9 @@ for (const engine of ["codex", "claude"] as const) {
await page.setViewportSize({ width: 390, height: 844 });
const relay = await mockRightPanelRelay(page, { retained: false, engine });
await page.goto("/");
+ // Initial focus restores the session's composer scope. Wait for that
+ // boundary before entering the command whose keyboard layout we test.
+ await expect(page.locator(".scard.active")).toContainText("Layout parent");
const composer = page.locator(".composer textarea");
await composer.fill("/goal");
await composer.press("Enter");
@@ -9725,3 +10296,247 @@ test("Goal editor restores after a late keyboard viewport correction without ano
return editorBox.top >= bodyBox.top && editorBox.bottom <= bodyBox.bottom;
})).toBe(true);
});
+
+for (const viewport of [{ width: 390, height: 560 }, { width: 1280, height: 500 }]) {
+ test(`timed task popover stays above the footer at ${viewport.width}px without making the session busy`, async ({ page }, testInfo) => {
+ await page.setViewportSize(viewport);
+ await page.goto("/tests/history-browser.html?profile-sidebar=code&timed-tasks");
+ const card = page.locator(".scard").last();
+ const trigger = card.getByRole("button", { name: "查看定时任务" });
+ await trigger.click();
+ const panel = page.getByRole("tooltip");
+ await expect(panel).toContainText("定时任务仍在处理");
+ await expect(panel).toContainText("已发送 2/3 次");
+ await expect(card.locator(".pill.running")).toHaveCount(0);
+ await expect(card.locator(".timed-task-orbit")).toHaveCount(1);
+ const geometry = await panel.evaluate(element => {
+ const box = element.getBoundingClientRect();
+ return { top: box.top, bottom: box.bottom, left: box.left, right: box.right,
+ footer: document.querySelector(".s-foot")!.getBoundingClientRect().top };
+ });
+ expect(geometry.top).toBeGreaterThanOrEqual(8);
+ expect(geometry.left).toBeGreaterThanOrEqual(8);
+ expect(geometry.right).toBeLessThanOrEqual(viewport.width - 8);
+ expect(geometry.bottom).toBeLessThan(geometry.footer);
+ await page.screenshot({ path: testInfo.outputPath("timed-task-popover.png") });
+ await page.keyboard.press("Escape");
+ await expect(panel).toHaveCount(0);
+ await card.getByRole("button", { name: "更多操作" }).click();
+ await expect(panel).toHaveCount(0);
+ await expect(page.locator(".card-menu")).toHaveAttribute("data-placement", "above");
+ });
+}
+
+test("timed task completion and stale helper stop the orbit while message tags remain", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 720 });
+ await page.clock.install();
+ await page.goto("/tests/history-browser.html?profile-sidebar=code&timed-tasks");
+ await expect(page.locator(".timed-task-orbit")).toHaveCount(1);
+ await expect(page.locator(".timed-message-tag")).toHaveCount(1);
+ await expect(page.locator(".ubub").last().locator(".timed-message-tag")).toHaveCount(0);
+ await page.locator(".scard").last().hover();
+ await expect(page.getByRole("tooltip")).toBeVisible();
+ await page.clock.fastForward(91_000);
+ await expect(page.locator(".timed-task-orbit")).toHaveCount(0);
+ await expect(page.getByRole("tooltip")).toHaveCount(0);
+ await expect(page.locator(".timed-message-tag")).toHaveCount(1);
+ await page.reload();
+ await expect(page.locator(".timed-task-orbit")).toHaveCount(1);
+ await page.getByTestId("finish-timed-task").click();
+ await expect(page.locator(".timed-task-orbit")).toHaveCount(0);
+ await expect(page.locator(".timed-message-tag")).toHaveCount(1);
+});
+
+test("timed task reduced motion keeps a stationary outline", async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await page.goto("/tests/history-browser.html?profile-sidebar=code&timed-tasks&theme=dark");
+ const orbit = page.locator(".timed-task-orbit");
+ await expect(orbit).toHaveCount(1);
+ expect(await orbit.evaluate(element => getComputedStyle(element).animationName)).toBe("none");
+ await page.getByRole("button", { name: "查看定时任务" }).focus();
+ await expect(page.getByRole("tooltip")).toBeVisible();
+});
+
+for (const viewport of [{ width: 390, height: 560 }, { width: 1280, height: 500 }]) {
+ test(`session action menu opens upward above the footer at ${viewport.width}px`, async ({ page }, testInfo) => {
+ await page.setViewportSize(viewport);
+ await page.goto("/tests/history-browser.html?profile-sidebar=code&theme=dark");
+ const trigger = page.locator(".scard").last().getByRole("button", { name: "更多操作" });
+ await trigger.click();
+ const menu = page.locator(".card-menu");
+ await expect(menu).toHaveAttribute("data-placement", "above");
+ const geometry = await menu.evaluate(element => {
+ const box = element.getBoundingClientRect();
+ const trigger = document.querySelector('.scard-act[aria-expanded="true"]')!.getBoundingClientRect();
+ const footer = document.querySelector(".s-foot")!.getBoundingClientRect();
+ return {
+ top: box.top, bottom: box.bottom, triggerTop: trigger.top, footerTop: footer.top,
+ actionsVisible: [...element.querySelectorAll("button")].every(button => {
+ const rect = button.getBoundingClientRect();
+ return button.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2));
+ }),
+ };
+ });
+ expect(geometry.top).toBeGreaterThanOrEqual(8);
+ expect(geometry.bottom).toBeLessThan(geometry.triggerTop);
+ expect(geometry.bottom).toBeLessThan(geometry.footerTop);
+ expect(geometry.actionsVisible).toBe(true);
+ await page.screenshot({ path: testInfo.outputPath("session-menu.png") });
+ await menu.getByRole("button", { name: "归档", exact: true }).click();
+ await expect(menu).toHaveCount(0);
+ await expect(page.locator(".scard.active")).toContainText("看看当前仓库");
+ });
+}
+
+test("session action menu follows viewport changes and scrolls in a short viewport", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 900 });
+ await page.goto("/tests/history-browser.html?profile-sidebar=code");
+ const trigger = page.locator(".scard").first().getByRole("button", { name: "更多操作" });
+ await trigger.click();
+ const menu = page.locator(".card-menu");
+ await expect(menu).toHaveAttribute("data-placement", "below");
+ // iOS can change its visible region without resizing the layout viewport.
+ await page.evaluate(() => {
+ Object.defineProperties(window.visualViewport!, {
+ height: { configurable: true, value: 330 },
+ offsetTop: { configurable: true, value: 80 },
+ });
+ window.visualViewport!.dispatchEvent(new Event("resize"));
+ });
+ await expect(menu).toHaveAttribute("data-placement", "above");
+ const short = await menu.evaluate(element => ({
+ top: element.getBoundingClientRect().top,
+ bottom: element.getBoundingClientRect().bottom,
+ height: element.clientHeight, content: element.scrollHeight,
+ }));
+ expect(short.top).toBeGreaterThanOrEqual(88);
+ expect(short.bottom).toBeLessThanOrEqual(402);
+ expect(short.content).toBeGreaterThan(short.height);
+ // Reaching the last action must scroll the menu, not the page underneath it.
+ const listScroll = await page.locator(".s-scroll").evaluate(element => element.scrollTop);
+ await menu.getByRole("button", { name: "归档", exact: true }).scrollIntoViewIfNeeded();
+ expect(await menu.evaluate(element => element.scrollTop)).toBeGreaterThan(0);
+ expect(await page.locator(".s-scroll").evaluate(element => element.scrollTop)).toBe(listScroll);
+ await page.evaluate(() => {
+ Object.defineProperties(window.visualViewport!, {
+ height: { configurable: true, value: 900 },
+ offsetTop: { configurable: true, value: 0 },
+ });
+ window.visualViewport!.dispatchEvent(new Event("resize"));
+ });
+ await expect(menu).toHaveAttribute("data-placement", "below");
+ const restored = await menu.evaluate(element => ({ height: element.clientHeight, content: element.scrollHeight }));
+ expect(restored.height).toBe(restored.content);
+ await page.keyboard.press("Escape");
+ await expect(menu).toHaveCount(0);
+ await expect(trigger).toBeFocused();
+});
+
+test("session action menu long press stays above the footer and dismisses outside", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 560 });
+ await page.goto("/tests/history-browser.html?profile-sidebar=code");
+ const card = page.locator(".scard").last();
+ await card.evaluate(element => {
+ const box = element.getBoundingClientRect();
+ const event = new Event("touchstart", { bubbles: true });
+ Object.defineProperty(event, "touches", { value: [{ clientX: box.x + 10, clientY: box.y + 10 }] });
+ element.dispatchEvent(event);
+ });
+ await expect(card).toHaveClass(/lifting/);
+ await card.dispatchEvent("touchend");
+ const menu = page.locator(".card-menu");
+ await expect(menu).toHaveAttribute("data-placement", "above");
+ await menu.getByRole("button", { name: "标记为未读" }).click();
+ await expect(menu).toHaveCount(0);
+ await expect(card.locator(".pill.completed")).toHaveText("未读");
+ await card.getByRole("button", { name: "更多操作" }).click();
+ await page.getByRole("button", { name: "新会话", exact: true }).click();
+ await expect(menu).toHaveCount(0);
+});
+
+test("session action menu keeps keyboard navigation and closes when its card scrolls away", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 350 });
+ await page.goto("/tests/history-browser.html?profile-sidebar=code");
+ const trigger = page.locator(".scard").first().getByRole("button", { name: "更多操作" });
+ await trigger.focus();
+ await page.keyboard.press("Enter");
+ const menu = page.locator(".card-menu");
+ await expect(menu.getByRole("button", { name: "重命名" })).toBeFocused();
+ await page.keyboard.press("End");
+ await expect(menu.getByRole("button", { name: "归档", exact: true })).toBeFocused();
+ await page.keyboard.press("Home");
+ await expect(menu.getByRole("button", { name: "重命名" })).toBeFocused();
+ await page.keyboard.press("Escape");
+ await expect(menu).toHaveCount(0);
+ await expect(trigger).toBeFocused();
+ await trigger.click();
+ await page.locator(".s-scroll").evaluate(element => { element.scrollTop = element.scrollHeight; });
+ expect(await trigger.evaluate(element => element.getBoundingClientRect().bottom
+ <= element.closest(".s-scroll")!.getBoundingClientRect().top)).toBe(true);
+ await expect(menu).toHaveCount(0);
+});
+
+for (const engine of ["codex", "claude"]) {
+ test(`live token usage ${engine} updates and opens above the composer`, async ({ page, isMobile }, testInfo) => {
+ await page.goto(`/tests/history-browser.html?turn-usage&engine=${engine}&theme=dark`);
+ const trigger = page.getByRole("button", { name: "查看 token 用量" });
+ await expect(trigger).toContainText("↑ 184k");
+ await expect(trigger).toContainText("↓ 2.4k tokens");
+ const card = page.getByRole("dialog", { name: "Token 用量", exact: true });
+ if (!isMobile) await trigger.hover();
+ await trigger.focus();
+ await expect(card).toBeHidden();
+ await expect(trigger).toHaveAttribute("aria-expanded", "false");
+ await trigger.click();
+ await expect(card).toBeVisible();
+ await expect(card).toContainText("184,001");
+ await expect(trigger).toHaveCSS("color", await card.locator(".turn-usage-heading > span")
+ .evaluate(node => getComputedStyle(node).color));
+ await expect(card).toContainText("180,000");
+ const bounds = await card.boundingBox();
+ const composer = await page.locator(".composer").boundingBox();
+ expect(bounds!.y).toBeGreaterThanOrEqual(0);
+ expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(composer!.y + 1);
+ expect(bounds!.x).toBeGreaterThanOrEqual(0);
+ expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(page.viewportSize()!.width);
+ // The compact counter and detail show the latest native reading directly.
+ await page.getByRole("button", { name: "更新用量" }).evaluate(el => (el as HTMLButtonElement).click());
+ await expect(card).toContainText("5,820");
+ await expect(card).toContainText("182,000");
+ await expect(trigger).toContainText("↓ 5.8k tokens");
+ await page.screenshot({ path: testInfo.outputPath("token-usage.png") });
+ await page.keyboard.press("Escape");
+ await expect(card).toBeHidden();
+ await trigger.click();
+ await expect(card).toBeVisible();
+ await trigger.click();
+ await expect(card).toBeHidden();
+ await trigger.click();
+ await expect(card).toBeVisible();
+ await page.getByRole("button", { name: "结束任务" }).click();
+ await expect(trigger).toBeHidden();
+ await expect(card).toBeHidden();
+ });
+}
+
+test("live token usage updates immediately and supports explicit keyboard activation", async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: "no-preference" });
+ await page.clock.install({ time: new Date("2026-09-16T10:00:00Z") });
+ await page.goto("/tests/history-browser.html?turn-usage&engine=claude");
+ const trigger = page.getByRole("button", { name: "查看 token 用量" });
+ const card = page.getByRole("dialog", { name: "Token 用量", exact: true });
+ await expect(trigger).toContainText("↑ 184k");
+ await expect(trigger).toContainText("↓ 2.4k tokens");
+ await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000));
+ await trigger.focus();
+ await expect(card).toBeHidden();
+ await trigger.press("Enter");
+ await expect(card).toBeVisible();
+ await trigger.press("Space");
+ await expect(card).toBeHidden();
+ await trigger.press("Enter");
+ await page.getByRole("button", { name: "更新用量" }).evaluate(el => (el as HTMLButtonElement).click());
+ await expect(trigger).toContainText("↓ 5.8k tokens");
+ await page.locator(".composer").dispatchEvent("pointerdown");
+ await expect(card).toBeHidden();
+});
diff --git a/web/tests/history-live-order.test.ts b/web/tests/history-live-order.test.ts
index d299290b..a92942cd 100644
--- a/web/tests/history-live-order.test.ts
+++ b/web/tests/history-live-order.test.ts
@@ -1758,7 +1758,7 @@ try {
activeTurnId: compactToolGapTurn.id,
onEdit: () => {}, onGetDiff: () => {}, onFork: () => {},
}));
- assert.match(compactToolGapMarkup, /turn-process-state running/,
+ assert.match(compactToolGapMarkup, /turn-process-label running status-shimmer is-active/,
"an exact active task keeps its process disclosure running across a compact tool gap");
assert.match(compactToolGapMarkup,
/class="turn-working"[\s\S]*处理中/,
@@ -1781,7 +1781,7 @@ try {
}));
assert.doesNotMatch(unrelatedOwnerMarkup, /class="turn-working"/,
"session activity cannot leak onto a turn which does not own it");
- assert.match(unrelatedOwnerMarkup, /turn-process-state done/,
+ assert.match(unrelatedOwnerMarkup, /turn-process-label done status-shimmer/,
"removing the exact owner settles the process exactly once");
assert.match(unrelatedOwnerMarkup, /class="ubub-meta ai-meta"/,
"the real terminal reveals completion metadata");
diff --git a/web/tests/history-page-cache.test.ts b/web/tests/history-page-cache.test.ts
index 6f35aaf8..301070a3 100644
--- a/web/tests/history-page-cache.test.ts
+++ b/web/tests/history-page-cache.test.ts
@@ -443,6 +443,15 @@ assert.deepEqual((await reopenedAsyncCache.getPage(scope, staleAsyncPage.pageKey
["Question?", "Finished."]);
const claudeScope = { ...scope, engine: "claude" };
+const recoveryStorage = new MemoryStorage();
+const recoveryCache = new HistoryPageCache({ storage: recoveryStorage });
+const recoveryPage = { pageKey: "internal-recovery", turns: [turn("recovery")],
+ hasOlder: false, olderCursor: "recovery" };
+assert.equal((await recoveryCache.putPage(claudeScope, recoveryPage)).ok, true);
+const recoveryKey = recoveryCache.pageKey(claudeScope, recoveryPage.pageKey);
+(recoveryStorage.records.get(recoveryKey) as { version: number }).version = 9;
+assert.equal(await recoveryCache.getPage(claudeScope, recoveryPage.pageKey), null,
+ "cached native recovery prompts must not return as human turns after upgrade");
const legacyClaudeStorage = new MemoryStorage();
const legacyClaudeCache = new HistoryPageCache({ storage: legacyClaudeStorage });
assert.equal((await legacyClaudeCache.putPage(claudeScope, {
diff --git a/web/tests/notices-rate-limits.test.ts b/web/tests/notices-rate-limits.test.ts
index c73b8df3..8a311aaa 100644
--- a/web/tests/notices-rate-limits.test.ts
+++ b/web/tests/notices-rate-limits.test.ts
@@ -31,7 +31,7 @@ try {
accountQuotaWindows, activeRateLimits, quotaTone, quotaWindowLabel,
quotaWindowsForLimits, remainingPercent,
} = await harness.ssrLoadModule("/src/rate-limit-usage.ts");
- const { statusNotices } = await harness.ssrLoadModule(
+ const { conversationNotices, statusNotices } = await harness.ssrLoadModule(
"/src/notice-presentation.ts");
const {
ReconnectBanner, TRANSIENT_BANNER_TTL_MS,
@@ -43,6 +43,42 @@ try {
presentTurnOutcome,
} = await harness.ssrLoadModule("/src/problem-presentation.ts");
const { mergeInitialHistory, restoreCachedTurnDetails } = await harness.ssrLoadModule("/src/history-merge.ts");
+ const compactNotice = {
+ type: "notice", v: 1, ts: 1, notice_id: "compact-completed", severity: "info",
+ category: "runtime", title: "上下文压缩完成", message: "native compact boundary",
+ } as Notice;
+ assert.equal(conversationNotices([compactNotice])[0].title, "上下文压缩完成");
+ assert.equal(conversationNotices([{ ...compactNotice, title: "上下文压缩已启动" }])[0].title,
+ "上下文压缩已启动", "native Codex submission is not a completion receipt");
+ const { default: ContextPopover } = await harness.ssrLoadModule("/src/components/ContextPopover.tsx");
+ const contextHtml = renderToStaticMarkup(createElement(ContextPopover, {
+ report: { total_tokens: 54_459, max_tokens: 1_000_000, percentage: 5.4,
+ categories: [], model: "claude-fable-5-1[1m]" },
+ autoCompact: { mode: "custom", threshold_tokens: 500_000, applied_mode: "custom",
+ applied_threshold_tokens: 500_000, pending: false },
+ }));
+ assert.ok(contextHtml.includes("1,000,000"));
+ assert.ok(contextHtml.includes("生效压缩阈值") && contextHtml.includes("500,000"));
+ const pendingHtml = renderToStaticMarkup(createElement(ContextPopover, {
+ report: null, autoCompact: { mode: "custom", threshold_tokens: 500_000,
+ applied_mode: "inherit", applied_threshold_tokens: null, pending: true },
+ }));
+ assert.ok(pendingHtml.includes("等待生效") && pendingHtml.includes("Claude 默认值"));
+ const repairedHistory = reduce({
+ ...initialState, focusedSid: "compact-session",
+ runtimes: { "compact-session": { ...createRuntime(),
+ historyRevision: "previous-wrapper", hydratedCacheTurnIds: ["internal-caveat"],
+ turns: [{ id: "internal-caveat", prompt: "internal",
+ blocks: [], done: true, error: "该轮未正常结束" }],
+ } },
+ }, { type: "event", event: {
+ v: 1, ts: 12, type: "history", sid: "compact-session", session_id: "compact-session",
+ events: [], detail: "summary", revision: "repaired-wrapper", has_more: false,
+ in_progress: false, turns: [{ id: "real-user", prompt: "inspect", blocks: [],
+ done: true, doneTs: 5, durationMs: 4_000 }],
+ } });
+ assert.deepEqual(repairedHistory.runtimes["compact-session"].turns.map((turn: { id: string }) => turn.id),
+ ["real-user"], "canonical compact history removes the old cached disclaimer/error turn");
const sid = "notice-session";
const event = (body: Record): ServerEvent => ({
v: 10, ts: 10, sid, ...body,
@@ -456,7 +492,7 @@ try {
assert.equal(presentTurnOutcome("interrupted", "Codex updated; private diagnostic"), "已打断");
assert.equal(presentTurnOutcome("failed", hiddenDiagnostic), "回复未完成");
assert.equal(presentCommandProblem({ code: "steer_outcome_unknown", message: hiddenDiagnostic }),
- "引导已发出,Codex 尚未确认是否生效。请先查看后续结果。");
+ "引导已发出,尚未确认是否生效。请先查看后续结果。");
assert.equal(presentCommandProblem({ code: "not_steerable", message: "当前没有可引导的 Codex 任务" }),
"当前没有可引导的任务,本次未发送。请在会话空闲后重试。");
assert.equal(presentCommandProblem({ code: "not_steerable", message: "Codex 任务已结束,本次引导未发送。" }),
diff --git a/web/tests/reliability.test.ts b/web/tests/reliability.test.ts
index 31a93c62..9334f593 100644
--- a/web/tests/reliability.test.ts
+++ b/web/tests/reliability.test.ts
@@ -1119,8 +1119,8 @@ assert.equal(classifyBusySubmit("running", "steer", "codex", true), "steer",
"the default Codex busy submit appends input to its active native turn");
assert.equal(
classifyBusySubmit("running", "steer", "claude", true),
- "interrupt-and-replace",
- "Claude retains interrupt-and-replace because it cannot steer an active turn",
+ "steer",
+ "Claude submits native non-interrupting input while its task continues",
);
assert.equal(classifyBusySubmit(
"interrupting", "steer", "codex", true), "replace",
@@ -1387,8 +1387,8 @@ assert.match(historyAppSource,
assert.match(historyAppSource,
/requestHistoryTurnDetail = useCallback\([\s\S]{0,120}autoLoad = false/,
"every detail entry point must default to one bounded page");
-assert.match(cacheSource, /const CACHE_VER = 26/,
- "async-question repair must invalidate browser summaries missing unphased replies");
+assert.match(cacheSource, /const CACHE_VER = 27/,
+ "native recovery repair must invalidate browser summaries split by internal prompts");
assert.match(cacheSource, /objectStore\(STORE\)\.delete\(sessionId\)/);
assert.match(cacheSource, /job\.epoch !== sessionEpoch\(job\.sid\)/,
"a debounced pre-marker write must not recreate the deleted cache row");
@@ -1591,7 +1591,7 @@ assert.match(layoutCss,
/\.scard-profile-ribbon\s*\{[^}]*top\s*:\s*-6px[^}]*height\s*:\s*16px[^}]*max-width\s*:\s*64px[^}]*font-family\s*:\s*var\(--mono\)[^}]*font-size\s*:\s*8\.5px/s,
"profile keycaps must hang compactly from the card edge");
assert.match(layoutCss,
- /\.work-profile-owner\s*\{[^}]*max-width\s*:\s*72px[^}]*height\s*:\s*19px[^}]*font\s*:\s*650 9px\/1 var\(--mono\)/s,
+ /\.work-profile-owner\s*\{[^}]*max-width\s*:\s*72px[^}]*height\s*:\s*19px[^}]*font\s*:\s*var\(--font-weight-650\) 9px\/1 var\(--mono\)/s,
"a multi-account Work owner stays compact in the shared header");
assert.match(layoutCss,
/@media \(max-width:980px\)\{\s*\.artifact-panel\{[^}]*top:calc\(var\(--app-offset-top,0px\) \+ 10px\)[^}]*bottom:auto[^}]*height:calc\(var\(--app-height,100dvh\) - 20px\)[^}]*max-height:none/s,
@@ -11246,6 +11246,41 @@ try {
["only once"]);
assert.deepEqual(state.runtimes[otherSid].turns, [untouched]);
+ function testClaudeRecoveredText(): void {
+ // A separate Claude worker can replay a complete prefix after the browser
+ // already painted part of it. Repeated recovery replaces that exact message;
+ // subsequent native deltas still append, and completed history stays final.
+ for (const channel of ["commentary", "final"] as const) {
+ const recoveredSid = `claude-recovered-${channel}`;
+ let recoveredState = {
+ ...initialState,
+ engine: "claude" as const,
+ focusedSid: recoveredSid,
+ runtimes: { [recoveredSid]: createRuntime() },
+ };
+ const recovery = {
+ type: "delta", sid: recoveredSid, message_id: "recovered-answer",
+ channel, text: "first recovered", replace: true,
+ };
+ for (const body of [
+ { type: "user_msg", sid: recoveredSid, msg_id: "recovered-user", prompt: "continue" },
+ { type: "assistant_msg_start", sid: recoveredSid, message_id: "recovered-answer", channel },
+ { ...recovery, text: "first ", replace: false },
+ recovery,
+ recovery,
+ { ...recovery, text: " tail", replace: false },
+ { type: "assistant_msg_end", sid: recoveredSid, message_id: "recovered-answer", channel },
+ { ...recovery, text: "delayed stale prefix" },
+ ]) recoveredState = reduce(recoveredState, { type: "event", event: event(body) });
+ assert.equal(recoveredState.runtimes[recoveredSid].turns.length, 1);
+ assert.deepEqual(recoveredState.runtimes[recoveredSid].turns[0].blocks.map(
+ (block: { text?: string; done: boolean }) => [block.text, block.done]),
+ [["first recovered tail", true]],
+ "recovery replaces partial text once and cannot overwrite a completed exact message");
+ }
+ }
+ testClaudeRecoveredText();
+
// App-server 0.147 can report an interrupted summary for the exact native
// turn which is still appending after context compaction. A newest
// authoritative History page says which visible row owns that active head;
@@ -15418,7 +15453,7 @@ try {
}));
assert.match(answeringAfterProcessMarkup, /class="turn-process open"/,
"the process disclosure stays open until the turn terminal boundary");
- assert.match(answeringAfterProcessMarkup, /turn-process-state done/,
+ assert.match(answeringAfterProcessMarkup, /turn-process-label done status-shimmer/,
"a settled process shell cannot keep spinning while the answer streams");
assert.match(answeringAfterProcessMarkup, /class="turn-working"[\s\S]*回答中/);
assert.equal(
@@ -15444,7 +15479,7 @@ try {
onEdit: () => {}, onGetDiff: () => {},
},
));
- assert.match(commentaryWhileAnsweringMarkup, /turn-process-state running/);
+ assert.match(commentaryWhileAnsweringMarkup, /turn-process-label running status-shimmer is-active/);
assert.match(
commentaryWhileAnsweringMarkup,
/class="turn-working"[\s\S]*处理中/,
@@ -16413,7 +16448,7 @@ try {
"detached Agent work cannot reopen the completed turn's top-level spark");
assert.match(
backgroundMarkup,
- /turn-process-state done">