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} -