diff --git a/cc_remote/claude_service/client.py b/cc_remote/claude_service/client.py
index fcbfc63..f6909ce 100644
--- a/cc_remote/claude_service/client.py
+++ b/cc_remote/claude_service/client.py
@@ -23,6 +23,24 @@
"claude_service_callback", default=None)
+def service_owner_exited(identity) -> bool:
+ """Prove an exact local owner exited; an unreadable PID is not proof."""
+ if identity is None:
+ return False
+ from cc_remote.wrapper.process_scan import process_identity
+
+ current = process_identity(identity.pid)
+ if current is not None:
+ return current != identity
+ try:
+ os.kill(identity.pid, 0)
+ except ProcessLookupError:
+ return True
+ except OSError:
+ pass
+ return False
+
+
class Connection:
def __init__(self, socket_path: str):
self.socket_path = os.path.expanduser(socket_path)
@@ -101,7 +119,8 @@ def options_payload(options) -> dict:
class RemoteClient:
- def __init__(self, socket_path, *, options, metadata, isolated=False):
+ def __init__(self, socket_path, *, options, metadata, isolated=False,
+ previous_owner_identity=None):
self.connection = Connection(socket_path)
self.options = options
self.metadata = metadata
@@ -119,6 +138,7 @@ def __init__(self, socket_path, *, options, metadata, isolated=False):
self.last_seq = 0
self.ready = asyncio.Event()
self.owner_identity = None
+ self.previous_owner_identity = previous_owner_identity
async def connect(self) -> None:
await self.connection.connect()
@@ -136,12 +156,32 @@ async def connect(self) -> None:
claude_sdk_process_env(self.options.env, environment)
if self.isolated else {**environment, **self.options.env}
)
+ worker_id = self.metadata.get("service_id")
+ from cc_remote.wrapper.process_scan import process_identity
+
+ owner = process_identity(hello["pid"])
+ if (worker_id and self.options.resume and not self.options.fork_session
+ and owner is not None and owner != self.previous_owner_identity
+ and service_owner_exited(self.previous_owner_identity)):
+ sessions = await self.connection.call("list")
+ if not any(item["id"] == worker_id or all(
+ item["metadata"].get(key) == self.metadata.get(key)
+ for key in ("profile_root", "session_id", "space", "work_id", "btw")
+ ) for item in sessions):
+ # A restarted service has no old in-memory worker. Resume
+ # the same native transcript using its full account/cwd
+ # identity, without submitting any prior accepted input.
+ # Keep the hint until open succeeds so a failed connection
+ # does not erase recovery authority. A replacement worker
+ # already owning this transcript needs ordinary replay
+ # recovery, not this idle control/new-input reconnect.
+ worker_id = None
self.description = await self._open({
"options": payload,
"metadata": self.metadata,
"isolated": self.isolated,
"fork": self.options.fork_session,
- "session": self.metadata.get("service_id"),
+ "session": worker_id,
"strict_session": bool(hello.get("strict_controller_leases")),
}, legacy=not hello.get("strict_controller_leases"))
except BaseException:
diff --git a/cc_remote/claude_service/server.py b/cc_remote/claude_service/server.py
index eabab75..ff67406 100644
--- a/cc_remote/claude_service/server.py
+++ b/cc_remote/claude_service/server.py
@@ -19,7 +19,9 @@
from pathlib import Path
from uuid import uuid4
-from cc_remote.claude_steering import PendingSteers, background_end_ids, is_managed_input, steer_message
+from cc_remote.claude_steering import (
+ PendingSteers, background_end_ids, is_human_result, is_managed_input, steer_message,
+)
from .wire import (
ControllerLeaseConflict, decode_sdk, encode_sdk, private_directory,
@@ -79,9 +81,7 @@ def close(self) -> None:
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")
+ return is_human_result(data)
class Session:
@@ -282,6 +282,7 @@ def description(self) -> dict:
"native_steering": True,
"background_steering": True,
"background_activity_steering": True,
+ "human_result_receipts": True,
"pending_steers": {uid: {"id": data["id"]} for uid, data in self.steers.pending.items()},
}
@@ -407,6 +408,7 @@ async def stream():
self.turn = {**params["turn"], "start_seq": self.journal.seq, "started_at": time.time(),
"previous_origin_id": self.origin_id}
self.managed_input_seen = False
+ self.steers.begin_turn()
self.pending_compact = (isinstance(prompt, str)
and prompt.split(maxsplit=1)[:1] == ["/compact"])
self.terminal_seq = None
diff --git a/cc_remote/claude_steering.py b/cc_remote/claude_steering.py
index 423f9e3..8f18c16 100644
--- a/cc_remote/claude_steering.py
+++ b/cc_remote/claude_steering.py
@@ -68,6 +68,17 @@ def is_managed_input(value: dict, *, pending_compact: bool = False) -> bool:
return False
+def is_human_result(value: dict) -> bool:
+ """Use an exact consumption receipt before falling back to legacy origin."""
+ if (value.get("type") != "result" or value.get("parent_tool_use_id")
+ or value.get("parentToolUseID")):
+ return False
+ if "__cc_managed_result" in value:
+ return value["__cc_managed_result"] is True
+ origin = value.get("origin")
+ return not isinstance(origin, dict) or origin.get("kind") in (None, "human")
+
+
class PendingSteers:
"""Fence accepted inputs against their exact replayed human UUIDs.
@@ -83,6 +94,10 @@ def __init__(self):
self.background_origin: str | None = None
self.background_origin_data: dict | None = None
self._backgrounds: dict[str, dict] = {}
+ self._consumed_user_id: str | None = None
+
+ def begin_turn(self) -> None:
+ self._consumed_user_id = None
def handoff_background(self, identity: str | None) -> None:
self._backgrounds.pop(identity, None)
@@ -96,10 +111,31 @@ def add(self, native_id: str, metadata: dict) -> None:
raise ClaudeSteerRejected("Claude steering capacity reached")
self.pending[native_id] = metadata
- def annotate(self, value: dict, *, managed_active: bool = False) -> dict:
+ def annotate(self, value: dict, *, managed_active: bool = False,
+ result_receipts: bool = True) -> dict:
origin = value.get("origin")
kind = origin.get("kind") if isinstance(origin, dict) else None
child = value.get("parent_tool_use_id") or value.get("parentToolUseID")
+ uid = value.get("uuid")
+ if (is_managed_input(value) and value.get("type") == "user"
+ and isinstance(uid, str) and uid
+ and (managed_active or uid in self.pending or value.get("__cc_steer"))):
+ self._consumed_user_id = uid
+ if value.get("type") == "result" and not child:
+ receipts = value.get("user_message_uuids")
+ receipts = receipts if isinstance(receipts, list) else []
+ receipts = [uid for uid in receipts if isinstance(uid, str) and uid]
+ receipt = value.get("user_message_uuid")
+ if isinstance(receipt, str) and receipt:
+ receipts.append(receipt)
+ if receipts and result_receipts and "__cc_managed_result" not in value:
+ # Task notifications can be absorbed into the human response.
+ # The Result must consume the latest echoed input, not merely
+ # an older input in the same session. Preserve this decision in
+ # the journal: the pinned SDK parser drops the receipt fields.
+ value = {**value, "__cc_managed_result":
+ self._consumed_user_id is not None
+ and self._consumed_user_id in receipts}
if not child:
if managed_active and is_managed_input(value, pending_compact=True):
# A native request can precede its replayed human input. That
@@ -150,7 +186,7 @@ def annotate(self, value: dict, *, managed_active: bool = False) -> dict:
elif value.get("type") == "result":
ended = background_end_ids(value)
local_ends = ()
- if kind in (None, "human"):
+ if is_human_result(value):
# An unattributed Result closes the physical response,
# including its in-turn task inputs. An older autonomous
# response must not be consumed by a new human terminal.
@@ -159,7 +195,7 @@ def annotate(self, value: dict, *, managed_active: bool = False) -> dict:
if entry["managed"])
elif kind is None:
local_ends = tuple(self._backgrounds)
- else:
+ elif isinstance(origin, dict) and kind not in (None, "human"):
# A precise unrelated origin remains authoritative.
local_ends = tuple(key for key, entry in self._backgrounds.items()
if entry["origin_key"] in (None, _origin_key(origin)))
@@ -184,14 +220,16 @@ def annotate(self, value: dict, *, managed_active: bool = False) -> dict:
if metadata is not None:
return {**value, "type": "system", "subtype": "cc_remote_steer_cancelled",
"__cc_steer_cancelled": metadata}
+ if value.get("type") == "result" and is_human_result(value):
+ self._consumed_user_id = None
+ if self.pending:
+ return {**value, "__cc_steer_intermediate": True}
if kind not in (None, "human") or child:
return value
if value.get("type") == "user":
metadata = self.pending.pop(value.get("uuid"), None)
if metadata is not None:
return {**value, "__cc_steer": value.get("__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:
diff --git a/cc_remote/wrapper/btw_history.py b/cc_remote/wrapper/btw_history.py
new file mode 100644
index 0000000..6a79654
--- /dev/null
+++ b/cc_remote/wrapper/btw_history.py
@@ -0,0 +1,350 @@
+"""Bounded, process-local presentation snapshots for ephemeral side chats.
+
+Native Codex ephemeral threads cannot read persisted turns. Keep public wire
+items, rather than token frames, until the side chat closes. Replay cuts only
+between items and always carries the exact human/native owner boundary.
+"""
+from __future__ import annotations
+
+from collections import OrderedDict
+from dataclasses import dataclass, field
+
+from cc_remote.protocol import (
+ AssistantMsgEnd, AssistantMsgStart, Delta, Error, ProcessEvent, ReplayEnd,
+ ReplayStart, ToolDelta, ToolResult, ToolUse, TurnBinding, TurnEnd,
+ TurnDiff, TurnFileChanges, TurnPlan, TurnSteered, UserMsg,
+)
+from cc_remote.wrapper.ringbuffer import RingBuffer
+
+
+def item_key(event) -> str | None:
+ if isinstance(event, (AssistantMsgStart, Delta, AssistantMsgEnd)):
+ return "message:" + event.message_id
+ if isinstance(event, (ToolUse, ToolDelta, ToolResult)):
+ return "tool:" + event.tool_use_id
+ if isinstance(event, (ProcessEvent, TurnPlan, TurnDiff)):
+ return "process:" + event.item_id
+ if isinstance(event, TurnFileChanges):
+ return "changes:" + event.turn_id
+ if isinstance(event, Error) and event.msg_id:
+ return "error:" + event.msg_id
+ return None
+
+
+@dataclass
+class _Item:
+ events: OrderedDict[str, object] = field(default_factory=OrderedDict)
+ size: int = 0
+ sealed: bool = False
+
+ def append(self, event) -> None:
+ if ((isinstance(event, Delta) and "assistant_msg_end" in self.events)
+ or (isinstance(event, ToolDelta) and "tool_result" in self.events)):
+ return
+ if (self.sealed and isinstance(event, ProcessEvent)
+ and event.phase in {"start", "update"}
+ and event.status in {"unknown", "pending", "running"}):
+ return
+ key = event.type
+ if isinstance(event, ToolDelta):
+ key += ":" + event.stream
+ if isinstance(event, Delta) and event.replace:
+ for stored in list(self.events):
+ if stored == key or stored.startswith(key + ":"):
+ self.size -= RingBuffer._size(self.events.pop(stored))
+ if isinstance(event, (Delta, ToolDelta)):
+ key = next((stored for stored in reversed(self.events)
+ if stored == key or stored.startswith(key + ":")), key)
+ previous = self.events.get(key)
+ if isinstance(event, Delta) and previous is not None and not event.replace:
+ # Coalesce small chunks, not the entire message on every token.
+ # This bounds copy/JSON sizing work on the live reader path.
+ if len(previous.text) + len(event.text) <= 32 * 1024:
+ event = event.model_copy(update={
+ "text": previous.text + event.text,
+ "replace": previous.replace,
+ "channel": previous.channel if event.channel == "unknown" else event.channel,
+ })
+ else:
+ key += ":" + str(len(self.events))
+ previous = None
+ elif isinstance(event, ToolDelta) and previous is not None:
+ if len(previous.delta) + len(event.delta) <= 32 * 1024:
+ event = event.model_copy(update={"delta": previous.delta + event.delta})
+ else:
+ key += ":" + str(len(self.events))
+ previous = None
+ elif isinstance(event, ProcessEvent):
+ if previous is not None:
+ event = previous.model_copy(update=event.model_dump(exclude_none=True))
+ if event.append_to and event.delta:
+ field = event.append_to
+ limit = {"summary": 65536, "detail": 262144,
+ "output": 2097152, "diff": 2097152, "progress": 65536}[field]
+ value = (getattr(event, field) or "") + event.delta
+ event = event.model_copy(update={
+ field: value[-limit:], "append_to": None, "delta": None,
+ "truncated": event.truncated or len(value) > limit,
+ })
+ self.events[key] = event
+ self.size += RingBuffer._size(event) - (
+ RingBuffer._size(previous) if previous is not None else 0)
+
+
+@dataclass
+class _Turn:
+ user: UserMsg | TurnSteered
+ binding: TurnBinding | None = None
+ items: OrderedDict[str, _Item] = field(default_factory=OrderedDict)
+ end: TurnEnd | None = None
+
+ def envelope(self) -> tuple[list, list]:
+ return ([self.user, *([self.binding] if self.binding else [])],
+ [self.end] if self.end else [])
+
+
+class BtwHistory:
+ def __init__(self, max_bytes: int, max_items: int):
+ self.max_bytes = max_bytes
+ self.max_items = max_items
+ self.turns: OrderedDict[str, _Turn] = OrderedDict()
+ self.truncated = False
+ self._bytes = 0
+ self._items = 0
+ self._owners: dict[str, str] = {}
+ self._omitted: OrderedDict[str, None] = OrderedDict()
+
+ def observe(self, event) -> None:
+ if isinstance(event, (UserMsg, TurnSteered)):
+ if event.msg_id not in self.turns:
+ self.turns[event.msg_id] = _Turn(event)
+ self._bytes += RingBuffer._size(event)
+ elif isinstance(event, TurnBinding):
+ turn = self.turns.get(event.msg_id)
+ if turn is not None:
+ self._bytes -= RingBuffer._size(turn.binding) if turn.binding else 0
+ turn.binding = event
+ self._bytes += RingBuffer._size(event)
+ elif self.turns:
+ key = item_key(event)
+ if key in self._omitted:
+ # A later suffix of an evicted item is not a whole message.
+ # Only an exact completed snapshot may restore it below.
+ return
+ owner = self._owners.get(key) if key else None
+ parent = getattr(event, "parent_id", None)
+ if owner is None and parent:
+ owner = next((self._owners[prefix + parent]
+ for prefix in ("tool:", "process:", "message:")
+ if prefix + parent in self._owners), None)
+ turn = self.turns.get(owner) if owner else next(reversed(self.turns.values()))
+ explicit = event.msg_id if isinstance(event, Error) else getattr(event, "turn_id", None)
+ if explicit and owner is None:
+ matches = [row for row in self.turns.values()
+ if explicit in {row.user.msg_id,
+ getattr(row.user, "turn_id", None),
+ row.binding.turn_id if row.binding else None}]
+ turn = matches[-1] if matches else None
+ if turn is not None:
+ if isinstance(event, TurnEnd):
+ self._bytes -= RingBuffer._size(turn.end) if turn.end else 0
+ turn.end = event
+ self._bytes += RingBuffer._size(event)
+ elif key:
+ group = turn.items.setdefault(key, _Item())
+ if key not in self._owners:
+ self._owners[key] = turn.user.msg_id
+ self._items += 1
+ old_size = group.size
+ group.append(event)
+ self._bytes += group.size - old_size
+ self._trim()
+
+ def _trim(self) -> None:
+ while self.turns and (self._bytes > self.max_bytes
+ or self._items > self.max_items
+ or len(self.turns) > 64):
+ turn_id, turn = next(iter(self.turns.items()))
+ self.truncated = True
+ if len(self.turns) > 1 or not turn.items:
+ self.turns.pop(turn_id)
+ for event in sum(turn.envelope(), []):
+ self._bytes -= RingBuffer._size(event)
+ groups = list(turn.items.items())
+ else:
+ groups = [turn.items.popitem(last=False)]
+ for key, group in groups:
+ self._bytes -= group.size
+ self._items -= 1
+ self._owners.pop(key, None)
+ self._omitted[key] = None
+ while len(self._omitted) > 10000:
+ self._omitted.popitem(last=False)
+
+ def repair(self, native_turn_id: str, snapshots: list[tuple[str | None, list]],
+ *, item_order: list[str] | None = None) -> None:
+ """Merge complete native items only into a proven visible segment."""
+ rows = [row for row in self.turns.values()
+ if native_turn_id in {
+ row.binding.turn_id if row.binding else None,
+ getattr(row.user, "turn_id", None)}]
+ ordered: dict[str, list[str]] = {}
+ original = {owner: list(row.items) for owner, row in self.turns.items()}
+ for client_id, events in snapshots:
+ if not events:
+ continue
+ key = item_key(events[0])
+ owner = self._owners.get(key)
+ turn = self.turns.get(owner or client_id)
+ if turn is None and client_id is None and len(rows) == 1:
+ turn = rows[0]
+ if turn is None or not any(turn is row for row in rows) or not key:
+ continue
+ group = _Item()
+ for event in events:
+ group.append(event)
+ group.sealed = True
+ previous = turn.items.get(key)
+ if previous is None:
+ self._items += 1
+ self._bytes += group.size - (previous.size if previous else 0)
+ turn.items[key] = group
+ self._owners[key] = turn.user.msg_id
+ self._omitted.pop(key, None)
+ ordered.setdefault(turn.user.msg_id, []).append(key)
+ for owner, keys in ordered.items():
+ turn = self.turns[owner]
+ if item_order is not None:
+ by_native_id = {key.split(":", 1)[1]: key for key in turn.items}
+ keys = [by_native_id[native_id] for native_id in item_order
+ if native_id in by_native_id]
+ # Insert missing completed items before the next known native item.
+ # A bounded cache can omit an older prefix still in the live row;
+ # never move that prefix behind the retained cache suffix.
+ order = original[owner]
+ pending = []
+ for key in dict.fromkeys(keys):
+ if key in order:
+ at = order.index(key)
+ order[at:at] = pending
+ pending = []
+ else:
+ pending.append(key)
+ order.extend(pending)
+ # A translator may normalize a malformed native id. Its complete
+ # public snapshot still belongs here even without an order anchor.
+ order.extend(key for key in turn.items if key not in order)
+ turn.items = OrderedDict((key, turn.items[key]) for key in order)
+ self._trim()
+
+ def replay(self, *, tail_seq: int, generation: str, max_bytes: int,
+ max_events: int, turn_usage: list) -> list:
+ selected: list = []
+ used = 0
+ truncated = self.truncated
+ for turn in reversed(self.turns.values()):
+ opening, closing = turn.envelope()
+ envelope = opening + closing
+ size = sum(RingBuffer._size(e) for e in envelope)
+ if used + size > max_bytes or len(selected) + len(envelope) > max_events:
+ truncated = True
+ break
+ body: list = []
+ used += size
+ for group in reversed(turn.items.values()):
+ events = list(group.events.values())
+ if (used + group.size > max_bytes
+ or len(selected) + len(envelope) + len(body) + len(events) > max_events):
+ truncated = True
+ break
+ body[0:0] = events
+ used += group.size
+ selected[0:0] = opening + body + closing
+ if len(body) < sum(len(group.events) for group in turn.items.values()):
+ break
+ # These are complete item snapshots, not a cursor suffix. Sequence is
+ # committed once at ReplayEnd; no reconstructed item changes live seq.
+ frames = [ReplayStart(from_seq=0, to_seq=tail_seq, truncated=truncated,
+ rebuild=True, generation=generation)]
+ frames.extend(e.model_copy(deep=True, update={"seq": None}) for e in selected)
+ frames.append(ReplayEnd(to_seq=tail_seq, truncated=truncated, turn_usage=turn_usage))
+ return frames
+
+
+class CodexBtwCompletions:
+ """Capture public completion snapshots before a bounded live queue sheds.
+
+ No raw reasoning, model context or disk transcript is retained. At most two
+ turns survive, allowing the previous terminal repair to race the next input.
+ """
+ def __init__(self, max_bytes: int, max_items: int, tool_result_max: int):
+ self.max_bytes, self.max_items = max_bytes, max_items
+ self.tool_result_max = tool_result_max
+ self.turns: OrderedDict[str, OrderedDict] = OrderedDict()
+ self.owners: OrderedDict[str, str | None] = OrderedDict()
+ self._last_users: dict[str, str] = {}
+ self.initial_owner: str | None = None
+ self._bytes = 0
+ self.truncated: set[str] = set()
+
+ def observe(self, message: dict) -> None:
+ from cc_remote.wrapper.codex_stream import CodexStreamTranslator, codex_live_user_message
+
+ params = message.get("params")
+ if not isinstance(params, dict):
+ return
+ turn_id = params.get("turnId")
+ item = params.get("item")
+ if (message.get("method") not in {"item/started", "item/completed"}
+ or not isinstance(turn_id, str) or not turn_id or len(turn_id) > 256
+ or not isinstance(item, dict) or not isinstance(item.get("id"), str)
+ or not item["id"] or len(item["id"]) > 256):
+ return
+ user = codex_live_user_message(message)
+ if user is not None:
+ # An unattributed steer is deliberately ambiguous, never assumed to
+ # belong to the last accepted browser input.
+ if self._last_users.get(turn_id) != user.message_id:
+ self.owners[turn_id] = user.client_id or (
+ self.initial_owner if turn_id not in self._last_users else None)
+ self._last_users[turn_id] = user.message_id
+ elif user.client_id:
+ self.owners[turn_id] = user.client_id
+ while len(self.owners) > 64:
+ old_id, _ = self.owners.popitem(last=False)
+ self._last_users.pop(old_id, None)
+ return
+ rows = self.turns.setdefault(turn_id, OrderedDict())
+ self.owners.setdefault(turn_id, self.initial_owner)
+ key = item["id"]
+ if key not in rows:
+ rows[key] = (self.owners[turn_id], [], 0)
+ if message["method"] == "item/completed":
+ try:
+ events = CodexStreamTranslator(self.tool_result_max).feed(message)
+ except (TypeError, ValueError, KeyError):
+ # Optional recovery must not break the native stdout reader.
+ return
+ events = [e for e in events if item_key(e)]
+ size = sum(RingBuffer._size(e) for e in events)
+ owner, _, previous = rows[key]
+ rows[key] = (owner, events, size)
+ self._bytes += size - previous
+ while self.turns and (len(self.turns) > 2 or self._bytes > self.max_bytes
+ or sum(len(rows) for rows in self.turns.values()) > self.max_items):
+ first_id, first = next(iter(self.turns.items()))
+ if len(self.turns) > 1 or not first:
+ self.turns.pop(first_id)
+ self.owners.pop(first_id, None)
+ self._last_users.pop(first_id, None)
+ self.truncated.discard(first_id)
+ self._bytes -= sum(entry[2] for entry in first.values())
+ else:
+ self.truncated.add(first_id)
+ self._bytes -= first.popitem(last=False)[1][2]
+
+ def snapshots(self, turn_id: str) -> list[tuple[str | None, list]]:
+ return [(owner, events) for owner, events, _ in self.turns.get(turn_id, {}).values()]
+
+ def item_order(self, turn_id: str) -> list[str]:
+ return list(self.turns.get(turn_id, {}))
diff --git a/cc_remote/wrapper/claude_external.py b/cc_remote/wrapper/claude_external.py
index c099ea5..8255191 100644
--- a/cc_remote/wrapper/claude_external.py
+++ b/cc_remote/wrapper/claude_external.py
@@ -41,6 +41,7 @@
_NEUTRAL_METADATA_TYPES = frozenset({
"ai-title",
"atis-latch",
+ "cost-state",
"mode",
"permission-mode",
"queue-operation",
@@ -119,13 +120,15 @@ def _resolve_continue_target(
def classify_claude_growth(
data: bytes,
owned_message_ids: Collection[str] = (),
-) -> tuple[Literal["sdk", "external", "unknown"], tuple[str, ...]]:
+) -> tuple[Literal["sdk", "external", "metadata", "unknown"], tuple[str, ...]]:
"""Attribute complete Claude JSONL growth without a time heuristic.
Agent SDK transcript rows carry ``entrypoint=sdk-py`` (and user rows also
carry ``promptSource=sdk``), while native TUI rows carry ``entrypoint=cli``.
A few metadata rows have no direct origin; ``last-prompt`` and file-history
rows can still be attributed through the message UUID they reference.
+ Cost-only growth is ``metadata``: it advances no conversation and proves
+ nothing about process ownership.
Unknown or partial data deliberately remains unknown so the machine can
fail closed unless an SDK operation is actively writing. An explicit
@@ -200,6 +203,11 @@ def classify_claude_growth(
if sdk_evidence:
# Preserve insertion order while avoiding unbounded duplicate ids.
return "sdk", tuple(dict.fromkeys(new_owned))
+ # Reconnect/exit may flush statistics without any accompanying SDK rows.
+ # Keep this narrower than the attribution-neutral allowlist: a standalone
+ # mode/permission/queue update still needs its original ownership checks.
+ if all(row.get("type") == "cost-state" for row in rows):
+ return "metadata", ()
return "unknown", ()
diff --git a/cc_remote/wrapper/claude_rate_limits.py b/cc_remote/wrapper/claude_rate_limits.py
index 3e71f00..1348509 100644
--- a/cc_remote/wrapper/claude_rate_limits.py
+++ b/cc_remote/wrapper/claude_rate_limits.py
@@ -1,10 +1,12 @@
-"""Sanitized, expiring Claude Agent SDK rate-limit projection.
+"""Sanitized, expiring Claude account rate-limit projection.
The SDK has no supported pull API for account usage. Claude Code emits
``RateLimitEvent`` records when its native quota state changes, so the wrapper
keeps only those public fields long enough to survive a browser reconnect or a
wrapper restart. Model credentials, account identity and the SDK's raw payload
-are never persisted.
+are never persisted. An optional external usage helper supplies read-only
+snapshots using the same cache; its percentages have different units from SDK
+events and are parsed separately.
"""
from __future__ import annotations
@@ -12,6 +14,7 @@
import math
import os
import time
+from datetime import datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
@@ -90,6 +93,7 @@ class ClaudeRateLimitStore:
def __init__(self, state_dir: str | os.PathLike[str]):
self.path = Path(state_dir) / "claude-rate-limits.json"
self._limits = self._load()
+ self.revisions: dict[str, int] = {}
def _load(self) -> dict[str, dict[str, Any]]:
try:
@@ -196,53 +200,130 @@ def _persist(self) -> None:
"Claude rate-limit cache could not be persisted") from exc
@staticmethod
- def _update(
- rate_type: str, entry: dict[str, Any],
- ) -> RateLimitUpdate:
- limit_id, name, slot, duration = _RATE_LIMITS[rate_type]
- window = StatusRateLimitWindow(
- used_percent=entry.get("used_percent"),
- resets_at=entry.get("resets_at"),
- window_duration_mins=duration,
- )
- return RateLimitUpdate(
- limit_id=limit_id,
- name=name,
- # Empty is an explicit clear for a previously rejected window.
- reached_type=(rate_type if entry.get("status") == "rejected" else ""),
- primary=window if slot == "primary" else None,
- secondary=window if slot == "secondary" else None,
- )
+ def _updates(limits: dict[str, dict[str, Any]]) -> tuple[RateLimitUpdate, ...]:
+ grouped: dict[str, dict[str, Any]] = {}
+ for rate_type in _RATE_LIMITS:
+ entry = limits.get(rate_type)
+ if entry is None:
+ continue
+ limit_id, name, slot, duration = _RATE_LIMITS[rate_type]
+ update = grouped.setdefault(limit_id, {
+ "limit_id": limit_id, "name": name, "reached_type": "",
+ })
+ update[slot] = StatusRateLimitWindow(
+ used_percent=entry.get("used_percent"),
+ resets_at=entry.get("resets_at"),
+ window_duration_mins=duration,
+ )
+ # Account windows share a bucket. An allowed companion window
+ # must not clear the rejection reported in this same observation.
+ if entry.get("status") == "rejected" and not update["reached_type"]:
+ update["reached_type"] = rate_type
+ return tuple(RateLimitUpdate(**update) for update in grouped.values())
- def observe(self, info: Any, *, now: int | None = None) -> RateLimitUpdate | None:
+ def observe(self, info: Any, *, now: int | None = None) -> tuple[RateLimitUpdate, ...]:
observed_at = int(time.time()) if now is None else int(now)
rate_type = getattr(info, "rate_limit_type", None)
- if rate_type not in _RATE_LIMITS:
- return None
- raw_reset = getattr(info, "resets_at", None)
- resets_at = _reset_timestamp(raw_reset, observed_at)
- if raw_reset is not None and resets_at is None:
- if rate_type in self._limits:
- self._limits.pop(rate_type, None)
- self._persist()
- return None
- status = getattr(info, "status", None)
- if status not in {"allowed", "allowed_warning", "rejected"}:
- status = "allowed"
- used_percent = _used_percent(getattr(info, "utilization", None))
- if status == "rejected":
- used_percent = 100
- entry = {
- "resets_at": resets_at,
- "used_percent": used_percent,
- "status": status,
- "observed_at": observed_at,
- }
- changed = self._limits.get(rate_type) != entry
- self._limits[rate_type] = entry
+ windows: dict[str, tuple[Any, Any, Any]] = {}
+ if isinstance(rate_type, str) and rate_type in _RATE_LIMITS:
+ windows[rate_type] = (
+ getattr(info, "resets_at", None),
+ getattr(info, "utilization", None),
+ getattr(info, "status", None),
+ )
+ # The pinned SDK preserves newer CLI fields in raw. Read only known
+ # public windows; never persist the rest of that payload. Top-level
+ # status belongs to rateLimitType, not every unified window.
+ raw = getattr(info, "raw", None)
+ unified = raw.get("unifiedWindows") if isinstance(raw, dict) else None
+ if isinstance(unified, dict):
+ for name in _RATE_LIMITS:
+ window = unified.get(name)
+ if not isinstance(window, dict) or not (
+ "utilization" in window or "resetsAt" in window
+ ):
+ continue
+ reset, used, status = windows.get(name, (None, None, "allowed"))
+ windows[name] = (
+ window.get("resetsAt", reset),
+ window.get("utilization", used),
+ status,
+ )
+ changed = False
+ observed: dict[str, dict[str, Any]] = {}
+ for name, (raw_reset, used, status) in windows.items():
+ self.revisions[name] = self.revisions.get(name, 0) + 1
+ resets_at = _reset_timestamp(raw_reset, observed_at)
+ if raw_reset is not None and resets_at is None:
+ changed = self._limits.pop(name, None) is not None or changed
+ continue
+ if status not in {"allowed", "allowed_warning", "rejected"}:
+ status = "allowed"
+ entry = {
+ "resets_at": resets_at,
+ "used_percent": 100 if status == "rejected" else _used_percent(used),
+ "status": status,
+ "observed_at": observed_at,
+ }
+ changed = self._limits.get(name) != entry or changed
+ self._limits[name] = observed[name] = entry
if changed:
self._persist()
- return self._update(rate_type, entry)
+ return self._updates(observed)
+
+ def observe_usage(self, payload: dict, revisions: dict[str, int], *,
+ now: int | None = None) -> None:
+ """Apply a GET snapshot without overwriting a newer native event.
+
+ OAuth usage percentages are 0..100 (SDK utilization is 0..1). Null
+ windows remove their old observation; absent windows are not invented.
+ Validate everything before changing or persisting any cache entry.
+ """
+ observed_at = int(time.time()) if now is None else int(now)
+ parsed: dict[str, dict | None] = {}
+ for name in _RATE_LIMITS:
+ if name not in payload:
+ continue
+ window = payload[name]
+ if window is None:
+ parsed[name] = None
+ continue
+ if not isinstance(window, dict):
+ raise ValueError("invalid usage window")
+ used = window.get("utilization")
+ if (isinstance(used, bool) or not isinstance(used, (int, float))
+ or not math.isfinite(used) or not 0 <= used <= 100):
+ raise ValueError("invalid usage percentage")
+ raw_reset = window.get("resets_at")
+ reset = None
+ if raw_reset is not None:
+ if not isinstance(raw_reset, str) or len(raw_reset) > 64:
+ raise ValueError("invalid usage reset")
+ try:
+ stamp = datetime.fromisoformat(raw_reset.replace("Z", "+00:00"))
+ if stamp.tzinfo is None:
+ raise ValueError()
+ reset = int(stamp.timestamp())
+ except (ValueError, OverflowError):
+ raise ValueError("invalid usage reset") from None
+ if reset <= observed_at:
+ parsed[name] = None
+ continue
+ parsed[name] = {
+ "resets_at": reset, "used_percent": round(used),
+ "status": "rejected" if used >= 100 else "allowed",
+ "observed_at": observed_at,
+ }
+ if not parsed:
+ raise ValueError("usage windows missing")
+ for name, entry in parsed.items():
+ if self.revisions.get(name, 0) != revisions.get(name, 0):
+ continue
+ if entry is None:
+ self._limits.pop(name, None)
+ else:
+ self._limits[name] = entry
+ self._persist()
def snapshot(self, *, now: int | None = None) -> tuple[RateLimitUpdate, ...]:
observed_at = int(time.time()) if now is None else int(now)
@@ -269,8 +350,4 @@ def snapshot(self, *, now: int | None = None) -> tuple[RateLimitUpdate, ...]:
for rate_type in expired:
self._limits.pop(rate_type, None)
self._persist()
- return tuple(
- self._update(rate_type, self._limits[rate_type])
- for rate_type in _RATE_LIMITS
- if rate_type in self._limits
- )
+ return self._updates(self._limits)
diff --git a/cc_remote/wrapper/claude_usage.py b/cc_remote/wrapper/claude_usage.py
new file mode 100644
index 0000000..f7c9ae8
--- /dev/null
+++ b/cc_remote/wrapper/claude_usage.py
@@ -0,0 +1,131 @@
+"""Account-scoped, read-only usage helpers; credentials stay with the provider.
+
+An operator-owned helper performs the authenticated GET and returns only usage
+JSON. The Wrapper never reads OAuth/keychain/model credentials or runs a chat
+command to obtain quota. Helpers are local configuration, never wire inputs.
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+from pathlib import Path
+import signal
+import stat
+import time
+
+from cc_remote.claude_profiles import ClaudeProfile
+
+
+MAX_BYTES = 64 * 1024
+TIMEOUT = 15
+REFRESH_INTERVAL = 15
+
+
+class ClaudeUsageError(RuntimeError):
+ """Only a fixed, credential-free reason may cross the control link."""
+
+
+class ClaudeUsageReader:
+ def __init__(self, state_dir: str | os.PathLike[str]):
+ self.path = Path(state_dir) / "claude-usage-helpers.json"
+ self._locks: dict[str, asyncio.Lock] = {}
+ self._recent: dict[str, tuple[float, tuple[str, ...], str | None]] = {}
+
+ def command(self, profile: ClaudeProfile) -> tuple[str, ...] | None:
+ try:
+ with self.path.open("rb") as stream:
+ info = os.fstat(stream.fileno())
+ if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
+ or info.st_mode & 0o077 or info.st_size > MAX_BYTES):
+ raise ValueError()
+ raw = json.loads(stream.read(MAX_BYTES + 1))
+ if not isinstance(raw, dict) or raw.get("version") != 1:
+ raise ValueError()
+ entries = raw["profiles"]
+ if not isinstance(entries, dict):
+ raise ValueError()
+ entry = entries.get(profile.id)
+ if entry is None:
+ return None
+ if (not isinstance(entry, dict) or not isinstance(entry.get("config_dir"), str)
+ or Path(entry["config_dir"]).expanduser().resolve() != profile.config_dir):
+ raise ValueError()
+ command = entry["command"]
+ if (not isinstance(command, list) or not 1 <= len(command) <= 16
+ or any(not isinstance(arg, str) or not arg or len(arg) > 4096
+ or "\x00" in arg for arg in command)
+ or not Path(command[0]).is_absolute()):
+ raise ValueError()
+ return tuple(command)
+ except FileNotFoundError:
+ return None
+ except (OSError, ValueError, KeyError, TypeError):
+ raise ClaudeUsageError("usage helper configuration invalid") from None
+
+ async def refresh(self, profile: ClaudeProfile, apply) -> str | None:
+ """Serialize each account's reads, including browser retry storms.
+
+ Only refresh completion is cached here. The shared quota store remains
+ authoritative, so native events received after a GET are never replaced
+ with an old cached response.
+ """
+ async with self._locks.setdefault(profile.id, asyncio.Lock()):
+ command = self.command(profile)
+ if command is None:
+ return "usage helper unavailable"
+ recent = self._recent.get(profile.id)
+ if (recent and recent[1] == command
+ and time.monotonic() - recent[0] < REFRESH_INTERVAL):
+ return recent[2]
+ error = None
+ try:
+ await apply(lambda: self._read(profile, command))
+ except ClaudeUsageError as exc:
+ error = str(exc)
+ self._recent[profile.id] = (time.monotonic(), command, error)
+ return error
+
+ async def _read(self, profile: ClaudeProfile, command: tuple[str, ...]) -> dict:
+ # No shell, project environment, prompt, relay secret or ambient model
+ # selector. The helper receives the exact public profile binding on stdin.
+ env = {key: os.environ[key] for key in (
+ "HOME", "PATH", "LANG", "LC_ALL", "TMPDIR", "TMP", "TEMP",
+ ) if key in os.environ}
+ process = None
+ try:
+ process = await asyncio.create_subprocess_exec(
+ *command, stdin=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
+ cwd=Path.home(), env=env, start_new_session=True,
+ )
+ async def collect():
+ process.stdin.write(json.dumps({
+ "version": 1, "profile_id": profile.id,
+ "config_dir": str(profile.config_dir),
+ }).encode() + b"\n")
+ await process.stdin.drain()
+ process.stdin.close()
+ output = bytearray()
+ while chunk := await process.stdout.read(4096):
+ output.extend(chunk)
+ if len(output) > MAX_BYTES:
+ raise ClaudeUsageError("usage response invalid")
+ if await process.wait() != 0:
+ raise ClaudeUsageError("usage request failed")
+ return output
+ data = json.loads(await asyncio.wait_for(collect(), TIMEOUT))
+ if not isinstance(data, dict):
+ raise ValueError()
+ return data
+ except TimeoutError:
+ raise ClaudeUsageError("usage request timed out") from None
+ except (OSError, ValueError, RecursionError):
+ raise ClaudeUsageError("usage response unavailable") from None
+ finally:
+ if process is not None and process.returncode is None:
+ try:
+ os.killpg(process.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ await process.wait()
diff --git a/cc_remote/wrapper/codex_detail_pages.py b/cc_remote/wrapper/codex_detail_pages.py
new file mode 100644
index 0000000..8f137d2
--- /dev/null
+++ b/cc_remote/wrapper/codex_detail_pages.py
@@ -0,0 +1,202 @@
+"""Bounded, read-only intra-turn pages for oversized Codex rollout segments.
+
+Conversation cursors still navigate between human inputs. These private cursors
+walk source windows *inside* one input, then reuse the ordinary display-group
+pager. No source path or caller-supplied byte offset crosses the wire.
+"""
+from __future__ import annotations
+
+from collections import OrderedDict
+from dataclasses import dataclass, field
+import os
+import threading
+from typing import Callable
+from uuid import uuid4
+
+from cc_remote.protocol import TurnBinding, UserMsg
+from cc_remote.wrapper.codex_stream import (
+ _history_boundary_records,
+ _next_jsonl_offset,
+ _previous_jsonl_record_offset,
+ codex_history_boundary_user,
+ codex_next_user_boundary_ts,
+ codex_translate_history,
+)
+from cc_remote.wrapper.history_store import (
+ HistorySourceFingerprint,
+ history_source_extends,
+)
+
+PREFIX = "cd1."
+_MAX_SNAPSHOTS = 32
+_MAX_PAGES = 8192
+_Position = tuple[int, str | None]
+_Page = tuple[list[dict], bool, str | None, bool, str | None]
+
+
+class CodexDetailCursorExpired(ValueError):
+ """The exact source/page chain is no longer available; restart explicitly."""
+
+
+@dataclass
+class _Snapshot:
+ sid: str
+ turn_id: str
+ revision: str
+ source: HistorySourceFingerprint
+ start: int
+ end: int
+ native_turn_id: str | None
+ user: UserMsg
+ window_bytes: int
+ limit: int
+ max_bytes: int
+ tool_result_max: int
+ segment_end_ts: float | None = None
+ # Every accepted coordinate was issued by this pager, never supplied by
+ # the browser. Parent links keep reverse navigation exact across windows.
+ pages: dict[_Position, _Position | None] = field(default_factory=dict)
+
+
+class CodexDetailPages:
+ def __init__(self) -> None:
+ self._snapshots: OrderedDict[str, _Snapshot] = OrderedDict()
+ self._lock = threading.Lock()
+
+ @staticmethod
+ def _cursor(key: str, position: _Position) -> str:
+ end, before = position
+ return f"{PREFIX}{key}.{end:x}.{before if before is not None else 'n'}"
+
+ def read(
+ self, path: str, sid: str, turn_id: str, revision: str,
+ *, before: str | None, limit: int, window_bytes: int,
+ max_bytes: int, tool_result_max: int, paginate: Callable[..., _Page],
+ ) -> _Page | None:
+ """Return None for ordinary turns; reject stale private cursors."""
+ with self._lock:
+ return self._read(
+ path, sid, turn_id, revision, before=before, limit=limit,
+ window_bytes=max(1024 * 1024, window_bytes),
+ max_bytes=max_bytes, tool_result_max=tool_result_max,
+ paginate=paginate,
+ )
+
+ def _read(
+ self, path: str, sid: str, turn_id: str, revision: str,
+ *, before: str | None, limit: int, window_bytes: int,
+ max_bytes: int, tool_result_max: int, paginate: Callable[..., _Page],
+ ) -> _Page | None:
+ if before is not None:
+ if not before.startswith(PREFIX):
+ return None
+ try:
+ _, key, encoded_end, encoded_before = before.split(".")
+ position = (int(encoded_end, 16),
+ None if encoded_before == "n" else encoded_before)
+ snapshot = self._snapshots[key]
+ except (ValueError, KeyError):
+ raise CodexDetailCursorExpired("expired Codex detail cursor") from None
+ if (snapshot.sid != sid or snapshot.turn_id != turn_id
+ or snapshot.revision != revision or snapshot.limit != limit
+ or position not in snapshot.pages
+ or self._cursor(key, position) != before):
+ raise CodexDetailCursorExpired("Codex detail cursor scope changed")
+ else:
+ if os.path.getsize(path) <= window_bytes:
+ return None
+ source = HistorySourceFingerprint.capture(path)
+ end = source.size
+ # Never freeze a half-written JSONL record into an immutable page.
+ with open(path, "rb") as stream:
+ stream.seek(end - 1)
+ if stream.read(1) != b"\n":
+ end = _previous_jsonl_record_offset(path, end)
+ found = None
+ for boundary in _history_boundary_records(
+ path, use_turns=True, end_offset=end,
+ ):
+ if turn_id in (boundary.cursor, boundary.compatibility_cursor):
+ found = boundary
+ break
+ end = boundary.offset
+ if found is None or end - found.offset <= window_bytes:
+ return None
+ user = codex_history_boundary_user(path, found.offset, found.cursor)
+ if user is None:
+ # An assistant-only native task has a proven boundary too.
+ user = UserMsg(msg_id=found.cursor, prompt="", ts=0)
+ user = user.model_copy(update={"msg_id": turn_id})
+ snapshot = _Snapshot(
+ sid, turn_id, revision, source, found.offset, end,
+ found.native_turn_id, user, window_bytes, limit, max_bytes,
+ tool_result_max,
+ segment_end_ts=(codex_next_user_boundary_ts(
+ path, end, found.native_turn_id, end_offset=source.size)
+ if end < source.size else None),
+ )
+ key = uuid4().hex
+ position = (end, None)
+ snapshot.pages[position] = None
+ self._snapshots[key] = snapshot
+ self._snapshots.move_to_end(key)
+ while len(self._snapshots) > _MAX_SNAPSHOTS:
+ self._snapshots.popitem(last=False)
+
+ current = HistorySourceFingerprint.capture(path)
+ if not history_source_extends(snapshot.source, current):
+ self._snapshots.pop(key, None)
+ raise CodexDetailCursorExpired("Codex rollout was replaced or truncated")
+ end, group_before = position
+ start = snapshot.start
+ if end - start > snapshot.window_bytes:
+ start = _next_jsonl_offset(path, end - snapshot.window_bytes, end)
+ if start == end:
+ # A single oversized record is skipped by the bounded reader;
+ # still advance past it instead of issuing the same page forever.
+ start = max(snapshot.start, _previous_jsonl_record_offset(path, end))
+ events, _ = codex_translate_history(
+ path, snapshot.tool_result_max, start_offset=start, end_offset=end,
+ source_continuation="authoritative_page",
+ source_turn_id=snapshot.native_turn_id,
+ segment_end_ts=snapshot.segment_end_ts if end == snapshot.end else None,
+ snapshot_in_progress=True,
+ )
+ # The source window may omit its user/start. Always repeat the proven
+ # envelope; never project the chunk as a new question or a new task.
+ if start > snapshot.start:
+ events.insert(0, snapshot.user)
+ if snapshot.native_turn_id:
+ events.insert(1, TurnBinding(
+ msg_id=turn_id, turn_id=snapshot.native_turn_id,
+ ts=snapshot.user.ts,
+ ))
+ rows = [event.model_dump(mode="json") for event in events]
+ for row in rows:
+ row["sid"] = sid
+ if row.get("type") == "user_msg":
+ row["msg_id"] = turn_id
+ page, has_more, older, _has_newer, _newer = paginate(
+ rows, before=group_before, limit=snapshot.limit,
+ max_bytes=snapshot.max_bytes,
+ )
+ # Validate again after parsing, so a concurrent rollback cannot publish
+ # an apparently authoritative page from a different source generation.
+ if not history_source_extends(
+ snapshot.source, HistorySourceFingerprint.capture(path),
+ ):
+ self._snapshots.pop(key, None)
+ raise CodexDetailCursorExpired("Codex rollout changed during detail read")
+ older_position = ((end, older) if has_more else
+ (start, None) if start > snapshot.start else None)
+ if older_position is not None:
+ if len(snapshot.pages) >= _MAX_PAGES and older_position not in snapshot.pages:
+ raise CodexDetailCursorExpired("Codex detail page chain expired")
+ snapshot.pages.setdefault(older_position, position)
+ newer_position = snapshot.pages[position]
+ return (
+ page, older_position is not None,
+ self._cursor(key, older_position) if older_position is not None else None,
+ newer_position is not None,
+ self._cursor(key, newer_position) if newer_position is not None else None,
+ )
diff --git a/cc_remote/wrapper/codex_external.py b/cc_remote/wrapper/codex_external.py
index d2fcdef..a9ba4a6 100644
--- a/cc_remote/wrapper/codex_external.py
+++ b/cc_remote/wrapper/codex_external.py
@@ -1078,7 +1078,7 @@ def codex_user_item_text(item: object) -> str | None:
def codex_rollout_user_message(
payload: object,
) -> CodexRolloutUserMessage | None:
- """Normalize legacy and 0.147 persisted user-message records."""
+ """Normalize legacy and current persisted user-message records."""
if not isinstance(payload, dict):
return None
payload_type = payload.get("type")
@@ -1096,7 +1096,7 @@ def codex_rollout_user_message(
return None
raw_text = codex_user_item_text(item)
message_id = item.get("id")
- client_id = item.get("clientId", client_id)
+ client_id = item.get("clientId") or item.get("client_id") or client_id
else:
return None
if not isinstance(raw_text, str):
diff --git a/cc_remote/wrapper/codex_handle.py b/cc_remote/wrapper/codex_handle.py
index 2969223..735dfba 100644
--- a/cc_remote/wrapper/codex_handle.py
+++ b/cc_remote/wrapper/codex_handle.py
@@ -37,6 +37,7 @@
from cc_remote import __version__
from cc_remote.log import logger
from cc_remote.wrapper.token_usage import CodexUsageTracker
+from cc_remote.wrapper.btw_history import CodexBtwCompletions
from cc_remote.protocol import (
MAX_SAFE_WIRE_INTEGER,
MAX_SAFE_WIRE_TIMESTAMP_SECONDS,
@@ -1527,6 +1528,7 @@ def __init__(self, cfg, cwd: Optional[str] = None,
self._ephemeral_server_identity: Optional[CodexServerIdentity] = None
self._ephemeral_private_generation: Optional[int] = None
self._ephemeral_thread_gone = False
+ self.btw_completions: CodexBtwCompletions | None = None
# A shared daemon can publish every subscribed thread immediately after
# initialize, before thread/resume returns and assigns ``thread_id``.
# Freeze the requested resume id across that bind window so only the
@@ -2389,6 +2391,11 @@ async def connect(
self.thread_id = _thread_id_of(res)
bound_thread_id = self.thread_id
self._ephemeral_thread_id = self.thread_id
+ self.btw_completions = CodexBtwCompletions(
+ min(getattr(self.cfg, "ring_max_bytes", 24 * 1024 * 1024), 24 * 1024 * 1024),
+ min(getattr(self.cfg, "ring_max_events", 10000), 10000),
+ self.cfg.tool_result_max,
+ )
self._ephemeral_server_identity = self._daemon_process_identity
self._ephemeral_private_generation = (
None if daemon_proxy else self._generation)
@@ -2648,6 +2655,8 @@ async def query(
assert self.proc is not None and self.thread_id, "connect() first"
thread_id = self.thread_id
self._open_managed_stream()
+ if self.btw_completions is not None:
+ self.btw_completions.initial_owner = client_user_message_id
queue = self._turn_q
params = {
"threadId": thread_id,
@@ -3817,6 +3826,8 @@ def _queue_spontaneous_notification(
turn_id = self._spontaneous_queue_turn_id
if q is None or turn_id is None:
return False
+ if self.btw_completions is not None:
+ self.btw_completions.observe(message)
method = message.get("method")
terminal = method == "turn/completed"
size = (
@@ -3857,6 +3868,8 @@ def _queue_managed_notification(
q = self._turn_q
if not isinstance(q, _SpontaneousNotificationQueue):
return False
+ if self.btw_completions is not None:
+ self.btw_completions.observe(message)
method = message.get("method")
terminal = method == "turn/completed"
size = (
diff --git a/cc_remote/wrapper/codex_history.py b/cc_remote/wrapper/codex_history.py
index a3a9218..b644a8d 100644
--- a/cc_remote/wrapper/codex_history.py
+++ b/cc_remote/wrapper/codex_history.py
@@ -78,6 +78,10 @@ class CodexHistoryInvalidResponse(CodexHistoryError):
"""The app-server returned malformed or internally inconsistent history."""
+class CodexHistoryProjectionTooLarge(CodexHistoryInvalidResponse):
+ """Valid item pagination reached our bounded projection budget."""
+
+
class CodexHistoryCursorError(CodexHistoryInvalidResponse):
"""A browser-safe cursor has no mapping in this wrapper generation."""
@@ -1295,7 +1299,7 @@ async def _items_for_turn(
page_count = 0
while True:
if page_count >= _MAX_ITEM_PAGES:
- raise CodexHistoryInvalidResponse(
+ raise CodexHistoryProjectionTooLarge(
"Codex item pagination exceeded its page limit")
page_count += 1
response = await self._call("thread/items/list", {
@@ -1336,7 +1340,7 @@ async def _items_for_turn(
seen_items.add(item_id)
items.append(normalized_item)
if len(items) > _MAX_DETAIL_ITEMS:
- raise CodexHistoryUnsupported(
+ raise CodexHistoryProjectionTooLarge(
"Codex turn exceeds the bounded official item projection")
if next_cursor is None:
return items
@@ -1416,6 +1420,9 @@ async def turn_events(
try:
items = await self._items_for_turn(
thread_id, locator.native_turn_id)
+ except CodexHistoryProjectionTooLarge as exc:
+ raise CodexHistoryUnsupported(
+ "official Codex turn detail exceeds projection budget") from exc
except CodexRpcRejected as exc:
if not _unsupported(exc):
raise
diff --git a/cc_remote/wrapper/codex_stream.py b/cc_remote/wrapper/codex_stream.py
index 1e62a63..ce638d1 100644
--- a/cc_remote/wrapper/codex_stream.py
+++ b/cc_remote/wrapper/codex_stream.py
@@ -629,7 +629,7 @@ def _legacy_user_item_before(
path: str,
offset: int,
) -> tuple[int, str | None]:
- """Return the preceding record offset and its legacy native user id."""
+ """Include a preceding legacy user envelope, never a preceding tool row."""
if offset <= 0:
return 0, None
try:
@@ -651,8 +651,12 @@ def _legacy_user_item_before(
except (json.JSONDecodeError, ValueError):
return previous, None
if not isinstance(row, dict) or row.get("type") != "response_item":
- return previous, None
- return previous, _legacy_response_user_item_id(row.get("payload"))
+ return offset, None
+ payload = row.get("payload")
+ if (not isinstance(payload, dict) or payload.get("type") != "message"
+ or payload.get("role") != "user"):
+ return offset, None
+ return previous, _legacy_response_user_item_id(payload)
def _fallback_history_id(path: str, kind: str, offset: int, raw_ts: str,
@@ -2025,6 +2029,7 @@ def codex_history_boundary_user(
msg_id=message_id,
client_msg_id=client_id,
prompt=user.prompt,
+ ts=0,
)
if pending_images:
event.images = pending_images
@@ -2041,7 +2046,7 @@ def codex_history_boundary_user(
if goal_prompt is None or user_index != 0:
return None
prompt, timestamp = goal_prompt
- event = UserMsg(msg_id=cursor, prompt=prompt)
+ event = UserMsg(msg_id=cursor, prompt=prompt, ts=0)
if timestamp is not None:
event.ts = timestamp
return event
@@ -4015,6 +4020,43 @@ def is_turn_terminal(msg: dict) -> bool:
# ---- on-disk Codex rollout -> wire events (session history) ----
+def codex_next_user_boundary_ts(
+ path: str, offset: int, native_turn_id: str | None, *, end_offset: int | None = None,
+) -> float | None:
+ """Read only an adjacent user boundary, never infer completion from EOF.
+
+ Page ends can also be arbitrary source windows or frozen active tails.
+ Only an actual following user record proves a visible segment is closed.
+ Legacy response_item/user must be paired with its immediately next replay.
+ """
+ try:
+ with open(path, "rb") as stream:
+ stream.seek(offset)
+ for index in range(2):
+ limit = _MAX_HISTORY_BOUNDARY_RECORD_BYTES + 1
+ if end_offset is not None:
+ limit = min(limit, max(0, end_offset - stream.tell()))
+ if limit == 0:
+ return None
+ line = stream.readline(limit)
+ if not line.endswith(b"\n") or len(line) > _MAX_HISTORY_BOUNDARY_RECORD_BYTES:
+ return None
+ row = json.loads(line)
+ payload = row.get("payload") or {}
+ if (index == 0 and row.get("type") == "response_item"
+ and payload.get("type") == "message" and payload.get("role") == "user"):
+ continue
+ user = codex_rollout_user_message(payload) if row.get("type") == "event_msg" else None
+ if (user is None or not user.prompt
+ or is_codex_account_switch_message(user.raw_text)
+ or (user.turn_id and native_turn_id and user.turn_id != native_turn_id)):
+ return None
+ return datetime.fromisoformat(row["timestamp"].replace("Z", "+00:00")).timestamp() - 0.001
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
+ pass
+ return None
+
+
def codex_translate_history(
path: str,
tool_result_max: int,
@@ -4022,6 +4064,8 @@ def codex_translate_history(
start_offset: int = 0,
end_offset: int | None = None,
source_continuation: str | None = None,
+ source_turn_id: str | None = None,
+ segment_end_ts: float | None = None,
snapshot_in_progress: bool = False,
active_task_ids: set[str] | frozenset[str] | tuple[str, ...] = (),
client_message_ids: dict[str, str] | None = None,
@@ -4039,7 +4083,7 @@ def codex_translate_history(
turn_open = False
active_turn_id: str | None = None
active_msg_id: str | None = None
- pending_turn_id: str | None = None
+ pending_turn_id: str | None = source_turn_id
turn_visible = False
turn_text_visible = False
turn_final_visible = False
@@ -4051,6 +4095,7 @@ def codex_translate_history(
cur_mid: str | None = None
cur_channel = "unknown"
last_ts = None
+ source_stamp = 0.0
pending_images: list = [] # input_image blocks seen before the next user_message
pending_legacy_user_item_id: str | None = None
pending_compactions: list[
@@ -4084,6 +4129,7 @@ def codex_translate_history(
seen_process_items: set[str] = set()
history_tools: dict[str, tuple[str, str, str | None, str | None, dict]] = {}
seen_agent_messages: set[tuple[str, str, str]] = set()
+ seen_agent_item_ids: set[str] = set()
seen_reasoning: set[tuple[str, str]] = set()
def _ts(iso: str):
@@ -4092,6 +4138,14 @@ def _ts(iso: str):
except Exception:
return None
+ def append_event(event) -> None:
+ # History is a projection of source time, never reconstruction time.
+ # Explicit timestamps (users, deferred messages and native terminals)
+ # win; other events inherit their current JSONL record's timestamp.
+ if "ts" not in event.model_fields_set:
+ event.ts = source_stamp
+ events.append(event)
+
def _stable_id(kind: str, line_no: int, raw_ts: str = "", identity=None) -> str:
"""Deterministic fallback for rollout records that carry no item id."""
stable_identity = str(identity or active_turn_id or "")
@@ -4136,13 +4190,14 @@ def ensure_assistant(
cur_mid = _history_id(item_id, "assistant", line_no, raw_ts)
assistant_open = True
cur_channel = channel
- events.append(AssistantMsgStart(
- message_id=cur_mid, channel=channel))
+ append_event(AssistantMsgStart(
+ message_id=cur_mid, channel=channel,
+ ts=_ts(raw_ts) if _ts(raw_ts) is not None else source_stamp))
def close_assistant(**message_fields):
nonlocal assistant_open, cur_mid, cur_channel
if assistant_open and cur_mid:
- events.append(AssistantMsgEnd(
+ append_event(AssistantMsgEnd(
message_id=cur_mid, channel=cur_channel, **message_fields))
assistant_open = False
cur_mid = None
@@ -4163,7 +4218,7 @@ def append_compaction(
)
if stamp is not None:
event.ts = stamp
- events.append(event)
+ append_event(event)
turn_visible = True
def flush_pending_compactions(target_owner: str | None) -> None:
@@ -4197,18 +4252,19 @@ def upsert_tool_use(
nonlocal turn_visible
history_tools[tool_id] = (
tool, category, title, server, tool_input)
- for event in reversed(events):
- if isinstance(event, ToolUse) and event.tool_use_id == tool_id:
- event.tool = tool
- event.category = category
- event.title = title
- event.server = server
- event.input = tool_input
- seen_tool_uses.add(tool_id)
- turn_visible = True
- return
+ if tool_id in seen_tool_uses:
+ for event in reversed(events):
+ if isinstance(event, ToolUse) and event.tool_use_id == tool_id:
+ event.tool = tool
+ event.category = category
+ event.title = title
+ event.server = server
+ event.input = tool_input
+ seen_tool_uses.add(tool_id)
+ turn_visible = True
+ return
ensure_assistant(line_no, raw_ts)
- events.append(ToolUse(
+ append_event(ToolUse(
message_id=cur_mid or "",
tool_use_id=tool_id,
tool=tool,
@@ -4222,15 +4278,18 @@ def upsert_tool_use(
def upsert_tool_result(result: ToolResult) -> None:
nonlocal turn_visible
- for index in range(len(events) - 1, -1, -1):
- event = events[index]
- if (isinstance(event, ToolResult)
- and event.tool_use_id == result.tool_use_id):
- events[index] = result
- seen_tool_results.add(result.tool_use_id)
- turn_visible = True
- return
- events.append(result)
+ if "ts" not in result.model_fields_set:
+ result.ts = source_stamp
+ if result.tool_use_id in seen_tool_results:
+ for index in range(len(events) - 1, -1, -1):
+ event = events[index]
+ if (isinstance(event, ToolResult)
+ and event.tool_use_id == result.tool_use_id):
+ events[index] = result
+ seen_tool_results.add(result.tool_use_id)
+ turn_visible = True
+ return
+ append_event(result)
seen_tool_results.add(result.tool_use_id)
turn_visible = True
@@ -4264,7 +4323,7 @@ def materialize_pending_goal_turn() -> bool:
user = UserMsg(msg_id=uid, prompt=prompt)
if prompt_ts is not None:
user.ts = prompt_ts
- events.append(user)
+ append_event(user)
turn_open = True
turn_visible = False
turn_text_visible = False
@@ -4310,7 +4369,7 @@ def open_assistant_only_turn(
# continuation. Bind it before its terminal arrives so history
# cannot invent a message id or a parser-time start timestamp.
active_msg_id = str(active_turn_id)
- events.append(TurnBinding(
+ append_event(TurnBinding(
msg_id=active_msg_id, turn_id=active_msg_id,
ts=pending_task_started[1] or 0,
))
@@ -4347,6 +4406,9 @@ def emit_agent_message(
item_id=None,
) -> None:
nonlocal turn_visible, turn_text_visible, turn_final_visible
+ native_id = item_id or payload.get("id") or payload.get("message_id")
+ if isinstance(native_id, str) and native_id in seen_agent_item_ids:
+ return
open_assistant_only_turn()
text = payload.get("message") or ""
channel = _assistant_channel(payload.get("phase"))
@@ -4356,8 +4418,10 @@ def emit_agent_message(
if payload.get("delivery") == "async" else channel),
text,
)
- if not text or key in seen_agent_messages:
+ if not text or (native_id is None and key in seen_agent_messages):
return
+ if isinstance(native_id, str):
+ seen_agent_item_ids.add(native_id)
seen_agent_messages.add(key)
close_assistant()
ensure_assistant(
@@ -4370,9 +4434,11 @@ def emit_agent_message(
turn_text_visible = True
if channel == "final" and payload.get("delivery") != "async":
turn_final_visible = True
- events.append(Delta(
- message_id=cur_mid, text=text, channel=channel))
- close_assistant(**_async_message_fields(payload))
+ append_event(Delta(
+ message_id=cur_mid, text=text, channel=channel,
+ ts=_ts(raw_ts) if _ts(raw_ts) is not None else source_stamp))
+ close_assistant(ts=_ts(raw_ts) if _ts(raw_ts) is not None else source_stamp,
+ **_async_message_fields(payload))
def emit_completed_plan_answer(
line_no: int,
@@ -4391,7 +4457,7 @@ def emit_completed_plan_answer(
channel="final",
force_new=True,
)
- events.append(Delta(
+ append_event(Delta(
message_id=cur_mid, text=text, channel="final"))
close_assistant()
seen_agent_messages.add((turn_key, "final", text))
@@ -4460,7 +4526,7 @@ def close_turn(
terminal_ts = completed_ts if completed_ts is not None else last_ts
if terminal_ts is not None:
te.ts = terminal_ts
- events.append(te)
+ append_event(te)
turn_open = False
pending_turn_id = None
active_turn_id = None
@@ -4497,6 +4563,7 @@ def close_turn(
p = d.get("payload") if isinstance(d.get("payload"), dict) else {}
raw_ts = d.get("timestamp", "")
ts = _ts(raw_ts)
+ source_stamp = ts if ts is not None else last_ts or 0.0
payload_type = p.get("type")
paired_legacy_user_item_id = None
if pending_legacy_user_item_id is not None:
@@ -4694,6 +4761,7 @@ def close_turn(
if steered_same_task or active_mid_task_steer:
close_turn(
"steered", 0, False,
+ completed_ts=ts - 0.001 if ts is not None else last_ts,
authoritative_boundary=False)
else:
close_turn(
@@ -4748,7 +4816,7 @@ def close_turn(
um.images = pending_images
if ts is not None:
um.ts = ts
- events.append(um)
+ append_event(um)
turn_open = True
turn_has_user = True
turn_continuation_reason = None
@@ -4777,7 +4845,7 @@ def close_turn(
plan_tool_ids.add(tool_id)
seen_tool_uses.add(tool_id)
turn_visible = True
- events.append(plan_event)
+ append_event(plan_event)
else:
ensure_assistant(line_no, raw_ts)
tool, category, title, server = _hist_tool_presentation(
@@ -4787,7 +4855,7 @@ def close_turn(
if tool_id not in seen_tool_uses:
seen_tool_uses.add(tool_id)
turn_visible = True
- events.append(ToolUse(
+ append_event(ToolUse(
message_id=cur_mid or "",
tool_use_id=tool_id,
tool=tool,
@@ -4811,7 +4879,7 @@ def close_turn(
ensure_assistant(line_no, raw_ts)
tool, category, title, server, hist_input = tool_meta
seen_tool_uses.add(tool_id)
- events.append(ToolUse(
+ append_event(ToolUse(
message_id=cur_mid or "", tool_use_id=tool_id,
tool=tool, input=hist_input, category=category,
title=title, server=server))
@@ -4843,7 +4911,7 @@ def close_turn(
raw_output, tool_result_max)
exit_code = _history_exit_code(output)
is_error = structured_error or _exit_is_error(output)
- events.append(ToolResult(
+ append_event(ToolResult(
tool_use_id=tool_id,
content=output,
is_error=is_error,
@@ -4851,7 +4919,21 @@ def close_turn(
status="failed" if is_error else "succeeded",
exit_code=exit_code,
))
- elif t == "event_msg" and payload_type == "exec_command_end":
+ elif t == "event_msg" and (
+ payload_type == "exec_command_end"
+ or (payload_type == "item_completed" and isinstance(p.get("item"), dict)
+ and str(p["item"].get("type")).replace("_", "").lower() == "commandexecution")
+ ):
+ if payload_type == "item_completed":
+ native_turn = active_turn_id or pending_turn_id
+ if p.get("turn_id") is not None and p["turn_id"] != native_turn:
+ continue
+ item = p["item"]
+ p = {**item, "call_id": item.get("id"),
+ "parsed_cmd": item.get("parsed_cmd", item.get("commandActions")),
+ "process_id": item.get("process_id", item.get("processId")),
+ "aggregated_output": item.get("aggregated_output", item.get("aggregatedOutput")),
+ "exit_code": item.get("exit_code", item.get("exitCode"))}
open_assistant_only_turn()
tool_id = _history_id(
p.get("call_id"), "tool", line_no, raw_ts)
@@ -4892,7 +4974,9 @@ def close_turn(
truncated=True if truncated else None,
status=status,
exit_code=exit_code,
- duration_ms=_legacy_duration_ms(p.get("duration")),
+ duration_ms=(_duration_ms(p.get("durationMs"))
+ if p.get("durationMs") is not None
+ else _legacy_duration_ms(p.get("duration"))),
))
elif t == "event_msg" and payload_type == "mcp_tool_call_end":
open_assistant_only_turn()
@@ -4942,7 +5026,7 @@ def close_turn(
)
if image_event is not None:
image_event.ts = ts if ts is not None else 0
- events.append(image_event)
+ append_event(image_event)
turn_visible = True
elif (
(t == "response_item" and str(payload_type).lower() == "filechange")
@@ -4975,16 +5059,45 @@ def close_turn(
elif t == "event_msg" and payload_type == "item_completed":
item = p.get("item") if isinstance(p.get("item"), dict) else {}
item_type = str(item.get("type") or "").replace("_", "").lower()
- if item_type == "subagentactivity":
+ if item_type == "agentmessage":
+ # New rollouts persist the public message here, using
+ # PascalCase Text parts. Raw response_item messages also
+ # contain private compaction summaries and are NOT a
+ # substitute for this public lifecycle record.
+ text = item.get("text")
+ if not isinstance(text, str):
+ content = item.get("content")
+ text = "".join(
+ part["text"] for part in (content if isinstance(content, list) else [])
+ if isinstance(part, dict)
+ and str(part.get("type", "")).lower() == "text"
+ and isinstance(part.get("text"), str)
+ )
+ emit_agent_message({
+ **item, "message": text,
+ }, line_no, raw_ts, item.get("id"))
+ elif item_type == "contextcompaction":
+ marker = (
+ _history_id(item.get("id"), "compaction", line_no, raw_ts),
+ ts,
+ _history_optional_turn_id(p.get("turn_id") or active_turn_id or pending_turn_id),
+ )
+ if turn_open:
+ append_compaction(marker)
+ else:
+ pending_compactions.append(marker)
+ if len(pending_compactions) > _MAX_PENDING_HISTORY_COMPACTIONS:
+ del pending_compactions[0]
+ elif item_type == "subagentactivity":
open_assistant_only_turn()
- events.append(_subagent_event({
+ append_event(_subagent_event({
**item, "agentThreadId": item.get("agentThreadId") or item.get("agent_thread_id"),
"agentPath": item.get("agentPath") or item.get("agent_path"),
}, _history_optional_turn_id(p.get("turn_id") or active_turn_id or pending_turn_id), True))
turn_visible = True
elif item_type == "collabagenttoolcall":
open_assistant_only_turn()
- events.append(_collab_event({
+ append_event(_collab_event({
**item,
"receiverThreadIds": item.get("receiverThreadIds") or item.get("receiver_thread_ids"),
"senderThreadId": item.get("senderThreadId") or item.get("sender_thread_id"),
@@ -5003,7 +5116,7 @@ def close_turn(
)
if item_id not in seen_process_items:
seen_process_items.add(item_id)
- events.append(ProcessEvent(
+ append_event(ProcessEvent(
item_id=item_id,
kind="plan",
phase="end",
@@ -5022,7 +5135,7 @@ def close_turn(
if summary and key not in seen_reasoning:
seen_reasoning.add(key)
open_assistant_only_turn()
- events.append(ProcessEvent(
+ append_event(ProcessEvent(
item_id=_history_id(
p.get("id"), "reasoning", line_no, raw_ts),
kind="reasoning",
@@ -5039,7 +5152,7 @@ def close_turn(
if summary and key not in seen_reasoning:
seen_reasoning.add(key)
open_assistant_only_turn()
- events.append(ProcessEvent(
+ append_event(ProcessEvent(
item_id=_history_id(
p.get("id") or p.get("event_id"),
"reasoning", line_no, raw_ts),
@@ -5063,7 +5176,7 @@ def close_turn(
if tool_id not in seen_tool_uses:
seen_tool_uses.add(tool_id)
turn_visible = True
- events.append(ToolUse(
+ append_event(ToolUse(
message_id=cur_mid or "",
tool_use_id=tool_id,
tool="apply_patch",
@@ -5083,7 +5196,7 @@ def close_turn(
output, output_truncated = bounded_text(
p.get("stdout") or p.get("stderr") or "",
tool_result_max)
- events.append(ToolResult(
+ append_event(ToolResult(
tool_use_id=tool_id,
content=output,
is_error=not success,
@@ -5106,7 +5219,7 @@ def close_turn(
if tool_id not in seen_tool_uses:
seen_tool_uses.add(tool_id)
turn_visible = True
- events.append(ToolUse(
+ append_event(ToolUse(
message_id=cur_mid or "",
tool_use_id=tool_id,
tool="webSearch",
@@ -5118,7 +5231,7 @@ def close_turn(
))
if tool_id not in seen_tool_results:
seen_tool_results.add(tool_id)
- events.append(ToolResult(
+ append_event(ToolResult(
tool_use_id=tool_id,
content="",
is_error=False,
@@ -5132,7 +5245,7 @@ def close_turn(
"agentThreadId": p.get("agent_thread_id"),
"agentPath": p.get("agent_path"),
}
- events.append(_subagent_event(
+ append_event(_subagent_event(
item,
_history_optional_turn_id(
active_turn_id or pending_turn_id),
@@ -5172,7 +5285,7 @@ def close_turn(
close_assistant()
ensure_assistant(
line_no, raw_ts, channel="final", force_new=True)
- events.append(Delta(
+ append_event(Delta(
message_id=cur_mid, text=last, channel="final"))
close_assistant()
seen_agent_messages.add((turn_key, "final", last))
@@ -5183,7 +5296,7 @@ def close_turn(
if terminal_error is None:
emit_completed_plan_answer(line_no, raw_ts)
if terminal_error is not None:
- events.append(Error(
+ append_event(Error(
code=ERR_CC_CRASH,
message=_provider_failure_message(terminal_error),
msg_id=active_msg_id,
@@ -5194,7 +5307,7 @@ def close_turn(
close_turn("success", _duration(p), False,
_completed_ts(p, ts), p.get("turn_id"))
else:
- events.append(Error(
+ append_event(Error(
code=ERR_CC_CRASH,
message=_EMPTY_COMPLETED_MESSAGE,
msg_id=active_msg_id,
@@ -5225,13 +5338,17 @@ def close_turn(
# session_meta / world_state / token_count / private reasoning : skipped
if ts is not None:
last_ts = ts
- if pending_agent_message is not None and not snapshot_in_progress:
+ if pending_agent_message is not None and (not snapshot_in_progress or segment_end_ts is not None):
payload, pending_line, pending_ts = pending_agent_message
emit_agent_message(payload, pending_line, pending_ts)
# A file can be read while Codex is still appending the current turn. Close
# only its current text block; deliberately omit TurnEnd so the reducer keeps
# the turn not-done instead of fabricating a completed status.
close_assistant()
+ if segment_end_ts is not None:
+ # A following user record closes this visible segment, not the native
+ # task. Never emit a native terminal id/fork point for this boundary.
+ close_turn("steered", 0, False, segment_end_ts, authoritative_boundary=False)
return events, model
diff --git a/cc_remote/wrapper/history_store.py b/cc_remote/wrapper/history_store.py
index 193312d..0ede588 100644
--- a/cc_remote/wrapper/history_store.py
+++ b/cc_remote/wrapper/history_store.py
@@ -69,7 +69,10 @@
# v42 replaces recovered text prefixes and bounds summary answer block counts.
# v43 makes manual /compact a visible turn owning its native boundary.
# v44 restores consumed human queued_command attachments as visible inputs.
-_SCHEMA_VERSION = 44
+# v45 restores public AgentMessage records in new Codex rollouts and the
+# native owner of source-window tails. Old tools-only projections must rebuild.
+# v46 restores native commands, source clocks and closed segment envelopes.
+_SCHEMA_VERSION = 46
_FINGERPRINT_SAMPLE_BYTES = 64 * 1024
_DEFAULT_MAX_ENTRIES = 128
_DEFAULT_MAX_BYTES = 64 * 1024 * 1024
@@ -1465,7 +1468,7 @@ 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, 38, 39, 40, 41, 42, 43):
+ elif current in (21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45):
# The independent v22-v44 invalidations above suffice.
pass
elif current not in (0, _SCHEMA_VERSION):
@@ -1648,6 +1651,12 @@ def _ensure_schema(self) -> None:
)
"""
)
+ if 0 < current < 46:
+ # v46 reads public command lifecycle records and source clocks in
+ # Codex rollouts. Rebuild only the derived Codex projections;
+ # native sources, other engines and binary assets stay valid.
+ for table in ("history_pages", "history_turn_details"):
+ connection.execute(f"DELETE FROM {table} WHERE engine='codex'")
if current != _SCHEMA_VERSION:
connection.execute(f"PRAGMA user_version={_SCHEMA_VERSION}")
try:
diff --git a/cc_remote/wrapper/machine.py b/cc_remote/wrapper/machine.py
index 56e9229..bd541e9 100644
--- a/cc_remote/wrapper/machine.py
+++ b/cc_remote/wrapper/machine.py
@@ -198,6 +198,7 @@
ClaudeRateLimitStore,
ClaudeRateLimitStoreError,
)
+from cc_remote.wrapper.claude_usage import ClaudeUsageError, ClaudeUsageReader
from cc_remote.wrapper.codex_controls import (
CODEX_WEB_SEARCH_MODES,
CodexControls,
@@ -321,6 +322,7 @@
codex_history_file_changes,
codex_history_process_append,
codex_history_window_info,
+ codex_next_user_boundary_ts,
codex_native_rollback_turns,
codex_translate_history,
)
@@ -328,9 +330,14 @@
CodexHistoryCursorError,
CodexHistoryInvalidResponse,
CodexHistoryPage,
+ CodexHistoryProjectionTooLarge,
CodexHistoryUnsupported,
CodexOfficialHistory,
)
+from cc_remote.wrapper.codex_detail_pages import (
+ CodexDetailPages, CodexDetailCursorExpired, PREFIX as CODEX_DETAIL_CURSOR_PREFIX,
+)
+from cc_remote.wrapper.btw_history import BtwHistory
from cc_remote.wrapper.codex_sessions import (
CODEX_EXACT_CATALOG_MAX_IDS,
list_codex_sessions, codex_exact_catalog_rows, codex_session_cwd,
@@ -1724,12 +1731,10 @@ def _apply_codex_process_clocks(
if (
turn.get("processDetailState") == "none"
and turn.get("done") is True
- and not turn.get("forkPointId")
):
- # A completed segment without the native terminal/fork belongs to
- # a steer boundary. An acceptance-time clock cannot contradict its
- # exact empty source projection. The enclosing task's own clock can
- # still recover work omitted from an opaque/compacted history tail.
+ # A clock proves activity, not public process content. Exact empty
+ # segments stay empty even when they own the native terminal. Opaque
+ # or bounded projections use unknown/present instead of none.
continue
visible_id = turn.get("id")
client_message_id = turn.get("clientMsgId")
@@ -1747,6 +1752,9 @@ def _apply_codex_process_clocks(
)
if started_ms is None:
continue
+ if (isinstance(turn.get("ts"), int) and started_ms < turn["ts"]
+ or isinstance(turn.get("doneTs"), int) and started_ms > turn["doneTs"]):
+ continue
current = turn.get("processStartedTs")
turn["processStartedTs"] = (
min(current, started_ms)
@@ -2206,6 +2214,7 @@ def __init__(self, cfg: WrapperConfig, transport: WrapperTransport):
self._claude_rate_limit_stores[profile.id] = store
self._claude_rate_limits = self._claude_rate_limit_stores.get(
self._claude_profiles.default.id)
+ self._claude_usage_reader = ClaudeUsageReader(cfg.state_dir)
# A History token changes for every wrapper process and every local
# destructive conversation mutation. Browsers persist this token with
# IndexedDB turns, so a fresh wrapper can never merge a pre-crash cache
@@ -2392,6 +2401,7 @@ def __init__(self, cfg: WrapperConfig, transport: WrapperTransport):
recover_user=self._recover_official_codex_user,
recover_users=self._recover_official_codex_users,
)
+ self._codex_detail_pages = CodexDetailPages()
# In-memory at-most-once window for client retries. The outer and inner
# OrderedDicts are both bounded; wrapper process restart intentionally
# resets this window (documented residual risk, not durable exactly-once).
@@ -10399,6 +10409,8 @@ async def _emit_locked(self, ctx: SessionContext, msg) -> None:
)
if is_downstream(msg):
msg.seq = ctx.next_seq()
+ if ctx.btw:
+ self._btw_history(ctx).observe(msg)
ctx.buffer.append(msg)
self._observe_active_turn_binding(ctx, msg)
live = (
@@ -13247,6 +13259,21 @@ async def _repair_codex_projection_after_overflow(
if not sid:
return
try:
+ if ctx.btw:
+ # Ephemeral native threads reject both persisted-history APIs.
+ # Their private presentation snapshot is captured before the
+ # lossy queue and never goes through a normal session route.
+ if not ctx.owner_client_id:
+ return
+ async with ctx.emit_lock:
+ completions = getattr(ctx.sdk, "btw_completions", None)
+ if completions is not None and turn_id in completions.truncated:
+ self._btw_history(ctx).truncated = True
+ for frame in self._btw_replay(ctx):
+ await self.transport.send(frame.model_copy(update={
+ "sid": ctx.key, "owner_id": ctx.owner_client_id,
+ }))
+ return
await self._push_mirrored_history(sid)
log.info(
"codex overflow projection repaired",
@@ -14528,6 +14555,12 @@ async def _build_history_source(
and source_window_boundary_offset is not None
else None
),
+ source_turn_id=source_window_native_turn_id,
+ segment_end_ts=(await asyncio.to_thread(
+ codex_next_user_boundary_ts, path, end_offset,
+ source_window.newest_native_turn_id,
+ end_offset=source_fingerprint.size if source_fingerprint is not None else None,
+ ) if before is not None else None),
snapshot_in_progress=(
in_progress and before is None
),
@@ -14585,6 +14618,12 @@ async def _build_history_source(
source_window_process_started_ms,
)
events.insert(0, recovered_user)
+ if source_window_native_turn_id is not None:
+ events.insert(1, TurnBinding(
+ msg_id=recovered_user.msg_id,
+ turn_id=source_window_native_turn_id,
+ ts=recovered_user.ts,
+ ))
except Exception as e:
log.warning("codex get_history failed", session_id=sid, error=str(e))
history_error = "历史暂时不可用,请稍后重试"
@@ -15719,6 +15758,19 @@ async def _build_official_codex_history(
source=source_before,
**alias_kwargs,
)
+ except CodexHistoryProjectionTooLarge as exc:
+ # The source already proved user segments missing from the summary.
+ # A bounded native item read cannot repair a very long steered turn.
+ # Switch the whole newest-page family only against that same frozen
+ # source; an older official cursor must never enter rollout paging.
+ if before is None and _minimum_user_segments and source_before is not None:
+ source_after = await asyncio.to_thread(
+ HistorySourceFingerprint.capture, source_before.path)
+ if source_after == source_before:
+ raise _CodexOfficialProjectionIncomplete(
+ "bounded official history omits source-proven user segments",
+ ) from exc
+ raise
finally:
take_identities = getattr(
self._codex_history,
@@ -16587,6 +16639,35 @@ async def send(
or watch.get("engine") == "codex"
)
raw_before = getattr(cmd, "before", None)
+ if is_codex and (raw_before is None or raw_before.startswith(CODEX_DETAIL_CURSOR_PREFIX)):
+ try:
+ path = await asyncio.to_thread(self._codex_rollout_for_wire, sid)
+ if path:
+ source_page = await asyncio.to_thread(
+ self._codex_detail_pages.read,
+ path, sid, cmd.turn_id, revision,
+ before=raw_before, limit=getattr(cmd, "limit", 192),
+ window_bytes=self.cfg.codex_history_window_max_bytes,
+ max_bytes=min(8 * 1024 * 1024,
+ max(512 * 1024, self.cfg.ws_max_size_bytes // 2)),
+ tool_result_max=self.cfg.tool_result_max,
+ paginate=_turn_detail_page,
+ )
+ if self._history_revision(sid) != revision:
+ raise CodexDetailCursorExpired("history revision changed")
+ if source_page is not None:
+ page, has_more, oldest, has_newer, newer = source_page
+ return await send(page, has_more=has_more, oldest_cursor=oldest,
+ has_newer=has_newer, newer_cursor=newer)
+ elif raw_before is not None:
+ raise CodexDetailCursorExpired("rollout is unavailable")
+ except CodexDetailCursorExpired:
+ return await send(error="详细过程已更新,请重新加载该轮", reset_required=True)
+ except (OSError, ValueError):
+ if raw_before is not None:
+ return await send(error="详细过程已更新,请重新加载该轮", reset_required=True)
+ # An initial read can still use the ordinary official/indexed
+ # path when the optional local rollout is unavailable.
try:
snapshot_cursor = _decode_turn_detail_snapshot_cursor(raw_before)
except ValueError:
@@ -17834,6 +17915,21 @@ async def _handle_takeover(self, cmd):
# can recover a response that was lost with the original WebSocket.
return None
+ async def _reject_query(self, ctx, cmd, error: Error) -> Error:
+ """A pre-acceptance rejection belongs to the sender, not the live turn.
+
+ The reliable command cache handles lost ACKs. Never put this response
+ in the shared ring: reconnect replay rewrites recipients and would make
+ another browser mistake an unsent message for a failed engine turn.
+ """
+ error.sid = (self._ctx_wire_sid(ctx) if ctx is not None
+ else getattr(cmd, "sid", None))
+ error.msg_id = getattr(cmd, "msg_id", None)
+ error.request_id = getattr(cmd, "cmd_id", None)
+ error.to = getattr(cmd, "client_id", None)
+ await self.transport.send(error)
+ return error
+
async def _handle_query(self, cmd):
sid = getattr(cmd, "sid", None)
ctx = self._ctx_for(sid)
@@ -17844,13 +17940,13 @@ async def _handle_query(self, cmd):
error = Error(code=ERR_NOT_RUNNING,
message="该会话未启动(可能启动失败),重新点进这个会话再发",
msg_id=getattr(cmd, "msg_id", None))
- await self._emit_to_sid(sid, error)
- return error
+ return await self._reject_query(ctx, cmd, error)
if getattr(cmd, "delivery", "immediate") != "immediate":
return await self._enqueue_deferred_query(ctx, cmd)
async with ctx.query_lock:
if not self._is_resident_context(ctx):
- return await self._missing_session_error(cmd, "发送消息")
+ return await self._reject_query(ctx, cmd, Error(
+ code=ERR_NOT_RUNNING, message="该会话未启动,无法发送消息"))
return await self._handle_immediate_query(ctx, cmd)
async def _handle_immediate_query(
@@ -17863,14 +17959,12 @@ async def _handle_immediate_query(
if await self._refresh_btw_availability(ctx):
error = Error(code=ERR_NOT_RUNNING, message=self.BTW_DESTROYED_MESSAGE,
msg_id=getattr(cmd, "msg_id", None))
- await self._emit(ctx, error)
- return error
+ return await self._reject_query(ctx, cmd, error)
if ctx.state != "idle":
error = Error(
code=ERR_BUSY, message="该会话正忙,先 interrupt",
msg_id=getattr(cmd, "msg_id", None))
- await self._emit(ctx, error)
- return error
+ return await self._reject_query(ctx, cmd, error)
if self._claude_autonomous_followup_pending(ctx):
# Claude may emit a task notification after the parent Result and
# immediately start an autonomous response while the wrapper still
@@ -17882,8 +17976,7 @@ async def _handle_immediate_query(
message="Claude 正在处理后台任务结果,请稍后重试或排队发送",
msg_id=getattr(cmd, "msg_id", None),
)
- await self._emit(ctx, error)
- return error
+ return await self._reject_query(ctx, cmd, error)
if ctx.engine == "claude" and ctx.space == "code":
await self._adopt_claude_broker_handle(ctx)
if ctx.state != "idle":
@@ -17892,8 +17985,7 @@ async def _handle_immediate_query(
message="该会话正由终端中的 Claude 回合运行,先 interrupt",
msg_id=getattr(cmd, "msg_id", None),
)
- await self._emit(ctx, error)
- return error
+ return await self._reject_query(ctx, cmd, error)
is_claude_broker = bool(getattr(ctx.sdk, "is_claude_broker", False))
is_codex_shared = self._codex_shared_affinity(ctx)
if (
@@ -17907,8 +17999,7 @@ async def _handle_immediate_query(
else "Codex 共享通道重连失败,本次未发送;请重试"),
msg_id=getattr(cmd, "msg_id", None),
)
- await self._emit(ctx, error)
- return error
+ return await self._reject_query(ctx, cmd, error)
if is_claude_broker:
try:
metadata = await ctx.sdk.refresh_status()
@@ -17924,8 +18015,7 @@ async def _handle_immediate_query(
message="Claude 连接暂不可用,本次消息未发送,请稍后重试。",
msg_id=getattr(cmd, "msg_id", None),
)
- await self._emit(ctx, error)
- return error
+ return await self._reject_query(ctx, cmd, error)
else:
# A terminal session exit was proven and the context is now
# a fully connected SDK handle. Continue through the normal
@@ -17941,8 +18031,7 @@ async def _handle_immediate_query(
message="本机终端正在编辑输入;完成、发送或取消后再从 Remote 发送",
msg_id=getattr(cmd, "msg_id", None),
)
- await self._emit(ctx, error)
- return error
+ return await self._reject_query(ctx, cmd, error)
route_sid = self._ctx_wire_sid(ctx)
if route_sid:
self._watch_session(route_sid)
@@ -17971,15 +18060,13 @@ async def _handle_immediate_query(
message=message,
msg_id=getattr(cmd, "msg_id", None),
)
- await self._emit(ctx, error)
- return error
+ return await self._reject_query(ctx, cmd, error)
if not cmd.prompt and not cmd.images and not cmd.files:
error = Error(
code=ERR_BAD_PROMPT,
message="消息内容为空,请输入内容或添加附件。",
msg_id=getattr(cmd, "msg_id", None))
- await self._emit(ctx, error)
- return error
+ return await self._reject_query(ctx, cmd, error)
attachment_error = validate_attachments(
getattr(cmd, "images", None), getattr(cmd, "files", None))
if attachment_error:
@@ -17988,8 +18075,7 @@ async def _handle_immediate_query(
message="附件不符合要求,请调整后重试。",
msg_id=getattr(cmd, "msg_id", None),
)
- await self._emit(ctx, error)
- return error
+ return await self._reject_query(ctx, cmd, error)
if ctx.space == "work" and ctx.work_id:
try:
await asyncio.to_thread(
@@ -18004,8 +18090,7 @@ async def _handle_immediate_query(
message="工作资料同步失败,本轮尚未发送;请重试",
msg_id=getattr(cmd, "msg_id", None),
)
- await self._emit(ctx, error)
- return error
+ return await self._reject_query(ctx, cmd, error)
if self._claude_autonomous_followup_pending(ctx):
# Ownership and Work preflights above contain awaits. A task
# notification can start its autonomous follow-up during one of
@@ -18015,21 +18100,18 @@ async def _handle_immediate_query(
message="Claude 正在处理后台任务结果,请稍后重试或排队发送",
msg_id=getattr(cmd, "msg_id", None),
)
- await self._emit(ctx, error)
- return error
+ return await self._reject_query(ctx, cmd, error)
if ctx.engine == "claude" and not is_claude_broker:
context_error = await self._prepare_claude_context_before_query(
ctx, msg_id=getattr(cmd, "msg_id", None))
if context_error is not None:
- await self._emit(ctx, context_error)
- return context_error
+ return await self._reject_query(ctx, cmd, context_error)
if ctx.engine == "codex":
await self._apply_codex_context(ctx)
if ctx.state != "idle" or getattr(ctx.sdk, "turn_active", False):
error = Error(code=ERR_BUSY, message="会话已开始新回合,请稍后重试或排队发送",
msg_id=getattr(cmd, "msg_id", None))
- await self._emit(ctx, error)
- return error
+ return await self._reject_query(ctx, cmd, error)
# All synchronous rejection paths have passed. A new conversation may
# now finish before the next sidebar catalog read, so remember its first
# accepted prompt under the temporary key; capture migrates it to the
@@ -19369,6 +19451,12 @@ async def _handle_set_model(self, cmd):
if not self._is_resident_context(ctx):
return await self._missing_session_error(
cmd, "切换模型")
+ if getattr(ctx.sdk, "service_restart_required", False):
+ recovered = await self._reload_stale_claude_context_control(
+ ctx, expected_sdk=ctx.sdk,
+ expected_client=getattr(ctx.sdk, "client", None))
+ if not recovered:
+ raise RuntimeError("Claude service recovery is not ready")
await ctx.sdk.set_model(requested_model)
else:
await ctx.sdk.set_model(requested_model)
@@ -20123,6 +20211,32 @@ async def _handle_close_btw(self, cmd):
log.info("btw closed", btw_sid=sid)
return close_event
+ def _btw_history(self, ctx: SessionContext) -> BtwHistory:
+ if ctx.btw_history is None:
+ ctx.btw_history = BtwHistory(
+ min(self.cfg.ring_max_bytes, 24 * 1024 * 1024),
+ min(self.cfg.ring_max_events, 10000),
+ )
+ # Compatibility for an already-populated ring/test fixture. New
+ # forks enter here on their first emit, before any token eviction.
+ for _, event in ctx.buffer._buf:
+ ctx.btw_history.observe(event)
+ return ctx.btw_history
+
+ def _btw_replay(self, ctx: SessionContext) -> list:
+ history = self._btw_history(ctx)
+ completions = getattr(ctx.sdk, "btw_completions", None)
+ if completions is not None:
+ for native_id in tuple(completions.turns):
+ history.repair(native_id, completions.snapshots(native_id),
+ item_order=completions.item_order(native_id))
+ return history.replay(
+ tail_seq=ctx.buffer.tail_seq, generation=self.instance_id,
+ max_bytes=self.BTW_REPLAY_MAX_BYTES,
+ max_events=self.BTW_REPLAY_MAX_EVENTS,
+ turn_usage=ctx.buffer.latest_turn_usage(),
+ )
+
async def _handle_sync_btw(self, cmd: SyncBtw):
"""Hydrate one selected side chat without replaying every BTW on Hello."""
client_id = getattr(cmd, "client_id", None)
@@ -20163,21 +20277,7 @@ async def send(message) -> None:
await self._close_pending_ask_locked(
ctx, ask_id, reason="timeout")
- same_generation = cmd.generation == self.instance_id
- cursor = cmd.cursor if same_generation else 0
- frames = ctx.buffer.replay_from_bounded(
- cursor,
- max_bytes=self.BTW_REPLAY_MAX_BYTES,
- max_events=self.BTW_REPLAY_MAX_EVENTS,
- rebuild=not same_generation,
- generation=self.instance_id,
- )
- frames = self._reseed_active_binding_for_hello(
- ctx,
- frames,
- cursor=cursor,
- same_generation=same_generation,
- )
+ frames = self._btw_replay(ctx)
for frame in frames:
if not self._hello_replay_frame_visible(frame, client_id):
continue
@@ -20897,7 +20997,7 @@ async def _claude_context_refresh_readiness(
# fail-closed boundary merely to paint Context metadata.
if ctx.write_state != "writable":
return "external"
- if ctx.needs_reload:
+ if ctx.needs_reload or getattr(ctx.sdk, "service_restart_required", False):
return "stale"
return "ready"
@@ -20908,11 +21008,12 @@ async def _reload_stale_claude_context_control(
expected_sdk,
expected_client,
) -> bool:
- """Resume external transcript growth without crossing a new owner."""
+ """Resume a stale transcript/dead service without crossing a new owner."""
if (ctx.sdk is not expected_sdk
or getattr(ctx.sdk, "client", None) is not expected_client
or getattr(ctx.sdk, "is_claude_broker", False)
- or not ctx.needs_reload):
+ or not (ctx.needs_reload or getattr(
+ ctx.sdk, "service_restart_required", False))):
return False
readiness = await self._claude_context_refresh_readiness(
ctx,
@@ -20925,25 +21026,29 @@ async def _reload_stale_claude_context_control(
async def reconnect_if_still_stale() -> bool:
if (ctx.sdk is not expected_sdk
or getattr(ctx.sdk, "client", None) is not expected_client
- or not ctx.needs_reload
+ or not (ctx.needs_reload or getattr(
+ ctx.sdk, "service_restart_required", False))
or self._claude_context_work_active(ctx)
or ctx.write_state != "writable"):
return False
resume_id, fork = self._claude_reconnect_identity(ctx)
+ transcript_changed = ctx.needs_reload
# Clear first so a watcher append during reconnect can reassert the
# stale bit without being overwritten after the await.
ctx.needs_reload = False
try:
- await self._stage_claude_handoff_controls(ctx)
+ if transcript_changed:
+ await self._stage_claude_handoff_controls(ctx)
await expected_sdk.force_reconnect(
resume_id=resume_id,
cwd=ctx.cwd,
- reason="external transcript change before context",
+ reason=("external transcript change before context"
+ if transcript_changed else "persistent Claude service restart"),
preserve_model=True,
fork=fork,
)
except Exception as reconnect_exc:
- ctx.needs_reload = True
+ ctx.needs_reload = ctx.needs_reload or transcript_changed
log.warning(
"Claude context stale-generation reload failed",
session_id=ctx.session_id,
@@ -21366,6 +21471,8 @@ async def _handle_get_status(self, cmd) -> None:
ctx = self._ctx_for(getattr(cmd, "sid", None))
if ctx is None:
return await self._missing_session_error(cmd, "读取状态")
+ if ctx.engine == "claude":
+ return await self._handle_get_claude_status(ctx, cmd)
if ctx.engine != "codex":
error = Error(
code=ERR_INTERNAL,
@@ -21418,6 +21525,45 @@ async def _handle_get_status(self, cmd) -> None:
await self._emit(ctx, error)
return error
+ async def _handle_get_claude_status(self, ctx, cmd):
+ """Read account quota without connecting or controlling a chat worker."""
+ profile = self._claude_profile_for_ctx(ctx)
+ store = self._claude_rate_limit_stores.get(profile.id)
+
+ async def apply(read):
+ if store is None:
+ raise ClaudeUsageError("usage cache unavailable")
+ async with self._claude_rate_limit_lock:
+ revisions = dict(store.revisions)
+ payload = await read()
+ async with self._claude_rate_limit_lock:
+ try:
+ await asyncio.to_thread(store.observe_usage, payload, revisions)
+ except (ValueError, ClaudeRateLimitStoreError):
+ raise ClaudeUsageError("usage response invalid") from None
+
+ try:
+ failure = await self._claude_usage_reader.refresh(profile, apply)
+ except ClaudeUsageError as exc:
+ failure = str(exc)
+ windows = await self._claude_rate_limit_snapshot(ctx)
+ report = StatusReport(
+ thread={"thread_id": ctx.session_id or ctx.key,
+ "session_id": ctx.session_id,
+ "status": "active" if ctx.state == "running" else "idle"},
+ runtime={}, context={},
+ rate_limits=[{
+ "limit_id": update.limit_id, "limit_name": update.name,
+ "rate_limit_reached_type": update.reached_type,
+ "primary": update.primary, "secondary": update.secondary,
+ } for update in windows],
+ component_errors=[f"rate_limits: {failure}"] if failure else [],
+ request_id=getattr(cmd, "cmd_id", None),
+ to=getattr(cmd, "client_id", None),
+ )
+ await self._emit(ctx, report)
+ return report
+
async def _handle_consume_rate_limit_reset_credit(self, cmd):
"""Redeem one native account reset credit and refresh its snapshot."""
ctx = self._ctx_for(getattr(cmd, "sid", None))
@@ -21637,12 +21783,12 @@ async def _observe_claude_rate_limit_message(
return True
async with self._claude_rate_limit_lock:
try:
- update = await asyncio.to_thread(
+ updates = await asyncio.to_thread(
store.observe, message.rate_limit_info)
except ClaudeRateLimitStoreError:
log.warning("Claude rate-limit update could not be cached")
return True
- if update is None:
+ if not updates:
return True
# Each CLAUDE_CONFIG_DIR is an authentication boundary. Seed only
# resident normal sessions from the same profile;
@@ -21656,7 +21802,8 @@ async def _observe_claude_rate_limit_message(
)
async with self._claude_rate_limit_emit_lock:
for target in targets:
- await self._emit(target, update.model_copy(deep=True))
+ for update in updates:
+ await self._emit(target, update.model_copy(deep=True))
return True
async def _observe_claude_model_fallback(self, ctx: SessionContext, message) -> None:
@@ -37472,6 +37619,10 @@ async def _run_turn(
codex_handoff_to_spontaneous = False
codex_query_reconnected = False
native_turn_id: Optional[str] = None
+ codex_initial_msg_id = ctx.active_msg_id
+ codex_initial_user_seen = False
+ codex_seen_user_items: set[str] = set()
+ codex_seen_user_clients: set[str] = set()
file_meta = ([{"filename": item.get("filename", "attachment")}
for item in (files or [])] or None)
prelaunch_terminal_emitted = False
@@ -37669,6 +37820,55 @@ async def next_turn_message():
wait_task.cancel()
await asyncio.gather(wait_task, return_exceptions=True)
+ async def publish_codex_user(raw: dict) -> None:
+ """Move the visible segment on an exact managed-task user boundary."""
+ nonlocal codex_initial_user_seen
+ user = codex_live_user_message(raw)
+ if user is None or user.turn_id != native_turn_id:
+ return
+ if user.client_id is not None:
+ await self._remember_codex_live_user_alias(ctx, user)
+ if user.message_id in codex_seen_user_items:
+ return
+ initial = bool(codex_initial_msg_id and codex_initial_msg_id in {
+ user.message_id, user.client_id,
+ })
+ if not initial and not user.client_id and not codex_initial_user_seen:
+ # A missed initial echo cannot turn an unlabelled first item
+ # into a second input. Never guess from equal prompt text.
+ return
+ if len(codex_seen_user_items) >= self.CODEX_LIVE_USER_ITEM_IDS:
+ return
+ codex_seen_user_items.add(user.message_id)
+ if initial:
+ codex_initial_user_seen = True
+ return
+ if user.client_id is not None:
+ if (
+ ctx.codex_published_steers.get(user.client_id) == native_turn_id
+ or user.client_id in codex_seen_user_clients
+ ):
+ # Remote already published its boundary at RPC acceptance.
+ # A late echo must not steal activity from a newer input.
+ return
+ codex_seen_user_clients.add(user.client_id)
+ msg_id = user.client_id or user.message_id
+ ctx.active_msg_id = msg_id
+ self._rebind_codex_turn(ctx, user.turn_id, msg_id)
+ await self._emit(ctx, TurnSteered(
+ msg_id=msg_id,
+ turn_id=user.turn_id,
+ prompt=user.prompt,
+ ))
+ if user.client_id is not None:
+ # Reconcile the canonical history id only after the boundary;
+ # pre-inserting UserMsg would leave the previous row open.
+ await self._emit(ctx, UserMsg(
+ msg_id=user.message_id,
+ client_msg_id=user.client_id,
+ prompt=user.prompt,
+ ))
+
async def emit_codex_event(event) -> None:
nonlocal notice_active
# Translator errors/progress are turn-local, but the translator is
@@ -37921,7 +38121,8 @@ 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:
+ if (check_delivery is not None
+ and not getattr(ctx.sdk, "service_restart_required", False)):
check_delivery()
external_change = reason.startswith("external transcript change")
if external_change:
@@ -37951,7 +38152,8 @@ async def reconnect_claude(reason: str) -> None:
if not _recover_service and not _adopt_steer:
if not is_codex:
check_delivery = getattr(ctx.sdk, "check_service_delivery", None)
- if check_delivery is not None:
+ if (check_delivery is not None
+ and not getattr(ctx.sdk, "service_restart_required", False)):
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
@@ -38004,7 +38206,8 @@ async def reconnect_claude(reason: str) -> None:
# the failed prompt automatically because it may already have run.
if (
not is_codex
- and getattr(ctx.sdk, "message_pump_failed", False)
+ and (getattr(ctx.sdk, "message_pump_failed", False)
+ or getattr(ctx.sdk, "service_restart_required", False))
):
log.warning(
"recovering failed Claude SDK message pump before query",
@@ -38398,17 +38601,7 @@ async def msg_stream():
continue
await ctx.codex_steer_gate.wait()
await self._confirm_uncertain_codex_steer(ctx, msg)
- live_user = codex_live_user_message(msg)
- if (
- live_user is not None
- and live_user.client_id is not None
- ):
- # Only an upstream clientId can prove a native user-item
- # alias. Initial Query also keeps its source-bound
- # segment-0 fallback for older app-server generations;
- # never guess from an unlabelled prompt or timestamp.
- await self._remember_codex_live_user_alias(
- ctx, live_user)
+ await publish_codex_user(msg)
sid = codex_session_id(msg)
if sid and not ctx.session_id:
await self._capture_session_id(ctx, sid)
diff --git a/cc_remote/wrapper/sdk.py b/cc_remote/wrapper/sdk.py
index c1f7740..5e863f0 100644
--- a/cc_remote/wrapper/sdk.py
+++ b/cc_remote/wrapper/sdk.py
@@ -35,7 +35,7 @@
from cc_remote.config import WrapperConfig
from cc_remote.claude_steering import (
- ClaudeSteerRejected, PendingSteers, background_end_ids, is_managed_input, steer_message,
+ ClaudeSteerRejected, PendingSteers, background_end_ids, is_human_result, is_managed_input, steer_message,
)
from cc_remote.log import logger
from cc_remote.protocol import MAX_SAFE_WIRE_INTEGER
@@ -233,6 +233,7 @@ def __init__(
self.client: ClaudeSDKClient | None = None
self.service_metadata: dict | None = None
self.service_socket_override: str | None = None
+ self._service_owner_identity = None
self.service_recovery: dict | None = None
self.service_turn_metadata: dict | None = None
self.service_defer_events = False
@@ -409,7 +410,15 @@ def _options(
"set_mode('plan'); 'just do it' / 'go ahead' -> set_mode('bypassPermissions') "
"or 'acceptEdits'. The user has no Shift+Tab here, so calling this is how you "
"enter plan mode for them.\n"
- "Modes: default, acceptEdits, plan, auto, bypassPermissions."
+ "Modes: default, acceptEdits, plan, auto, bypassPermissions.\n\n"
+ "The user is reading your replies in cc-remote's browser. When sharing "
+ "files or directories available on this machine, use clickable Markdown "
+ "links with absolute paths, for example [Listen]() "
+ "or [Open folder](), rather than bare paths or "
+ "file:// URLs. These links open cc-remote's file browser or preview, "
+ "including audio playback. Link directly to existing audio files when "
+ "the user asks to listen; no separate HTML page or web server is needed. "
+ "Only link to real files and directories; preserve their actual paths."
)
# showThinkingSummaries is an interactive CLI preference. SDK sessions
# must request the readable summary explicitly. Pass only display here:
@@ -612,6 +621,7 @@ async def connect(
"applied_effort": launch_effort,
},
isolated=self.isolate_account_env,
+ previous_owner_identity=self._service_owner_identity,
)
elif self.isolate_account_env:
self.client = ClaudeSDKClient(
@@ -622,6 +632,11 @@ async def connect(
self.client = ClaudeSDKClient(options=opts)
self._conversation_rewind_capability = None
await self.client.connect()
+ if self.service_metadata is not None and getattr(self.client, "id", None):
+ # Retain both across failed closes/connects. A worker ID alone
+ # cannot distinguish a restarted service from a lost live lease.
+ self.service_metadata["service_id"] = self.client.id
+ self._service_owner_identity = self.client.owner_identity
self.service_recovery = getattr(self.client, "recovery", None)
description = getattr(self.client, "description", {})
if description.get("attached"):
@@ -1071,6 +1086,7 @@ async def query(self, prompt) -> None:
self.next_turn_id = None
self._turn_active = True
self._managed_input_seen = False
+ self._steers.begin_turn()
try:
await client.query(prompt)
except BaseException:
@@ -1568,7 +1584,13 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None:
self._managed_input_seen
or is_managed_input(data, pending_compact=(
self._pending_compact and self._message_route_owner != "background")))
- data = self._steers.annotate(data, managed_active=managed_active)
+ data = self._steers.annotate(
+ data, managed_active=managed_active,
+ # An older service still owns its origin-only terminal
+ # ledger. Do not issue commits it cannot acknowledge.
+ result_receipts=(service_seq is None or getattr(
+ client, "description", {}).get("human_result_receipts") is True),
+ )
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
@@ -1675,11 +1697,12 @@ async def _message_pump(self, client: ClaudeSDKClient) -> None:
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
- # turn; the browser query remains pending for its later
- # human/legacy-unattributed result on the same stream.
- if origin_kind is not None and origin_kind != "human":
+ # Raw consumption receipts survive SDK parsing through
+ # the shared journal annotation. Without an exact match,
+ # an unrelated task Result cannot complete human work.
+ human_result = (is_human_result(data) if parse_raw
+ else origin_kind in (None, "human"))
+ if not human_result:
owner = "background"
elif (getattr(message, "_cc_background_ends", ())
and not self._managed_input_seen):
@@ -1873,6 +1896,14 @@ async def _receive_response_pumped(self):
finally:
self._turn_consumer_active = False
+ @property
+ def service_restart_required(self) -> bool:
+ """A confirmed dead service may be resumed, never its accepted query."""
+ from cc_remote.claude_service.client import service_owner_exited
+
+ return self.service_metadata is not None and service_owner_exited(
+ self._service_owner_identity)
+
@property
def message_pump_failed(self) -> bool:
"""Whether this client must be reconnected before another query."""
diff --git a/cc_remote/wrapper/session_ctx.py b/cc_remote/wrapper/session_ctx.py
index b632860..5992a15 100644
--- a/cc_remote/wrapper/session_ctx.py
+++ b/cc_remote/wrapper/session_ctx.py
@@ -19,6 +19,7 @@
from cc_remote.protocol import AskUser, BackgroundProcessItem, State
from cc_remote.wrapper.ringbuffer import RingBuffer
+from cc_remote.wrapper.btw_history import BtwHistory
from cc_remote.wrapper.sdk import SdkHandle
from cc_remote.wrapper.stream import StreamTranslator
from cc_remote.wrapper.turn_changes import TurnChangeTracker
@@ -234,6 +235,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
+ btw_history: BtwHistory | None = None
claude_service_background_replay: object | None = None
parent_sid: Optional[str] = None
# Relay-authenticated account identity for a private side chat. The legacy
diff --git a/docs/claude-session-service.md b/docs/claude-session-service.md
index fbb19f6..f7e8ac9 100644
--- a/docs/claude-session-service.md
+++ b/docs/claude-session-service.md
@@ -36,9 +36,17 @@ 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.
+worker is rejected. A confirmed native close clears the Wrapper's worker identity
+for a deliberate reconnect. After a service restart, the Wrapper can also resume
+the same native transcript when it proves that the previous process identity has
+exited, the socket belongs to a different process generation, and neither the old
+worker nor a replacement owning that transcript is present. An unreadable process
+identity or a live old owner is not sufficient.
+Model switching and explicit idle context refresh use this recovery before their
+control request; the next user query can recover too, without resubmitting any
+previously accepted input. Cached history/context reads never trigger it. 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
@@ -73,6 +81,16 @@ retain their independent lifetime: after the main response ends, normal prompts
are accepted while those children continue, and later native activity can start
another main continuation.
+Owners advertising `human_result_receipts` also recognize a Result whose
+`user_message_uuid` / `user_message_uuids` includes the latest consumed human
+input, even when native Code labels its origin `task-notification`. They journal
+that exact ownership before SDK parsing. Old/foreign receipts and child Results
+cannot finish the current input, and queued inputs still require their own echo.
+Older owners retain their original terminal ledger; a Wrapper-only upgrade
+does not activate this fix or make an unacknowledgeable terminal commit. Upgrade
+the service with the drain procedure below. Never resubmit an accepted query or
+restart a live owner just to clear its running indicator.
+
Native Code can also absorb a queued task notification between tool batches in
an already-running human response. Its JSONL entry is a `queued_command`
attachment; the SDK projects it as a replayed `UserMessage` with non-human origin
diff --git a/docs/claude-usage.md b/docs/claude-usage.md
new file mode 100644
index 0000000..97e08fe
--- /dev/null
+++ b/docs/claude-usage.md
@@ -0,0 +1,71 @@
+# Claude account quota
+
+The quota meter reads on session focus/reconnect, when opened or refreshed, and
+once per minute while the Claude page is visible. Returning to the page resumes
+these periodic reads. This does not send a prompt, resume an engine, modify history,
+interrupt work, or consume model tokens. Native `RateLimitEvent` updates remain
+enabled. The Wrapper coalesces reads per account with a 15-second minimum
+interval, including failures, across sessions and browser clients.
+
+The pinned Agent SDK exposes quota events but no account-usage pull method.
+Active reads therefore use an optional **operator-owned usage helper**. The
+provider/login tool retains its credentials and performs its authenticated
+read; cc-remote never reads OAuth, keychain, API key or gateway token files.
+No helper or authentication data comes from the browser. A helper must be a
+read-only query, not `claude -p`, `/usage` submitted as a chat prompt, or a tool
+that logs in, redeems credits or refreshes model conversations.
+
+Place a private, mode-0600 `claude-usage-helpers.json` in `CC_REMOTE_STATE_DIR`
+(normally `~/.cc-remote`). It is external configuration and survives upgrades.
+Use absolute paths and bind every entry to its actual native account root:
+
+```json
+{
+ "version": 1,
+ "profiles": {
+ "primary": {
+ "config_dir": "/absolute/native/account/root",
+ "command": ["/absolute/path/to/provider-usage-helper"]
+ }
+ }
+}
+```
+
+The executable runs without a shell, from the service user's home. It receives
+one JSON object on stdin: `version`, `profile_id` and `config_dir`. Only basic
+OS environment fields are inherited; model credentials and account selectors
+are not. The helper must verify that this account is the one its provider
+connection represents. Keep credentials outside arguments and output. Never
+reuse another profile's gateway token merely because the URLs match.
+
+On success, stdout contains one JSON object with the read-only OAuth usage
+shape below; exit nonzero on failure. The Wrapper limits execution to 15 seconds
+and stdout to 64 KiB. Stderr and raw errors are never shown or logged.
+
+```json
+{
+ "five_hour": {"utilization": 37, "resets_at": "2026-10-01T12:00:00Z"},
+ "seven_day": {"utilization": 12.5, "resets_at": "2026-10-05T12:00:00Z"},
+ "seven_day_opus": null,
+ "seven_day_sonnet": null
+}
+```
+
+These `utilization` values are consumed **percentages from 0 to 100**; SDK
+events instead use fractions from 0 to 1. Reset times must include a timezone;
+null means unknown. A null window clears its cached value. Unknown fields,
+account details and paid-credit balances are discarded. Newer native events
+win over older in-flight HTTP results. Invalid results do not replace valid
+observations. Cached windows expire individually at their reset time.
+
+A missing helper keeps native-event synchronization and shows that active
+reads are not configured; this does not mean the provider lacks a usage API.
+A failed read keeps still-valid observations with a
+refresh-failure notice; absent data is not reported as exhausted quota.
+The existing status wire shape is reused; Codex's app-server status, coupons
+and account authentication paths are unchanged.
+
+For a private gateway, the provider helper can GET its existing
+`/api/oauth/usage` endpoint using the gateway's own authentication and release
+requirements. Verify the actual endpoint, account binding and response before
+enabling the helper. cc-remote does not provision or change that gateway.
diff --git a/tests/test_btw_history.py b/tests/test_btw_history.py
new file mode 100644
index 0000000..58e6376
--- /dev/null
+++ b/tests/test_btw_history.py
@@ -0,0 +1,275 @@
+"""Ephemeral presentation recovery without a native history API/model call."""
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+from cc_remote.protocol import (
+ AssistantMsgEnd, Delta, Error, ProcessEvent, SyncBtw, ToolDelta, TurnBinding,
+ TurnDiff, TurnEnd, TurnPlan, TurnResult,
+ TurnSteered, UserMsg,
+)
+from cc_remote.wrapper.btw_history import BtwHistory, CodexBtwCompletions
+from cc_remote.wrapper.codex_handle import CodexHandle
+from cc_remote.wrapper.ringbuffer import RingBuffer
+from tests.test_multisession import _mk_ctx, _mk_machine
+
+
+def replay(history, *, max_bytes=1_000_000, max_events=1000):
+ return history.replay(tail_seq=100, generation="g", max_bytes=max_bytes,
+ max_events=max_events, turn_usage=[])
+
+
+def seed(history, user="human", native="native"):
+ history.observe(UserMsg(msg_id=user, prompt="read only"))
+ history.observe(TurnBinding(msg_id=user, turn_id=native))
+
+
+def completed(index, text=None):
+ return {"method": "item/completed", "params": {
+ "threadId": "fork", "turnId": "native", "item": {
+ "id": f"item-{index}", "type": "agentMessage",
+ "phase": "commentary", "text": text or f"message {index}",
+ },
+ }}
+
+
+def test_token_ring_eviction_does_not_cut_message_or_lose_user_boundary():
+ history = BtwHistory(1_000_000, 100)
+ ring = RingBuffer(4, 4096)
+ frames = [UserMsg(msg_id="human", prompt="inspect"),
+ TurnBinding(msg_id="human", turn_id="native")]
+ frames += [Delta(message_id="reply", text=str(i) + ",", channel="commentary")
+ for i in range(100)]
+ frames += [AssistantMsgEnd(message_id="reply", channel="commentary"),
+ TurnEnd(turn_id="native", result=TurnResult(
+ subtype="success", duration_ms=123, is_error=False))]
+ for seq, event in enumerate(frames, 1):
+ event.seq = seq
+ ring.append(event)
+ history.observe(event)
+ assert ring.head_seq > 100
+ restored = replay(history)
+ assert restored[0].rebuild and not restored[0].truncated
+ assert [e.msg_id for e in restored if e.type == "user_msg"] == ["human"]
+ assert [e.text for e in restored if e.type == "delta"] == [
+ "".join(str(i) + "," for i in range(100))]
+ assert all(e.seq is None for e in restored)
+ assert len(restored) == 7
+
+
+def test_replay_budget_keeps_whole_items_and_their_exact_user():
+ history = BtwHistory(20_000, 100)
+ seed(history)
+ history.observe(Delta(message_id="old", text="x" * 2000))
+ history.observe(Delta(message_id="new", text="whole newest message"))
+ history.observe(AssistantMsgEnd(message_id="new"))
+ restored = replay(history, max_bytes=1300)
+ assert restored[0].truncated
+ assert restored[1].type == "user_msg" and restored[2].type == "turn_binding"
+ assert [e.text for e in restored if e.type == "delta"] == ["whole newest message"]
+ tiny = BtwHistory(1500, 2)
+ seed(tiny)
+ for index in range(20):
+ tiny.observe(Delta(message_id=str(index), text="safe"))
+ assert tiny.truncated and tiny._bytes <= 1500 and tiny._items <= 2
+ assert replay(tiny)[1].type == "user_msg"
+
+
+def test_native_completions_repair_pre_gap_text_and_missing_items():
+ cfg = SimpleNamespace(cc_cwd="/tmp", tool_result_max=1000,
+ turn_reader_queue_cap=1)
+ handle = CodexHandle(cfg)
+ handle.btw_completions = CodexBtwCompletions(1_000_000, 100, 1000)
+ handle.btw_completions.initial_owner = "human"
+ handle._open_managed_stream()
+ handle.turn_id = "native"
+ history = BtwHistory(1_000_000, 100)
+ seed(history)
+ history.observe(Delta(message_id="item-0", text=",only the tail"))
+ history.observe(AssistantMsgEnd(message_id="item-0"))
+ for index in range(90):
+ handle._queue_managed_notification(completed(index))
+ assert handle._managed_overflow
+ history.repair("native", handle.btw_completions.snapshots("native"))
+ values = [e.text for e in replay(history) if isinstance(e, Delta)]
+ assert values == [f"message {index}" for index in range(90)]
+ # A delayed pre-terminal queue delta cannot append to a complete snapshot.
+ history.observe(Delta(message_id="item-0", text="late suffix"))
+ assert [e.text for e in replay(history) if isinstance(e, Delta)] == values
+
+
+def test_repair_never_assigns_an_ambiguous_steer_to_another_input():
+ history = BtwHistory(1_000_000, 100)
+ seed(history, "first")
+ history.observe(TurnSteered(msg_id="second", turn_id="native", prompt="next"))
+ native = CodexBtwCompletions(1_000_000, 100, 1000)
+ native.initial_owner = "first"
+ native.observe(completed(1, "belongs to first"))
+ native.initial_owner = "second"
+ native.owners["native"] = "second"
+ native.observe(completed(2, "belongs to second"))
+ native.owners["native"] = None
+ native.observe(completed(3, "ambiguous"))
+ history.repair("native", native.snapshots("native"))
+ assert list(history.turns["first"].items) == ["message:item-1"]
+ assert list(history.turns["second"].items) == ["message:item-2"]
+ assert "ambiguous" not in str(replay(history))
+
+
+def test_duplicate_native_user_lifecycle_keeps_exact_completed_item_ownership():
+ native = CodexBtwCompletions(10000, 10, 1000)
+ native.initial_owner = "human"
+ user = {"method": "item/started", "params": {"turnId": "native", "item": {
+ "type": "userMessage", "id": "native-user", "content": [{"type": "text", "text": "inspect"}],
+ }}}
+ native.observe(user)
+ native.observe({**user, "method": "item/completed"})
+ native.observe(completed(1))
+ assert native.snapshots("native")[0][0] == "human"
+ user["params"]["item"]["id"] = "another-user"
+ native.observe(user)
+ native.observe(completed(2))
+ assert native.snapshots("native")[1][0] is None
+
+
+def test_native_recovery_cache_is_bounded_and_excludes_private_reasoning():
+ native = CodexBtwCompletions(5000, 3, 1000)
+ for index in range(50):
+ native.observe(completed(index, "x" * 300))
+ assert native._bytes <= 5000
+ assert sum(len(rows) for rows in native.turns.values()) <= 3
+ event = completed(99)
+ event["params"]["item"] = {
+ "id": "private", "type": "reasoning", "summary": [],
+ "content": [{"text": "PRIVATE SECRET"}], "encryptedContent": "PRIVATE SECRET",
+ }
+ native.observe(event)
+ assert "PRIVATE SECRET" not in str(native.snapshots("native"))
+
+
+def test_partial_completion_cache_does_not_reorder_the_live_prefix():
+ history = BtwHistory(1_000_000, 100)
+ seed(history)
+ for index in [0, 1, 3]:
+ history.observe(Delta(message_id=f"item-{index}", text=f"message {index}"))
+ native = CodexBtwCompletions(10000, 3, 1000)
+ native.initial_owner = "human"
+ for index in range(5):
+ native.observe(completed(index))
+ history.repair("native", native.snapshots("native"))
+ assert [e.text for e in replay(history) if e.type == "delta"] == [
+ f"message {index}" for index in range(5)]
+
+
+def test_repair_uses_running_items_as_order_anchors_without_replacing_their_text():
+ history = BtwHistory(1_000_000, 100)
+ seed(history)
+ history.observe(Delta(message_id="item-0", text="first"))
+ history.observe(Delta(message_id="item-3", text="still streaming"))
+ native = CodexBtwCompletions(10000, 10, 1000)
+ native.initial_owner = "human"
+ for index in range(5):
+ item = completed(index)
+ if index == 3:
+ item["method"] = "item/started"
+ native.observe(item)
+ history.repair("native", native.snapshots("native"), item_order=native.item_order("native"))
+ assert [e.text for e in replay(history) if e.type == "delta"] == [
+ "message 0", "message 1", "message 2", "still streaming", "message 4"]
+
+
+def test_repair_retains_translator_normalized_ids_outside_native_order_map():
+ history = BtwHistory(10000, 10)
+ seed(history)
+ history.repair("native", [("human", [Delta(message_id="normalized", text="whole")])],
+ item_order=["native-id"])
+ assert [e.text for e in replay(history) if e.type == "delta"] == ["whole"]
+
+
+def test_process_and_large_tool_streams_replay_the_same_content():
+ history = BtwHistory(3_000_000, 100)
+ seed(history)
+ for delta in ["first ", "second"]:
+ history.observe(ProcessEvent(item_id="process", kind="command", phase="update",
+ title="Command", append_to="output", delta=delta))
+ chunks = ["a" * 400_000, "b" * 200_000, "c" * 10_000]
+ for delta in chunks:
+ history.observe(ToolDelta(tool_use_id="tool", stream="output", delta=delta))
+ restored = replay(history, max_bytes=3_000_000)
+ process = next(e for e in restored if e.type == "process")
+ assert process.output == "first second" and process.append_to is None
+ assert "".join(e.delta for e in restored if e.type == "tool_delta") == "".join(chunks)
+ assert all(len(e.delta) <= 512 * 1024 for e in restored if e.type == "tool_delta")
+
+
+def test_item_snapshot_retains_plan_diff_and_exact_turn_error():
+ history = BtwHistory(10000, 100)
+ seed(history)
+ history.observe(TurnPlan(item_id="plan", turn_id="native", plan=[]))
+ history.observe(TurnDiff(item_id="diff", turn_id="native", diff="a diff"))
+ history.observe(Error(msg_id="human", code="internal", message="failed"))
+ history.observe(Error(msg_id="unaccepted-input", code="internal", message="unrelated"))
+ restored = replay(history)
+ assert any(e.type == "turn_plan" for e in restored)
+ assert any(e.type == "turn_diff" and e.diff == "a diff" for e in restored)
+ assert [e.message for e in restored if e.type == "error"] == ["failed"]
+
+
+def test_evicted_item_does_not_reappear_as_a_mid_sentence_fragment():
+ history = BtwHistory(1500, 10)
+ seed(history)
+ history.observe(Delta(message_id="big", text="prefix" * 1000))
+ history.observe(Delta(message_id="big", text=",only a suffix"))
+ history.observe(AssistantMsgEnd(message_id="big"))
+ history.observe(Delta(message_id="new", text="A complete new item."))
+ restored = replay(history)
+ assert restored[0].truncated
+ assert [e.text for e in restored if e.type == "delta"] == ["A complete new item."]
+
+
+def test_long_stream_coalesces_bounded_chunks_and_replace_discards_old_text():
+ history = BtwHistory(1_000_000, 10)
+ seed(history)
+ for _ in range(5000):
+ history.observe(Delta(message_id="stream", text="x" * 100))
+ text = [e.text for e in replay(history) if e.type == "delta"]
+ assert "".join(text) == "x" * 500_000
+ assert len(text) < 20 and max(map(len, text)) <= 32 * 1024
+ history.observe(Delta(message_id="stream", text="replacement", replace=True))
+ history.observe(Delta(message_id="stream", text=" complete"))
+ assert [e.text for e in replay(history) if e.type == "delta"] == ["replacement complete"]
+ assert history._bytes < 2000
+
+
+def test_btw_overflow_and_sync_use_private_snapshots_without_native_history():
+ async def run():
+ machine, transport = _mk_machine()
+ ctx = _mk_ctx("btw-private")
+ ctx.engine = "codex"
+ ctx.btw = ctx.btw_announced = True
+ ctx.owner_client_id = "owner"
+ ctx.sdk = SimpleNamespace(btw_completions=CodexBtwCompletions(50000, 100, 1000))
+ ctx.sdk.btw_completions.initial_owner = "human"
+ machine.sessions[ctx.key] = ctx
+ machine._push_mirrored_history = AsyncMock(side_effect=AssertionError("durable read"))
+ for frame in [UserMsg(msg_id="human", prompt="inspect"),
+ TurnBinding(msg_id="human", turn_id="native"),
+ Delta(message_id="item-1", text="tail")]:
+ await machine._emit(ctx, frame)
+ ctx.sdk.btw_completions.observe(completed(1, "full reply"))
+ transport.sent.clear()
+ await machine._repair_codex_projection_after_overflow(ctx, "native")
+ assert all(e.owner_id == "owner" and e.sid == ctx.key for e in transport.sent)
+ assert any(e.type == "delta" and e.text == "full reply" for e in transport.sent)
+ transport.sent.clear()
+ await machine._handle_sync_btw(SyncBtw(
+ sid=ctx.key, client_id="new-tab", owner_id="owner", cursor=999999,
+ generation=machine.instance_id))
+ assert any(e.type == "user_msg" and e.msg_id == "human" for e in transport.sent)
+ assert all(e.to == "new-tab" and e.owner_id == "owner" for e in transport.sent)
+ machine._push_mirrored_history.assert_not_called()
+ transport.sent.clear()
+ ctx.owner_client_id = None
+ await machine._repair_codex_projection_after_overflow(ctx, "native")
+ assert not transport.sent
+ asyncio.run(run())
diff --git a/tests/test_claude_external.py b/tests/test_claude_external.py
index 865d8cb..f01fddb 100644
--- a/tests/test_claude_external.py
+++ b/tests/test_claude_external.py
@@ -38,6 +38,20 @@ def _watch(path) -> dict:
}
+@pytest.fixture
+def cost_state_row() -> bytes:
+ return (json.dumps({
+ "type": "cost-state",
+ "sessionId": "sid",
+ "totalCostUSD": 0.06,
+ "totalAPIDuration": 1200,
+ "totalDuration": 10000,
+ "modelUsage": {
+ "test-model": {"inputTokens": 12, "outputTokens": 3},
+ },
+ }) + "\n").encode()
+
+
def _fake_process(
root: Path,
pid: int,
@@ -1239,6 +1253,110 @@ def test_claude_growth_classifier_rejects_partial_jsonl():
) == ("unknown", ())
+@pytest.mark.parametrize("count", [1, 3])
+def test_cost_state_only_growth_is_metadata(cost_state_row, count):
+ assert classify_claude_growth(cost_state_row * count) == ("metadata", ())
+
+
+@pytest.mark.parametrize("row, expected", [
+ ({"type": "assistant", "entrypoint": "sdk-py", "uuid": "sdk-msg"},
+ ("sdk", ("sdk-msg",))),
+ ({"type": "user", "entrypoint": "cli"}, ("external", ())),
+ ({"type": "cost-state", "entrypoint": "cli"}, ("external", ())),
+ ({"type": "cost-state", "promptSource": "cli"}, ("external", ())),
+ ({"type": "user"}, ("unknown", ())),
+ ({"type": "future-metadata"}, ("unknown", ())),
+ ({"type": "ai-title"}, ("unknown", ())),
+ ({"type": "mode"}, ("unknown", ())),
+ ({"type": "permission-mode"}, ("unknown", ())),
+ ({"type": "queue-operation"}, ("unknown", ())),
+])
+def test_cost_state_does_not_hide_other_growth(cost_state_row, row, expected):
+ data = cost_state_row + (json.dumps(row) + "\n").encode() + cost_state_row
+ assert classify_claude_growth(data) == expected
+
+
+@pytest.mark.parametrize("suffix", [b'{"type":"assistant"', b"not-json\n"])
+def test_cost_state_does_not_hide_incomplete_growth(cost_state_row, suffix):
+ assert classify_claude_growth(cost_state_row + suffix) == ("unknown", ())
+ assert classify_claude_growth(cost_state_row[:-1]) == ("unknown", ())
+
+
+@pytest.mark.parametrize("foreign_owner, scan_complete, pending_reload", [
+ (False, True, False),
+ (True, True, False),
+ (False, False, False),
+ (False, True, True),
+])
+def test_cost_state_watch_preserves_ownership_protection(
+ tmp_path, cost_state_row, foreign_owner, scan_complete, pending_reload,
+):
+ async def go():
+ machine, _ = _mk_machine()
+ path = tmp_path / "session.jsonl"
+ path.write_bytes(b"")
+ ctx = _mk_ctx("sid", "sid")
+ ctx.needs_reload = pending_reload
+ invalidations = []
+ ctx.sdk = SimpleNamespace(
+ invalidate_context_usage_cache=lambda: invalidations.append(True))
+ machine.sessions["sid"] = ctx
+ watch = _watch(path)
+ machine._watch["sid"] = watch
+ mirrored = []
+
+ async def mirror(sid):
+ mirrored.append(sid)
+
+ machine._push_mirrored_history = mirror
+ path.write_bytes(cost_state_row * 3)
+ holders = {ProcessIdentity(102, 1002)} if foreign_owner else set()
+ await machine._poll_claude_watch(
+ "sid", watch, holders, 1000.0,
+ ownership_scan_complete=scan_complete,
+ )
+
+ assert watch["size"] == path.stat().st_size
+ assert ctx.needs_reload is (foreign_owner or pending_reload)
+ assert machine._is_external("sid") is (foreign_owner or not scan_complete)
+ assert bool(invalidations) is foreign_owner
+ assert bool(mirrored) is (foreign_owner or not scan_complete)
+ assert ctx.external_ts == (1000.0 if foreign_owner else 0.0)
+
+ asyncio.run(go())
+
+
+@pytest.mark.parametrize("replace_file", [False, True])
+def test_cost_state_does_not_hide_transcript_replacement(
+ tmp_path, cost_state_row, replace_file,
+):
+ async def go():
+ machine, _ = _mk_machine()
+ path = tmp_path / "session.jsonl"
+ path.write_bytes(cost_state_row * 3)
+ ctx = _mk_ctx("sid", "sid")
+ machine.sessions["sid"] = ctx
+ watch = _watch(path)
+ machine._watch["sid"] = watch
+ machine._push_mirrored_history = lambda _sid: asyncio.sleep(0)
+
+ if replace_file:
+ replacement = tmp_path / "replacement.jsonl"
+ replacement.write_bytes(cost_state_row * 3)
+ replacement.replace(path)
+ else:
+ path.write_bytes(cost_state_row)
+ await machine._poll_claude_watch(
+ "sid", watch, set(), 1000.0,
+ ownership_scan_complete=True,
+ )
+
+ assert ctx.needs_reload is True
+ assert ctx.claude_native_controls_dirty is True
+
+ asyncio.run(go())
+
+
@pytest.mark.parametrize("entrypoint, expected", [("sdk-py", "sdk"), ("cli", "external"), (None, "external")])
def test_injected_system_prompt_keeps_its_native_process_provenance(entrypoint, expected):
row = {"type": "user", "uuid": "injected", "entrypoint": entrypoint,
@@ -1690,6 +1808,62 @@ async def go():
asyncio.run(go())
+@pytest.mark.parametrize("poll_during_reconnect", [False, True])
+def test_cost_state_during_effort_reconnect_does_not_cancel_query(
+ tmp_path, monkeypatch, cost_state_row, poll_during_reconnect,
+):
+ async def go():
+ machine, transport = _mk_machine()
+ path = tmp_path / "session.jsonl"
+ path.write_bytes(b"")
+ ctx = _mk_ctx("sid", "sid")
+ ctx.state = "running"
+ ctx.active_msg_id = "msg-effort"
+ sdk = _ClaudeRunSdk()
+ sdk.effort = "high"
+ ctx.sdk = sdk
+ machine.sessions["sid"] = ctx
+ watch = _watch(path)
+ machine._watch["sid"] = watch
+ mirrored = []
+
+ async def mirror(sid):
+ mirrored.append(sid)
+
+ machine._push_mirrored_history = mirror
+
+ async def probe(_paths, _cwds):
+ return HolderScan({"sid": set()}, True)
+
+ monkeypatch.setattr(machine, "_probe_claude_holders", probe)
+ reconnect = sdk.force_reconnect
+
+ async def reconnect_with_cost_state(**kwargs):
+ await reconnect(**kwargs)
+ with path.open("ab") as stream:
+ stream.write(cost_state_row)
+ if poll_during_reconnect:
+ await machine._poll_claude_watch(
+ "sid", watch, set(), 1000.0,
+ ownership_scan_complete=True,
+ )
+
+ monkeypatch.setattr(sdk, "force_reconnect", reconnect_with_cost_state)
+ await machine._run_turn(ctx, "hello")
+
+ assert sdk.reconnects == 1
+ assert sdk.reconnect_args[0]["reason"] == "effort change"
+ assert sdk.applied_effort == "high"
+ assert sdk.queries == 1
+ assert ctx.state == "idle"
+ assert ctx.needs_reload is False
+ assert watch["size"] == path.stat().st_size
+ assert mirrored == []
+ assert not [event for event in transport.sent if event.type == "error"]
+
+ asyncio.run(go())
+
+
def test_short_external_append_is_reloaded_before_claude_query(
tmp_path, monkeypatch,
):
diff --git a/tests/test_claude_native_task_merge.py b/tests/test_claude_native_task_merge.py
index 03a99dc..bc5deb2 100644
--- a/tests/test_claude_native_task_merge.py
+++ b/tests/test_claude_native_task_merge.py
@@ -71,7 +71,8 @@ def assert_one_response(events):
@pytest.mark.asyncio
@pytest.mark.parametrize("persistent", [False, True])
@pytest.mark.parametrize("stop_reason", ["end_turn", None])
-async def test_absorbed_notifications_keep_one_main_response_and_accept_next_prompt(persistent, stop_reason):
+@pytest.mark.parametrize("receipt", [False, True])
+async def test_absorbed_notifications_keep_one_main_response_and_accept_next_prompt(persistent, stop_reason, receipt):
async with environment() as (service, attach):
client = await attach() if persistent else NativeClient()
worker = service.sessions[client.id] if persistent else None
@@ -88,7 +89,11 @@ async def test_absorbed_notifications_keep_one_main_response_and_accept_next_pro
runner = ctx.turn_task = asyncio.create_task(machine._run_turn(ctx, "check both"))
inputs = native.prompts if persistent else native.inputs
await until(lambda: len(inputs) == 1)
- for frame in response_frames(stop_reason):
+ frames = response_frames(stop_reason)
+ if receipt:
+ frames[-1].update(origin={"kind": "task-notification"},
+ user_message_uuid=HUMAN, user_message_uuids=[HUMAN])
+ for frame in frames:
await native.queue.put(frame)
await asyncio.wait_for(runner, 3)
await until(lambda: sdk._background_callbacks_pending == 0 and ctx.state == "idle")
@@ -169,6 +174,7 @@ async def project(message, _turn):
try:
await sdk.query("check both")
frames = response_frames("end_turn")
+ frames[-1].update(origin={"kind": "task-notification"}, user_message_uuids=[HUMAN])
frames[2:2] = [
task_input("channel-input", {"kind": "channel"}),
assistant("channel-answer", [{"type": "text", "text": "channel report"}]),
diff --git a/tests/test_claude_queued_prompt_history.py b/tests/test_claude_queued_prompt_history.py
index 1ca467c..c2ad804 100644
--- a/tests/test_claude_queued_prompt_history.py
+++ b/tests/test_claude_queued_prompt_history.py
@@ -184,7 +184,7 @@ def test_only_accepted_humans_on_active_ancestry_are_restored(tmp_path, compact)
"old", "human", "steer-one", "steer-two"]
-def test_v43_rebuilds_claude_projection_preserving_other_engines_and_assets(tmp_path):
+def test_v43_migration_rebuilds_projections_preserving_dsh_and_assets(tmp_path):
path = tmp_path / f"{SID}.jsonl"
write(path, transcript(True))
store = HistoryIndexStore(tmp_path / "index")
@@ -199,8 +199,9 @@ def test_v43_rebuilds_claude_projection_preserving_other_engines_and_assets(tmp_
db.execute("PRAGMA user_version=43")
reopened = HistoryIndexStore(tmp_path / "index")
assert reopened.get_page(SID, "claude", fingerprint, before=None, limit=4) is None
- for engine in ("codex", "dsh"):
- assert reopened.get_page(SID, engine, fingerprint, before=None, limit=4) is not None
+ # The later v45 migration also refreshes newer Codex message projections.
+ assert reopened.get_page(SID, "codex", fingerprint, before=None, limit=4) is None
+ assert reopened.get_page(SID, "dsh", fingerprint, before=None, limit=4) is not 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] == 3
diff --git a/tests/test_claude_rate_limits.py b/tests/test_claude_rate_limits.py
index b21fbee..c3caf85 100644
--- a/tests/test_claude_rate_limits.py
+++ b/tests/test_claude_rate_limits.py
@@ -6,8 +6,10 @@
from types import SimpleNamespace
import pytest
+from claude_agent_sdk._internal.message_parser import parse_message
from claude_agent_sdk.types import RateLimitEvent, RateLimitInfo
+from cc_remote.claude_service.wire import decode_sdk, encode_sdk
from cc_remote.wrapper.claude_rate_limits import (
ClaudeRateLimitStore,
ClaudeRateLimitStoreError,
@@ -33,12 +35,140 @@ def _info(
)
+def _unified_event(now: int, **overrides) -> RateLimitEvent:
+ return parse_message({
+ "type": "rate_limit_event",
+ "uuid": "quota-event",
+ "session_id": "native-claude",
+ "rate_limit_info": {
+ "status": "allowed",
+ "rateLimitType": "five_hour",
+ "resetsAt": now + 3_600,
+ "unifiedWindows": {
+ "five_hour": {"utilization": 0.01, "resetsAt": now + 3_600},
+ "seven_day": {"utilization": 0, "resetsAt": now + 86_400},
+ },
+ **overrides,
+ },
+ })
+
+
+@pytest.mark.parametrize("persistent_service", [False, True])
+def test_native_unified_windows_survive_sdk_projection_and_cache_reload(
+ tmp_path, persistent_service,
+):
+ now = int(time.time())
+ event = _unified_event(now, credential="must-not-persist")
+ if persistent_service:
+ event = decode_sdk(encode_sdk(event))
+ store = ClaudeRateLimitStore(tmp_path)
+ store.observe(event.rate_limit_info, now=now)
+
+ windows = {
+ window.window_duration_mins: window
+ for update in store.snapshot(now=now)
+ for window in (update.primary, update.secondary) if window is not None
+ }
+ assert {duration: window.used_percent for duration, window in windows.items()} == {
+ 300: 1, 10_080: 0,
+ }
+ assert windows[300].resets_at == now + 3_600
+ assert windows[10_080].resets_at == now + 86_400
+ reloaded = ClaudeRateLimitStore(tmp_path).snapshot(now=now + 1)
+ assert [(update.primary.used_percent, update.secondary.used_percent)
+ for update in reloaded] == [(1, 0)]
+ assert "credential" not in store.path.read_text()
+ assert "must-not-persist" not in store.path.read_text()
+
+
+@pytest.mark.parametrize("rate_type", [None, "overage", "future-window"])
+def test_unified_windows_do_not_require_a_known_top_level_limit(tmp_path, rate_type):
+ now = int(time.time())
+ store = ClaudeRateLimitStore(tmp_path)
+ update, = store.observe(_unified_event(
+ now, rateLimitType=rate_type, status="rejected",
+ ).rate_limit_info, now=now)
+
+ assert update.primary.used_percent == 1
+ assert update.secondary.used_percent == 0
+ assert update.reached_type == "", "an unrelated rejection must not exhaust these windows"
+
+
+@pytest.mark.parametrize("rejected_type", ["five_hour", "seven_day", "seven_day_opus"])
+def test_unified_rejection_applies_only_to_its_own_window(tmp_path, rejected_type):
+ now = int(time.time())
+ store = ClaudeRateLimitStore(tmp_path)
+ windows = {
+ name: {"utilization": used, "resetsAt": now + 10_000}
+ for name, used in (("five_hour", 0.01), ("seven_day", 0),
+ ("seven_day_opus", 0.23), ("seven_day_sonnet", 0.45))
+ }
+ windows["unknown"] = {"utilization": 1, "credential": "must-not-persist"}
+ updates = store.observe(_unified_event(
+ now, rateLimitType=rejected_type, status="rejected", unifiedWindows=windows,
+ ).rate_limit_info, now=now)
+ for projected in (updates, ClaudeRateLimitStore(tmp_path).snapshot(now=now)):
+ main, opus, sonnet = projected
+ assert main.primary.used_percent == (100 if rejected_type == "five_hour" else 1)
+ assert main.secondary.used_percent == (100 if rejected_type == "seven_day" else 0)
+ assert main.reached_type == (rejected_type if rejected_type != "seven_day_opus" else "")
+ assert opus.limit_id == "claude-seven-day-opus"
+ assert opus.primary.used_percent == (100 if rejected_type == "seven_day_opus" else 23)
+ assert sonnet.primary.used_percent == 45 and sonnet.reached_type == ""
+ assert "unknown" not in store.path.read_text()
+ assert "credential" not in store.path.read_text()
+
+
+@pytest.mark.parametrize("unified", [None, [], "invalid", {
+ "five_hour": None, "seven_day": [], "seven_day_opus": {},
+}])
+def test_malformed_unified_shapes_retain_legacy_fields(tmp_path, unified):
+ now = int(time.time())
+ update, = ClaudeRateLimitStore(tmp_path).observe(_unified_event(
+ now, utilization=0.25, unifiedWindows=unified,
+ ).rate_limit_info, now=now)
+ assert update.primary.used_percent == 25 and update.secondary is None
+
+
+@pytest.mark.parametrize("used", [None, False, -0.1, 1.1, "0.5", float("nan"), float("inf")])
+def test_unified_utilization_is_validated_without_guessing_units(tmp_path, used):
+ now = int(time.time())
+ update, = ClaudeRateLimitStore(tmp_path).observe(_unified_event(
+ now, utilization=0.75, unifiedWindows={
+ "five_hour": {"utilization": used, "resetsAt": now + 1_000},
+ "seven_day": {"utilization": 0, "resetsAt": now + 10_000},
+ },
+ ).rate_limit_info, now=now)
+ assert update.primary.used_percent is None
+ assert update.secondary.used_percent == 0
+
+
+def test_unified_windows_expire_independently_and_keep_unmentioned_buckets(tmp_path):
+ now = int(time.time())
+ store = ClaudeRateLimitStore(tmp_path)
+ store.observe(_info("seven_day_opus", resets_at=now + 20_000), now=now)
+ update, = store.observe(_unified_event(now, utilization=0.9).rate_limit_info, now=now)
+ assert update.primary.used_percent == 1, "nested utilization overrides the legacy field"
+ event = _unified_event(now, unifiedWindows={
+ "five_hour": {"utilization": 0.8, "resetsAt": now},
+ "seven_day": {"utilization": 0, "resetsAt": now + 10_000},
+ })
+ update, = store.observe(event.rate_limit_info, now=now)
+ assert update.primary is None and update.secondary.used_percent == 0
+ main, opus = ClaudeRateLimitStore(tmp_path).snapshot(now=now)
+ assert main.primary is None and main.secondary.used_percent == 0
+ assert opus.primary.used_percent == 37
+ assert [row.limit_id for row in store.snapshot(now=now + 10_000)] == [
+ "claude-seven-day-opus",
+ ]
+
+
def test_claude_rate_limit_store_sanitizes_and_replays_windows(tmp_path):
now = int(time.time())
store = ClaudeRateLimitStore(tmp_path)
- five_hour = store.observe(
+ five_hour, = store.observe(
_info("five_hour", resets_at=now + 10_000), now=now)
- weekly = store.observe(
+ weekly, = store.observe(
_info(
"seven_day", resets_at=now + 20_000, utilization=0.82,
status="rejected",
@@ -62,8 +192,7 @@ def test_claude_rate_limit_store_sanitizes_and_replays_windows(tmp_path):
restored = ClaudeRateLimitStore(tmp_path).snapshot(now=now + 1)
assert [(event.limit_id, bool(event.primary), bool(event.secondary))
for event in restored] == [
- ("claude", True, False),
- ("claude", False, True),
+ ("claude", True, True),
]
@@ -71,11 +200,11 @@ def test_claude_rate_limit_store_handles_specialized_expiry_and_bad_usage(
tmp_path,
):
store = ClaudeRateLimitStore(tmp_path)
- opus = store.observe(
+ opus, = store.observe(
_info("seven_day_opus", resets_at=11_000, utilization=1.5),
now=10_000,
)
- sonnet = store.observe(
+ sonnet, = store.observe(
_info("seven_day_sonnet", resets_at=12_000, utilization=None),
now=10_000,
)
@@ -94,7 +223,7 @@ def test_claude_rejection_without_reset_or_usage_remains_visible(tmp_path):
now = int(time.time())
store = ClaudeRateLimitStore(tmp_path)
- rejected = store.observe(_info(
+ rejected, = store.observe(_info(
"five_hour",
resets_at=None,
utilization=None,
@@ -125,7 +254,7 @@ def test_claude_allowed_without_reset_clears_rejection_state(tmp_path):
status="rejected",
), now=now)
- allowed = store.observe(_info(
+ allowed, = store.observe(_info(
"seven_day",
resets_at=None,
utilization=None,
@@ -141,10 +270,10 @@ def test_claude_allowed_without_reset_clears_rejection_state(tmp_path):
def test_claude_rate_limit_store_ignores_unknown_or_stale_events(tmp_path):
store = ClaudeRateLimitStore(tmp_path)
- assert store.observe(_info("overage"), now=10_000) is None
+ assert store.observe(_info("overage"), now=10_000) == ()
assert store.observe(
_info("five_hour", resets_at=10_000), now=10_000,
- ) is None
+ ) == ()
assert store.snapshot(now=10_000) == ()
@@ -168,7 +297,8 @@ def _event(rate_type: str, utilization: float) -> RateLimitEvent:
)
-def test_machine_publishes_sdk_rate_limits_to_every_resident_claude_session():
+@pytest.mark.parametrize("unified", [False, True])
+def test_machine_publishes_sdk_rate_limits_to_every_resident_claude_session(unified):
async def run():
machine, transport = _mk_machine()
code = _mk_ctx("claude-code", "claude-code")
@@ -183,13 +313,15 @@ async def run():
}
assert await machine._observe_claude_rate_limit_message(
- code, _event("five_hour", 0.25)) is True
+ code, _unified_event(int(time.time())) if unified else _event("five_hour", 0.25)) is True
published = [message for message in transport.sent
if message.type == "rate_limit_update"]
assert {message.sid for message in published} == {
"claude-code", "claude-work",
}
- assert all(message.primary.used_percent == 25 for message in published)
+ assert all(message.primary.used_percent == (1 if unified else 25) for message in published)
+ if unified:
+ assert all(message.secondary.used_percent == 0 for message in published)
assert code.buffer.tail_seq == 0 and work.buffer.tail_seq == 0
transport.sent.clear()
@@ -206,13 +338,14 @@ async def run():
asyncio.run(run())
-def test_hello_reseeds_unexpired_claude_limits_without_a_model_probe():
+@pytest.mark.parametrize("unified", [False, True])
+def test_hello_reseeds_unexpired_claude_limits_without_a_model_probe(unified):
async def run():
machine, transport = _mk_machine()
ctx = _mk_ctx("claude-session", "claude-session")
machine.sessions[ctx.key] = ctx
await machine._observe_claude_rate_limit_message(
- ctx, _event("five_hour", 0.4))
+ ctx, _unified_event(int(time.time())) if unified else _event("five_hour", 0.4))
transport.sent.clear()
await machine._handle_client_hello(SimpleNamespace(
@@ -226,13 +359,16 @@ async def run():
assert limits[0].sid == "claude-session"
assert limits[0].to == "client-1"
assert limits[0].route_id == "route-1"
- assert limits[0].primary.used_percent == 40
+ assert limits[0].primary.used_percent == (1 if unified else 40)
+ if unified:
+ assert limits[0].secondary.used_percent == 0
asyncio.run(run())
+@pytest.mark.parametrize("unified", [False, True])
def test_machine_keeps_rate_limits_inside_claude_profile(
- tmp_path, monkeypatch,
+ tmp_path, monkeypatch, unified,
):
async def run():
personal_root = tmp_path / "personal"
@@ -267,7 +403,7 @@ async def run():
}
assert await machine._observe_claude_rate_limit_message(
- company, _event("five_hour", 0.6),
+ company, _unified_event(int(time.time())) if unified else _event("five_hour", 0.6),
) is True
published = [
@@ -278,6 +414,8 @@ async def run():
assert await machine._claude_rate_limit_snapshot(personal) == ()
company_snapshot = await machine._claude_rate_limit_snapshot(company)
assert len(company_snapshot) == 1
- assert company_snapshot[0].primary.used_percent == 60
+ assert company_snapshot[0].primary.used_percent == (1 if unified else 60)
+ if unified:
+ assert company_snapshot[0].secondary.used_percent == 0
asyncio.run(run())
diff --git a/tests/test_claude_service_restart.py b/tests/test_claude_service_restart.py
new file mode 100644
index 0000000..53eccc1
--- /dev/null
+++ b/tests/test_claude_service_restart.py
@@ -0,0 +1,227 @@
+"""A new service generation can resume a transcript, never an accepted input."""
+
+import asyncio
+import os
+from contextlib import asynccontextmanager
+
+import pytest
+
+from cc_remote.claude_service import client as client_module
+from cc_remote.claude_service.client import RemoteClient, service_owner_exited
+from cc_remote.config import WrapperConfig
+from cc_remote.protocol import ContextReport, Error, GetContext, SetModel, TurnEnd
+from cc_remote.wrapper import process_scan
+from cc_remote.wrapper.process_scan import ProcessIdentity
+from cc_remote.wrapper.sdk import SdkHandle
+from tests.test_claude_autocompact import SESSION_ID, _machine_with_sdk
+from tests.test_claude_live_context import SUMMARY
+from tests.test_claude_service import FakeClient, environment
+from tests.test_claude_service_delivery import wait_until
+
+
+@asynccontextmanager
+async def restarted_service(monkeypatch):
+ # Same PID with a new start time also covers PID reuse. No real daemon or
+ # model is touched: only the local Unix transport and service are real.
+ generation = [1]
+ def identity(pid):
+ return ProcessIdentity(pid, generation[0])
+ monkeypatch.setattr(process_scan, "process_identity", identity)
+ controls = []
+
+ async def control(client, request, timeout):
+ controls.append(request)
+ return dict(SUMMARY) if request["subtype"] == "get_context_usage" else {}
+
+ monkeypatch.setattr(FakeClient, "_send_control_request", control, raising=False)
+ async with environment() as (service, _attach):
+ sdk = SdkHandle(WrapperConfig(
+ claude_service_socket=str(service.directory / "service.sock")))
+ machine, transport, ctx = _machine_with_sdk(sdk)
+ sdk.service_metadata = {
+ "profile_root": str(service.directory / "profile"),
+ "session_id": SESSION_ID, "cwd": ctx.cwd, "space": "code",
+ }
+ await sdk.connect(resume_id=SESSION_ID, cwd=ctx.cwd)
+ first = sdk.client
+ old_worker = service.sessions[first.id]
+ # It is deliberately not completed. Restart recovery must not try to
+ # finish it by submitting its prompt again to the new service.
+ first.next_turn = {"id": "old-input", "prompt": "accepted before restart"}
+ await first.query("accepted before restart")
+ await first.detach()
+ await old_worker.close()
+ service.sessions.clear()
+ generation[0] += 1
+ await wait_until(lambda: sdk.message_pump_failed)
+ try:
+ yield sdk, machine, transport, ctx, service, old_worker, controls
+ finally:
+ await sdk.detach_for_shutdown()
+
+
+@pytest.mark.asyncio
+async def test_model_switch_after_service_restart_resumes_exact_session(monkeypatch):
+ async with restarted_service(monkeypatch) as (
+ sdk, machine, _, ctx, service, old, controls,
+ ):
+ assert sdk.service_restart_required
+ reply = await machine._handle_set_model(SetModel(sid=SESSION_ID, model="sonnet-5.5"))
+ assert not isinstance(reply, Error)
+ assert sdk.model == "sonnet-5.5"
+ assert controls == [{"subtype": "set_model", "model": "sonnet-5.5"}]
+ worker = service.sessions[sdk.client.id]
+ assert worker.id != old.id
+ assert sdk.service_metadata["service_id"] == worker.id
+ assert worker.client.options.resume == SESSION_ID
+ assert worker.client.options.cwd == ctx.cwd
+ assert worker.metadata["profile_root"] == old.metadata["profile_root"]
+ assert old.client.prompts == ["accepted before restart"]
+ assert worker.client.prompts == []
+ assert not sdk.service_restart_required
+
+
+@pytest.mark.asyncio
+async def test_context_refresh_recovers_but_cached_read_does_not(monkeypatch):
+ async with restarted_service(monkeypatch) as (
+ sdk, machine, _, ctx, service, old, controls,
+ ):
+ sdk.remember_recent_context_usage(dict(SUMMARY))
+ await machine._handle_get_context(GetContext(sid=SESSION_ID))
+ assert not service.sessions and controls == []
+ report = await machine._handle_get_context(GetContext(sid=SESSION_ID, refresh=True))
+ assert isinstance(report, ContextReport) and report.source == "control"
+ assert report.total_tokens == SUMMARY["totalTokens"]
+ assert service.sessions[sdk.client.id].client.prompts == []
+ assert controls == [{"subtype": "get_context_usage", "detail": "summary"}]
+ assert old.client.prompts == ["accepted before restart"]
+
+
+@pytest.mark.asyncio
+async def test_new_query_recovers_after_service_died_during_ack(monkeypatch):
+ async with restarted_service(monkeypatch) as (
+ sdk, machine, transport, ctx, service, old, _,
+ ):
+ sdk._service_delivery_error = ConnectionError("old service died during ACK")
+ ctx.active_msg_id = "new-input"
+ ctx.state = "running"
+
+ async def query(client, prompt):
+ client.prompts.append(prompt)
+ await client.queue.put({
+ "type": "result", "subtype": "success", "duration_ms": 20,
+ "duration_api_ms": 19, "is_error": False, "num_turns": 1,
+ "session_id": SESSION_ID,
+ })
+
+ monkeypatch.setattr(FakeClient, "query", query)
+ await asyncio.wait_for(machine._run_turn(ctx, "new explicit question"), 3)
+ worker = service.sessions[sdk.client.id]
+ assert worker.client.prompts == ["new explicit question"]
+ assert old.client.prompts == ["accepted before restart"]
+ assert len([frame for frame in transport.sent if isinstance(frame, TurnEnd)]) == 1
+ assert not [frame for frame in transport.sent if isinstance(frame, Error)]
+ assert worker.turn is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("blocked", ["active", "external", "callback"])
+async def test_model_recovery_yields_to_active_or_external_work(monkeypatch, blocked):
+ async with restarted_service(monkeypatch) as (
+ sdk, machine, _, ctx, service, old, controls,
+ ):
+ if blocked == "active":
+ ctx.state = "running"
+ elif blocked == "external":
+ async def external(_sid):
+ return True
+ machine._prime_claude_ownership = external
+ else:
+ sdk._background_callbacks_pending = 1
+ reply = await machine._handle_set_model(SetModel(sid=SESSION_ID, model="sonnet-5.5"))
+ assert isinstance(reply, Error)
+ assert not service.sessions and controls == []
+ assert sdk.service_metadata["service_id"] == old.id
+
+
+@pytest.mark.asyncio
+async def test_unavailable_service_keeps_restart_identity_for_later_retry(monkeypatch):
+ async with restarted_service(monkeypatch) as (
+ sdk, machine, _, ctx, service, old, controls,
+ ):
+ connect = client_module.Connection.connect
+
+ async def unavailable(_self):
+ raise ConnectionRefusedError("service is starting")
+
+ monkeypatch.setattr(client_module.Connection, "connect", unavailable)
+ reply = await machine._handle_set_model(SetModel(sid=SESSION_ID, model="sonnet-5.5"))
+ assert isinstance(reply, Error)
+ assert sdk.service_restart_required
+ assert sdk.service_metadata["service_id"] == old.id
+ assert not service.sessions and controls == []
+ monkeypatch.setattr(client_module.Connection, "connect", connect)
+ reply = await machine._handle_set_model(SetModel(sid=SESSION_ID, model="sonnet-5.5"))
+ assert not isinstance(reply, Error)
+ assert service.sessions[sdk.client.id].client.prompts == []
+
+
+@pytest.mark.asyncio
+async def test_restart_does_not_take_over_a_replacement_worker(monkeypatch):
+ async with restarted_service(monkeypatch) as (
+ sdk, machine, _, ctx, service, old, controls,
+ ):
+ another = RemoteClient(sdk.client.connection.socket_path, options=sdk.client.options,
+ metadata={**old.metadata, "service_id": None})
+ try:
+ await another.connect()
+ another.next_turn = {"id": "another-input", "prompt": "another controller"}
+ await another.query("another controller")
+ await another.detach()
+ reply = await machine._handle_set_model(SetModel(sid=SESSION_ID, model="sonnet-5.5"))
+ assert isinstance(reply, Error)
+ assert list(service.sessions) == [another.id]
+ worker = service.sessions[another.id]
+ assert worker.turn["id"] == "another-input"
+ assert worker.client.prompts == ["another controller"]
+ assert not worker.client.closed and not controls
+ finally:
+ await another.detach()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("owner", ["live", "unknown", "unreadable"])
+async def test_missing_worker_is_not_recreated_without_proven_restart(monkeypatch, owner):
+ async with environment() as (service, attach):
+ first = await attach()
+ first.options.resume = "native-session"
+ identity = first.owner_identity if owner != "unknown" else None
+ if owner == "unreadable":
+ monkeypatch.setattr(process_scan, "process_identity", lambda _pid: None)
+ client = RemoteClient(first.connection.socket_path, options=first.options,
+ metadata={**first.metadata, "service_id": "missing-worker"},
+ previous_owner_identity=identity)
+ try:
+ with pytest.raises(RuntimeError, match="KeyError"):
+ await client.connect()
+ assert list(service.sessions) == [first.id]
+ assert service.sessions[first.id].client.prompts == []
+ finally:
+ await client.detach()
+
+
+@pytest.mark.parametrize("probe", ["alive", "denied", "absent", "reused"])
+def test_service_owner_exit_proof_distinguishes_unknown_pid(monkeypatch, probe):
+ expected = ProcessIdentity(os.getpid(), 1)
+ monkeypatch.setattr(process_scan, "process_identity", lambda pid: (
+ ProcessIdentity(pid, 2) if probe == "reused" else None))
+
+ def kill(pid, signal):
+ assert pid == expected.pid and signal == 0
+ if probe == "denied":
+ raise PermissionError()
+ if probe == "absent":
+ raise ProcessLookupError()
+
+ monkeypatch.setattr(client_module.os, "kill", kill)
+ assert service_owner_exited(expected) == (probe in {"absent", "reused"})
diff --git a/tests/test_claude_steering.py b/tests/test_claude_steering.py
index 2976b54..c284b21 100644
--- a/tests/test_claude_steering.py
+++ b/tests/test_claude_steering.py
@@ -72,6 +72,93 @@ def requesting(uid="request"):
return {"type": "system", "subtype": "status", "status": "requesting", "uuid": uid}
+def test_result_receipt_requires_latest_consumed_input_and_preserves_pending_steers():
+ from cc_remote.claude_steering import is_human_result
+
+ steers = PendingSteers()
+ steers.annotate(user("first"), managed_active=True)
+ steers.add("second", {"id": "second-remote"})
+ value = steers.annotate({**result(), "origin": ORIGIN,
+ "user_message_uuids": ["first"]}, managed_active=True)
+ assert is_human_result(value) and value["__cc_steer_intermediate"]
+ assert "second" in steers.pending
+ steers.annotate(user("second"), managed_active=True)
+ for fields in ({"user_message_uuid": "first"},
+ {"user_message_uuids": ["foreign"]},
+ {"user_message_uuid": "second", "parent_tool_use_id": "child"}):
+ value = steers.annotate({**result(), "origin": ORIGIN, **fields}, managed_active=True)
+ assert not is_human_result(value)
+
+ value = steers.annotate({**result(), "origin": ORIGIN,
+ "user_message_uuids": ["first", "second"]}, managed_active=True)
+ assert is_human_result(value) and not value.get("__cc_steer_intermediate")
+ # A duplicate receipt or the next task-only Result cannot claim a new query.
+ steers.begin_turn()
+ for origin in (None, ORIGIN):
+ value = steers.annotate({**result(), "origin": origin, "user_message_uuid": "second"})
+ assert not is_human_result(value)
+
+
+def test_receipts_do_not_change_older_service_terminal_ledger():
+ from cc_remote.claude_steering import is_human_result
+
+ steers = PendingSteers()
+ steers.annotate(user("human"), managed_active=True)
+ value = steers.annotate({**result(), "origin": ORIGIN, "user_message_uuid": "human"},
+ managed_active=True, result_receipts=False)
+ assert "__cc_managed_result" not in value and not is_human_result(value)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("persistent", [False, True])
+async def test_task_origin_result_consumes_exact_steer_and_commits_original_root(persistent):
+ async with environment() as (service, attach):
+ client = await attach() if persistent else NativeClient()
+ worker = service.sessions[client.id] if persistent else None
+ native = worker.client if worker else client
+ sdk = SdkHandle(WrapperConfig(turn_reader_queue_cap=1))
+ sdk.client = client
+ sdk._start_message_pump()
+ consumer = None
+ try:
+ sdk.next_turn_id = "root"
+ await sdk.query("first")
+
+ async def read():
+ return [m async for m in sdk.receive_response()]
+
+ consumer = asyncio.create_task(read())
+ await native.queue.put(user("human", "first"))
+ await until(lambda: sdk._managed_input_seen)
+ await sdk.steer("guide", native_id="native-guide", metadata={"id": "guide"})
+ await native.queue.put({**result(), "origin": ORIGIN, "user_message_uuid": "human"})
+ await native.queue.put(user("native-guide", "guide"))
+ await until(lambda: not sdk._steers.pending)
+ assert not consumer.done()
+ if worker:
+ assert worker.terminal_seq is None and worker.turn["id"] == "root"
+ await native.queue.put({**result(), "origin": ORIGIN,
+ "user_message_uuids": ["native-guide"], "queued_turn_count": 0})
+ messages = await asyncio.wait_for(consumer, 3)
+ assert isinstance(messages[-1], ResultMessage)
+ await sdk.ack_service_message(messages[-1], turn_id="root")
+ sdk.release_background_messages()
+ if worker:
+ assert worker.turn is None
+ sdk.next_turn_id = "next"
+ await sdk.query("next")
+ await native.queue.put(user("next-native", "next"))
+ await native.queue.put(result())
+ messages = await asyncio.wait_for(read(), 3)
+ await sdk.ack_service_message(messages[-1], turn_id="next")
+ assert native.interrupts == 0
+ finally:
+ if consumer:
+ consumer.cancel()
+ await asyncio.gather(consumer, return_exceptions=True)
+ await sdk._stop_message_pump()
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize("persistent", [False, True])
@pytest.mark.parametrize("finish", ["result", "steer", "stop"])
diff --git a/tests/test_claude_usage.py b/tests/test_claude_usage.py
new file mode 100644
index 0000000..3cb6fb9
--- /dev/null
+++ b/tests/test_claude_usage.py
@@ -0,0 +1,166 @@
+"""Active quota reads never create, resume, interrupt or write a model turn."""
+import asyncio
+from datetime import datetime, timezone
+import json
+import sys
+import time
+
+import pytest
+
+from cc_remote.claude_profiles import ClaudeProfile
+from cc_remote.protocol import GetStatus, StatusReport
+from cc_remote.wrapper import claude_usage
+from cc_remote.wrapper.claude_rate_limits import ClaudeRateLimitStore
+from cc_remote.wrapper.claude_usage import ClaudeUsageError, ClaudeUsageReader
+from tests.test_claude_rate_limits import _event
+from tests.test_multisession import _mk_ctx, _mk_machine
+
+
+def usage(value=37):
+ return {"five_hour": {"utilization": value, "resets_at": datetime.fromtimestamp(
+ time.time() + 3600, timezone.utc).isoformat()},
+ "seven_day": {"utilization": 0, "resets_at": None},
+ "seven_day_sonnet": None, "ignored_secret": "never-copy"}
+
+
+def configure(reader, profile, command):
+ reader.path.parent.mkdir(parents=True, exist_ok=True)
+ reader.path.write_text(json.dumps({"version": 1, "profiles": {
+ profile.id: {"config_dir": str(profile.config_dir), "command": command},
+ }}))
+ reader.path.chmod(0o600)
+
+
+def test_usage_percentages_and_null_windows_are_not_sdk_fractions(tmp_path):
+ store = ClaudeRateLimitStore(tmp_path)
+ store.observe(_event("seven_day_sonnet", .8).rate_limit_info)
+ store.observe_usage(usage(1), dict(store.revisions))
+ row, = store.snapshot()
+ assert row.primary.used_percent == 1
+ assert row.secondary.used_percent == 0
+ assert "never-copy" not in store.path.read_text()
+ assert "seven_day_sonnet" not in store.path.read_text()
+ restored, = ClaudeRateLimitStore(tmp_path).snapshot()
+ assert restored.primary == row.primary and restored.secondary == row.secondary
+
+
+@pytest.mark.parametrize("invalid", [True, "5", -1, 101, float("nan"), None])
+def test_invalid_usage_is_atomic(tmp_path, invalid):
+ store = ClaudeRateLimitStore(tmp_path)
+ store.observe_usage(usage(), {})
+ original = store.path.read_bytes()
+ bad = usage(50)
+ bad["seven_day"] = {"utilization": invalid}
+ with pytest.raises(ValueError):
+ store.observe_usage(bad, {})
+ assert store.path.read_bytes() == original
+ assert store.snapshot()[0].primary.used_percent == 37
+
+
+def test_new_native_observation_wins_over_inflight_snapshot(tmp_path):
+ store = ClaudeRateLimitStore(tmp_path)
+ before = dict(store.revisions)
+ store.observe(_event("five_hour", .65).rate_limit_info)
+ store.observe_usage(usage(1), before)
+ row, = store.snapshot()
+ assert row.primary.used_percent == 65
+ assert row.secondary.used_percent == 0
+
+
+@pytest.mark.asyncio
+async def test_real_helper_receives_only_profile_binding(monkeypatch, tmp_path):
+ reader = ClaudeUsageReader(tmp_path)
+ profile = ClaudeProfile("company", "Company", tmp_path / "native")
+ monkeypatch.setenv("WRAPPER_TOKEN", "must-not-reach-helper")
+ monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "wrong-account")
+ code = ("import json,os,sys; d=json.load(sys.stdin); "
+ "assert d['profile_id']=='company'; "
+ "assert 'WRAPPER_TOKEN' not in os.environ; "
+ "assert 'CLAUDE_CODE_OAUTH_TOKEN' not in os.environ; "
+ "print(json.dumps({'seven_day':{'utilization':0,'resets_at':None}}))")
+ configure(reader, profile, [sys.executable, "-c", code])
+ seen = []
+ async def apply(read):
+ seen.append(await read())
+ assert await reader.refresh(profile, apply) is None
+ assert seen == [{"seven_day": {"utilization": 0, "resets_at": None}}]
+ other = ClaudeProfile(profile.id, profile.label, tmp_path / "other-account")
+ with pytest.raises(ClaudeUsageError, match="configuration invalid"):
+ await reader.refresh(other, apply)
+ reader.path.chmod(0o644)
+ with pytest.raises(ClaudeUsageError, match="configuration invalid"):
+ await reader.refresh(profile, apply)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("mode", ["large", "fail", "timeout"])
+async def test_helper_bounds_output_time_and_private_errors(monkeypatch, tmp_path, mode):
+ reader = ClaudeUsageReader(tmp_path)
+ profile = ClaudeProfile("primary", "Primary", tmp_path)
+ code = {"large": "print('secret' * 30000)",
+ "fail": "import sys; print('private'); sys.exit(1)",
+ "timeout": "import time; time.sleep(10)"}[mode]
+ configure(reader, profile, [sys.executable, "-c", code])
+ monkeypatch.setattr(claude_usage, "TIMEOUT", .2)
+ async def apply(read):
+ await read()
+ error = await reader.refresh(profile, apply)
+ assert error and "secret" not in error and "private" not in error
+
+
+@pytest.mark.asyncio
+async def test_busy_session_read_and_parallel_sessions_share_one_get(monkeypatch, tmp_path):
+ machine, transport = _mk_machine()
+ reader = machine._claude_usage_reader = ClaudeUsageReader(tmp_path)
+ profile = machine._claude_profiles.default
+ configure(reader, profile, [sys.executable])
+ one, two = _mk_ctx("one", "one"), _mk_ctx("two", "two")
+ one.state = "running"
+ one.active_msg_id = "active-user"
+ # Any access to SDK controls or query would fail; an active worker is not
+ # necessary for this read and must not be created as a side effect.
+ one.sdk = two.sdk = None
+ machine.sessions = {"one": one, "two": two}
+ calls = []
+ async def read(profile, command):
+ calls.append(profile.id)
+ await asyncio.sleep(.01)
+ await machine._observe_claude_rate_limit_message(one, _event("five_hour", .65))
+ return usage(1)
+ monkeypatch.setattr(reader, "_read", read)
+ reports = await asyncio.gather(*(machine._handle_get_status(GetStatus(
+ sid=sid, cmd_id="request-" + sid, client_id="browser-" + sid))
+ for sid in ("one", "two")))
+ assert calls == [profile.id]
+ for report, sid in zip(reports, ("one", "two")):
+ assert isinstance(report, StatusReport)
+ assert not report.component_errors
+ assert report.rate_limits[0].primary.used_percent == 65
+ assert report.rate_limits[0].secondary.used_percent == 0
+ assert report.to == "browser-" + sid
+ assert report.request_id == "request-" + sid
+ assert one.state == "running" and one.active_msg_id == "active-user"
+ assert one.buffer.tail_seq == two.buffer.tail_seq == 0
+ assert {e.type for e in transport.sent} <= {"status_report", "rate_limit_update"}
+
+
+@pytest.mark.asyncio
+async def test_failed_or_unconfigured_read_retains_native_cache(monkeypatch, tmp_path):
+ machine, _ = _mk_machine()
+ reader = machine._claude_usage_reader = ClaudeUsageReader(tmp_path)
+ profile = machine._claude_profiles.default
+ ctx = _mk_ctx("session", "session")
+ machine.sessions = {ctx.key: ctx}
+ await machine._observe_claude_rate_limit_message(ctx, _event("five_hour", .42))
+ request = GetStatus(sid=ctx.key, cmd_id="read", client_id="browser")
+ report = await machine._handle_get_status(request)
+ assert report.component_errors == ["rate_limits: usage helper unavailable"]
+ assert report.rate_limits[0].primary.used_percent == 42
+ configure(reader, profile, [sys.executable])
+ async def malformed(*args):
+ return {"error": "private upstream detail"}
+ monkeypatch.setattr(reader, "_read", malformed)
+ report = await machine._handle_get_status(request)
+ assert report.component_errors == ["rate_limits: usage response invalid"]
+ assert report.rate_limits[0].primary.used_percent == 42
+ assert "private" not in report.model_dump_json()
diff --git a/tests/test_codex_controls.py b/tests/test_codex_controls.py
index f5379b7..ba0d2ab 100644
--- a/tests/test_codex_controls.py
+++ b/tests/test_codex_controls.py
@@ -6932,6 +6932,116 @@ async def receive_response(self):
asyncio.run(run())
+@pytest.mark.parametrize("initial_echo", [False, True])
+def test_managed_codex_turn_projects_external_steers_once(initial_echo):
+ async def run():
+ machine, transport = _mk_machine()
+ ctx = _mk_ctx("managed-steers", "managed-steers")
+ ctx.engine = "codex"
+ ctx.state = "running"
+ ctx.active_msg_id = "browser-original"
+ ctx.turn_task = asyncio.current_task()
+ machine.sessions[ctx.key] = ctx
+ observations = []
+ first_steer_output = asyncio.Event()
+ original_send = transport.send
+
+ async def observe(message):
+ if isinstance(message, Delta):
+ lease = machine._codex_turn_leases.get(ctx.key)
+ observations.append((
+ message.text, ctx.state, ctx.active_msg_id,
+ ctx.active_turn_binding.msg_id,
+ lease.msg_id if lease else None,
+ ))
+ if message.text == "one":
+ first_steer_output.set()
+ await original_send(message)
+
+ transport.send = observe
+
+ def user(item_id, client_id, *, turn_id="native-turn", method="item/completed"):
+ return {"method": method, "params": {
+ "threadId": ctx.session_id, "turnId": turn_id,
+ "item": {"type": "userMessage", "id": item_id,
+ "clientId": client_id, "text": "same prompt"},
+ }}
+
+ def delta(name):
+ return {"method": "item/agentMessage/delta", "params": {
+ "threadId": ctx.session_id, "turnId": "native-turn",
+ "itemId": "answer-" + name, "delta": name,
+ }}
+
+ class AcceptedSdk:
+ tier_dirty = False
+ model = None
+ effort = None
+ collaboration_mode = "default"
+ service_tier = None
+ shared_daemon_affinity = True
+
+ async def query(self, _prompt, images=None, *, client_user_message_id=None):
+ assert client_user_message_id == "browser-original"
+ return "native-turn"
+
+ async def receive_response(self):
+ # An unlabelled first echo cannot prove an external steer.
+ yield user("unattributed", None)
+ if initial_echo:
+ yield user("native-original", "browser-original")
+ yield delta("original")
+ yield user("foreign-user", "foreign-client", turn_id="other-turn")
+ yield user("native-one", "external-one", method="item/started")
+ yield user("native-one", "external-one")
+ yield delta("one")
+ await first_steer_output.wait()
+ # Simulate _handle_steer's already-published RPC acceptance.
+ accepted = TurnSteered(msg_id="remote-steer", turn_id="native-turn",
+ prompt="same prompt")
+ ctx.active_msg_id = accepted.msg_id
+ machine._remember_codex_published_steer(ctx, accepted)
+ machine._rebind_codex_turn(ctx, accepted.turn_id, accepted.msg_id)
+ await machine._emit(ctx, accepted)
+ yield user("native-two", "external-two", method="item/started")
+ yield user("native-one", "external-one")
+ yield user("native-original", "browser-original")
+ yield user("native-remote", "remote-steer")
+ yield user("native-two", "external-two")
+ yield delta("two")
+ yield {"method": "turn/completed", "params": {
+ "threadId": ctx.session_id,
+ "turn": {"id": "native-turn", "status": "completed"},
+ }}
+
+ ctx.sdk = AcceptedSdk()
+ machine._ensure_codex_daemon_generation = (
+ lambda *_args, **_kwargs: asyncio.sleep(0, result=True))
+ machine._begin_codex_checkpoint = lambda _ctx: asyncio.sleep(0)
+ machine._accept_codex_checkpoint = lambda _ctx: asyncio.sleep(0)
+ await asyncio.wait_for(machine._run_turn(ctx, "same prompt"), timeout=2)
+
+ assert observations == [
+ ("original", "running", "browser-original", "browser-original", "browser-original"),
+ ("one", "running", "external-one", "external-one", "external-one"),
+ ("two", "running", "external-two", "external-two", "external-two"),
+ ]
+ assert [(event.msg_id, event.turn_id) for event in transport.sent
+ if isinstance(event, TurnSteered)] == [
+ ("external-one", "native-turn"), ("remote-steer", "native-turn"),
+ ("external-two", "native-turn"),
+ ]
+ assert [(event.msg_id, event.client_msg_id) for event in transport.sent
+ if isinstance(event, UserMsg) and event.client_msg_id] == [
+ ("native-one", "external-one"), ("native-two", "external-two"),
+ ]
+ terminals = [event for event in transport.sent if isinstance(event, TurnEnd)]
+ assert len(terminals) == 1 and not terminals[0].result.is_error
+ assert ctx.state == "idle"
+
+ asyncio.run(run())
+
+
def test_managed_codex_turn_replaces_stale_effort_with_model_default():
async def run():
machine, transport = _mk_machine()
diff --git a/tests/test_codex_detail_pages.py b/tests/test_codex_detail_pages.py
new file mode 100644
index 0000000..7f57728
--- /dev/null
+++ b/tests/test_codex_detail_pages.py
@@ -0,0 +1,206 @@
+"""Read-only long-turn paging: source windows, display pages, and cursor scope."""
+import json
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+
+from cc_remote.wrapper.codex_detail_pages import CodexDetailPages, CodexDetailCursorExpired
+from cc_remote.wrapper.machine import _turn_detail_page
+from tests.test_multisession import _mk_ctx, _mk_machine
+
+
+def _event(payload):
+ return {"type": "event_msg", "payload": payload}
+
+
+def _user(native, visible, prompt):
+ return [
+ _event({"type": "task_started", "turn_id": native}),
+ _event({"type": "item_completed", "turn_id": native, "item": {
+ "type": "UserMessage", "id": visible,
+ "content": [{"type": "text", "text": prompt}],
+ }}),
+ ]
+
+
+def _message(index, native="native-long"):
+ return _event({"type": "item_completed", "turn_id": native, "item": {
+ "type": "AgentMessage", "id": f"progress-{index}", "phase": "commentary",
+ "content": [{"type": "Text", "text": f"Public progress {index}"}],
+ }})
+
+
+def _write(path, rows, mode="w"):
+ with path.open(mode) as stream:
+ for row in rows:
+ stream.write(json.dumps(row) + "\n")
+
+
+def _semantic(page):
+ return ([{k: v for k, v in event.items() if k != "ts"} for event in page[0]], *page[1:])
+
+
+def _long_rollout(path, *, newer=False):
+ rows = _user("native-old", "user-old", "Earlier question")
+ rows += [_event({"type": "task_complete", "turn_id": "native-old"})]
+ rows += _user("native-long", "user-long", "The long question")
+ for index in range(40):
+ rows += [_message(index), {"type": "compacted", "payload": {
+ "message": "PRIVATE SUMMARY", "replacement_history": ["x" * 90_000],
+ }}]
+ if newer:
+ rows += [_event({"type": "task_complete", "turn_id": "native-long"})]
+ rows += _user("native-new", "user-new", "Later question") + [_message("new", "native-new")]
+ _write(path, rows)
+
+
+def _read(pager, path, before=None, **overrides):
+ args = dict(before=before, limit=3, window_bytes=1024 * 1024,
+ max_bytes=512 * 1024, tool_result_max=65536, paginate=_turn_detail_page)
+ args.update(overrides)
+ return pager.read(str(path), "iris@session", "user-long", "revision-1", **args)
+
+
+@pytest.mark.parametrize("newer", [False, True])
+def test_long_turn_pages_recover_every_public_message_and_navigate_back(tmp_path, newer):
+ path = tmp_path / "rollout.jsonl"
+ _long_rollout(path, newer=newer)
+ pager = CodexDetailPages()
+ page = _read(pager, path)
+ ids = []
+ seen = set()
+ pages = []
+ before = None
+ while page is not None:
+ events, has_more, older, has_newer, newer_cursor = page
+ pages.append((before, page))
+ assert all(e.get("prompt") == "The long question"
+ for e in events if e["type"] == "user_msg")
+ assert not any("PRIVATE SUMMARY" in e.get("text", "") for e in events)
+ assert any(e["type"] == "turn_binding" and e["turn_id"] == "native-long" for e in events)
+ ids += [e["message_id"] for e in events if e["type"] == "delta"]
+ if not has_more:
+ break
+ assert older is not None and older not in seen
+ seen.add(older)
+ before = older
+ page = _read(pager, path, before)
+ assert set(ids) == {f"progress-{i}" for i in range(40)}
+ assert len(ids) == 40
+ assert len(pages) > 10 # both byte windows and display-group pages were crossed
+ # Follow the exact reverse links back to the newest frozen page.
+ for _before, expected in reversed(pages[:-1]):
+ assert page[3] is True
+ page = _read(pager, path, page[4])
+ assert _semantic(page) == _semantic(expected)
+
+
+def test_long_turn_cursor_survives_append_but_rejects_changed_scope_and_rollback(tmp_path):
+ path = tmp_path / "rollout.jsonl"
+ _long_rollout(path)
+ pager = CodexDetailPages()
+ newest = _read(pager, path)
+ cursor = newest[2]
+ original = _read(pager, path, cursor)
+ _write(path, [_message("appended")], "a")
+ assert _semantic(_read(pager, path, cursor)) == _semantic(original)
+ assert any(e.get("message_id") == "progress-appended" for e in _read(pager, path)[0])
+ for sid, turn, revision in [
+ ("other@session", "user-long", "revision-1"),
+ ("iris@session", "user-old", "revision-1"),
+ ("iris@session", "user-long", "revision-2"),
+ ]:
+ with pytest.raises(CodexDetailCursorExpired):
+ pager.read(str(path), sid, turn, revision, before=cursor, limit=3,
+ window_bytes=1024 * 1024, max_bytes=512 * 1024,
+ tool_result_max=65536, paginate=_turn_detail_page)
+ with pytest.raises(CodexDetailCursorExpired):
+ _read(pager, path, cursor + "0")
+ with pytest.raises(CodexDetailCursorExpired):
+ _read(pager, path, cursor, limit=4)
+ path.write_text("{}\n")
+ with pytest.raises(CodexDetailCursorExpired):
+ _read(pager, path, cursor)
+
+
+def test_long_turn_pager_does_not_claim_short_turns_or_unknown_ids(tmp_path):
+ path = tmp_path / "rollout.jsonl"
+ _write(path, _user("native-long", "user-long", "short"))
+ assert _read(CodexDetailPages(), path) is None
+ _long_rollout(path, newer=True)
+ pager = CodexDetailPages()
+ assert pager.read(str(path), "iris@session", "user-new", "revision-1",
+ before=None, limit=3, window_bytes=1024 * 1024,
+ max_bytes=512 * 1024, tool_result_max=65536,
+ paginate=_turn_detail_page) is None
+
+
+def test_detail_segment_fence_is_frozen_with_its_source_snapshot(tmp_path):
+ path = tmp_path / "frozen-boundary.jsonl"
+ _long_rollout(path)
+ pager = CodexDetailPages()
+ active = _read(pager, path)
+ assert not any(e["type"] == "turn_end" for e in active[0])
+ # A steer appended after a snapshot must not retroactively close that
+ # snapshot's active EOF when its cursors are revisited.
+ next_user = _user("native-long", "steer", "new instruction")[1]
+ next_user["timestamp"] = "2026-09-27T17:20:00Z"
+ _write(path, [next_user], "a")
+ older = _read(pager, path, active[2])
+ returned = _read(pager, path, older[4])
+ assert _semantic(returned) == _semantic(active)
+ fresh = _read(CodexDetailPages(), path)
+ terminal, = [e for e in fresh[0] if e["type"] == "turn_end"]
+ assert terminal["result"]["subtype"] == "steered"
+ assert terminal["turn_id"] is None and not terminal["result"]["is_error"]
+
+
+def test_long_turn_handler_pages_without_an_engine_and_resets_expired_cursor(tmp_path):
+ path = tmp_path / "rollout.jsonl"
+ _long_rollout(path)
+
+ async def run():
+ machine, transport = _mk_machine()
+ machine.cfg.codex_history_window_max_bytes = 1024 * 1024
+ ctx = _mk_ctx("long-session", "long-session")
+ ctx.engine = "codex"
+ machine.sessions[ctx.key] = ctx
+ machine._codex_rollout_for_wire = lambda _sid: str(path)
+ machine._codex_history = None # no app-server or model API is available
+ revision = machine._history_revision(ctx.key)
+ args = dict(session_id=ctx.key, turn_id="user-long", revision=revision,
+ before=None, client_id="browser", limit=3)
+ detail = await machine._handle_get_turn_detail(SimpleNamespace(**args))
+ assert detail.authoritative and detail.has_more and detail.to == "browser"
+ assert any(e.get("message_id") for e in detail.events if e["type"] == "delta")
+ args["before"] = detail.oldest_cursor
+ older = await machine._handle_get_turn_detail(SimpleNamespace(**args))
+ assert older.authoritative and older.has_newer
+ machine._codex_detail_pages = CodexDetailPages() # cursor cache eviction/restart
+ expired = await machine._handle_get_turn_detail(SimpleNamespace(**args))
+ assert expired.reset_required and not expired.authoritative and not expired.events
+ assert len(transport.sent) == 3
+
+ asyncio.run(run())
+
+
+def test_tool_call_and_result_across_source_windows_keep_the_same_native_id(tmp_path):
+ path = tmp_path / "rollout.jsonl"
+ rows = _user("native-long", "user-long", "The long question")
+ rows += [{"type": "response_item", "payload": {"type": "function_call",
+ "call_id": "seam-tool", "name": "exec_command", "arguments": '{"cmd":"ls"}'}}]
+ rows += [{"type": "compacted", "payload": {"message": "x" * 50_000}}] * 30
+ rows += [{"type": "response_item", "payload": {"type": "function_call_output",
+ "call_id": "seam-tool", "output": "Result on the other side of the window"}}]
+ _write(path, rows)
+ pager = CodexDetailPages()
+ newest = _read(pager, path)
+ assert any(e["type"] == "tool_result" and e["tool_use_id"] == "seam-tool"
+ for e in newest[0])
+ page = newest
+ while page[1]:
+ page = _read(pager, path, page[2])
+ assert any(e["type"] == "tool_use" and e["tool_use_id"] == "seam-tool"
+ and e["category"] == "command" and e["input"] == {"command": "ls"}
+ for e in page[0])
diff --git a/tests/test_codex_history.py b/tests/test_codex_history.py
index d55576a..604f834 100644
--- a/tests/test_codex_history.py
+++ b/tests/test_codex_history.py
@@ -10,6 +10,7 @@
from cc_remote.wrapper.codex_history import (
CodexHistoryCursorError,
CodexHistoryInvalidResponse,
+ CodexHistoryProjectionTooLarge,
CodexHistoryUnsupported,
CodexOfficialHistory,
)
@@ -39,6 +40,49 @@
)
+def test_rollout_public_agent_items_keep_ids_without_compaction_summaries(tmp_path):
+ path = tmp_path / "public-messages.jsonl"
+ def completed(item):
+ return {"type": "event_msg", "payload": {
+ "type": "item_completed", "turn_id": "native-turn", "item": item,
+ }}
+ first = completed({
+ "type": "AgentMessage", "id": "comment-1", "phase": "commentary",
+ "content": [{"type": "Text", "text": "Checking progress."}],
+ })
+ second = completed({
+ **first["payload"]["item"], "id": "comment-2",
+ })
+ final = completed({
+ "type": "AgentMessage", "id": "final-1", "phase": "final_answer",
+ "content": [{"type": "Text", "text": "Finished."}],
+ })
+ rows = [
+ {"type": "event_msg", "payload": {"type": "task_started", "turn_id": "native-turn"}},
+ completed({"type": "UserMessage", "id": "user-1",
+ "content": [{"type": "text", "text": "Inspect."}]}),
+ first, first,
+ {"type": "response_item", "payload": {
+ "type": "message", "role": "assistant", "phase": "final_answer",
+ "content": [{"type": "output_text", "text": "PRIVATE COMPACTION SUMMARY"}],
+ }},
+ completed({"type": "ContextCompaction", "id": "compact-1"}),
+ second, final,
+ {"type": "event_msg", "payload": {"type": "task_complete",
+ "turn_id": "native-turn", "last_agent_message": "Finished."}},
+ ]
+ path.write_text("".join(json.dumps(row) + "\n" for row in rows))
+ events, _ = codex_translate_history(str(path), 65536)
+ deltas = [e for e in events if e.type == "delta"]
+ assert [(e.message_id, e.channel, e.text) for e in deltas] == [
+ ("comment-1", "commentary", "Checking progress."),
+ ("comment-2", "commentary", "Checking progress."),
+ ("final-1", "final", "Finished."),
+ ]
+ assert len([e for e in events if e.type == "turn_end"]) == 1
+ assert any(e.type == "process" and e.kind == "compaction" for e in events)
+
+
def _user(
item_id: str,
text: str,
@@ -2106,7 +2150,8 @@ def test_live_rollout_user_recovery_bounds_the_reverse_search(tmp_path):
) is None
-def test_codex_0147_rollout_uses_official_user_item_identity(tmp_path):
+@pytest.mark.parametrize("client_field", ["clientId", "client_id"])
+def test_codex_0147_rollout_uses_official_user_item_identity(tmp_path, client_field):
rollout = tmp_path / "rollout-modern-user.jsonl"
rollout.write_text("".join(json.dumps(row) + "\n" for row in [
{
@@ -2132,7 +2177,7 @@ def test_codex_0147_rollout_uses_official_user_item_identity(tmp_path):
"turn_id": "native-modern",
"item": {
"id": "user-modern",
- "clientId": "cli-message-modern",
+ client_field: "cli-message-modern",
"type": "UserMessage",
"content": [{"type": "text", "text": "inspect modern"}],
},
@@ -3060,7 +3105,7 @@ async def rpc(method, params, cwd=None):
async def run():
history = CodexOfficialHistory(64 * 1024, rpc=rpc)
with pytest.raises(
- CodexHistoryInvalidResponse,
+ CodexHistoryProjectionTooLarge,
match="exceeded its page limit",
):
await history._items_for_turn("thread-1", "native-1")
diff --git a/tests/test_codex_rollout_lifecycle.py b/tests/test_codex_rollout_lifecycle.py
new file mode 100644
index 0000000..521ef6b
--- /dev/null
+++ b/tests/test_codex_rollout_lifecycle.py
@@ -0,0 +1,127 @@
+"""Source clocks, visible segment fences and public native commands."""
+import json
+from datetime import datetime
+
+import pytest
+
+from cc_remote.wrapper.codex_stream import (
+ codex_next_user_boundary_ts, codex_translate_history,
+)
+from cc_remote.wrapper.history_store import materialize_history_turns
+
+
+def row(second, payload, kind="event_msg"):
+ return {"timestamp": f"2026-09-27T17:00:{second:02d}Z", "type": kind, "payload": payload}
+
+
+def item(second, value):
+ return row(second, {"type": "item_completed", "turn_id": "native", "item": value})
+
+
+def user(second, uid):
+ return item(second, {"type": "UserMessage", "id": uid,
+ "content": [{"type": "text", "text": uid}]})
+
+
+def write(path, rows):
+ offsets = []
+ with path.open("wb") as stream:
+ for value in rows:
+ offsets.append(stream.tell())
+ stream.write((json.dumps(value) + "\n").encode())
+ return offsets
+
+
+def stamp(second):
+ return datetime.fromisoformat(row(second, {})["timestamp"]).timestamp()
+
+
+def test_history_events_retain_source_timestamps_including_delayed_agent_rows(tmp_path):
+ path = tmp_path / "source.jsonl"
+ write(path, [
+ row(0, {"type": "task_started", "turn_id": "native"}), user(1, "first"),
+ row(2, {"type": "agent_message", "message": "progress", "phase": "commentary"}),
+ row(8, {"type": "function_call", "call_id": "tool", "name": "exec_command",
+ "arguments": '{"command":"ls"}'}, "response_item"),
+ row(9, {"type": "function_call_output", "call_id": "tool", "output": "ok"}, "response_item"),
+ row(10, {"type": "context_compacted", "id": "compact"}),
+ item(11, {"type": "AgentMessage", "id": "answer", "phase": "final_answer",
+ "content": [{"type": "Text", "text": "done"}]}),
+ row(12, {"type": "task_complete", "turn_id": "native"}),
+ ])
+ events, _ = codex_translate_history(str(path), 1024)
+ assert [e.msg_id for e in events if e.type == "user_msg"] == ["first"]
+ assert all(stamp(0) <= e.ts <= stamp(12) for e in events)
+ assert next(e for e in events if e.type == "delta" and e.text == "progress").ts == stamp(2)
+ assert next(e for e in events if e.type == "tool_use").ts == stamp(8)
+ assert next(e for e in events if e.type == "tool_result").ts == stamp(9)
+ assert [e.model_dump() for e in events] == [
+ e.model_dump() for e in codex_translate_history(str(path), 1024)[0]]
+ turn, = materialize_history_turns([e.model_dump() for e in events])
+ assert turn["processStartedTs"] == int(stamp(2) * 1000)
+ assert turn["processDoneTs"] <= turn["doneTs"] == int(stamp(12) * 1000)
+
+
+@pytest.mark.parametrize("legacy_pair", [False, True])
+def test_bounded_steer_segment_closes_without_claiming_native_terminal(tmp_path, legacy_pair):
+ path = tmp_path / "source.jsonl"
+ rows = [row(0, {"type": "task_started", "turn_id": "native"}), user(1, "first"),
+ row(2, {"type": "agent_message", "message": "progress", "phase": "commentary"})]
+ if legacy_pair:
+ rows += [row(9, {"type": "message", "role": "user", "id": "next"}, "response_item"),
+ row(10, {"type": "user_message", "message": "next"})]
+ else:
+ rows += [user(10, "next")]
+ offsets = write(path, rows)
+ end_ts = codex_next_user_boundary_ts(str(path), offsets[3], "native")
+ assert end_ts == stamp(10) - 0.001
+ assert codex_next_user_boundary_ts(str(path), offsets[3], "native",
+ end_offset=path.stat().st_size - 1) is None
+ events, _ = codex_translate_history(str(path), 1024, end_offset=offsets[3],
+ snapshot_in_progress=True, segment_end_ts=end_ts)
+ terminal = events[-1]
+ assert terminal.type == "turn_end" and terminal.result.subtype == "steered"
+ assert not terminal.result.is_error and terminal.turn_id is None
+ assert terminal.ts == end_ts
+ assert any(e.type == "delta" and e.text == "progress" for e in events)
+ # A byte window inside the response and a frozen active EOF are not fences.
+ assert codex_next_user_boundary_ts(str(path), offsets[2], "native") is None
+ active, _ = codex_translate_history(str(path), 1024, end_offset=offsets[3], snapshot_in_progress=True)
+ assert not any(e.type == "turn_end" for e in active)
+
+
+@pytest.mark.parametrize("camel", [False, True])
+def test_native_commands_are_readable_bounded_and_deduplicated(tmp_path, camel):
+ path = tmp_path / "commands.jsonl"
+ command = {"type": "CommandExecution", "id": "exec-1", "command": ["rg", "a b", "."],
+ "cwd": "/project", "status": "Completed", "exit_code": 1,
+ "aggregated_output": "native output" * 100, "process_id": "pid-1",
+ "parsed_cmd": [{"type": "Search", "query": "a b"}],
+ "duration": {"secs": 2, "nanos": 500_000_000}}
+ if camel:
+ for snake, alternate in (("exit_code", "exitCode"), ("aggregated_output", "aggregatedOutput"),
+ ("process_id", "processId"), ("parsed_cmd", "commandActions")):
+ command[alternate] = command.pop(snake)
+ command["durationMs"] = 2500
+ del command["duration"]
+ write(path, [row(0, {"type": "task_started", "turn_id": "native"}), user(1, "first"),
+ row(2, {"type": "function_call", "call_id": "exec-1", "name": "exec_command",
+ "arguments": '{}'}, "response_item"),
+ item(4, command), item(4, command),
+ row(5, {"type": "task_complete", "turn_id": "native"})])
+ events, _ = codex_translate_history(str(path), 64)
+ use, = [e for e in events if e.type == "tool_use"]
+ result, = [e for e in events if e.type == "tool_result"]
+ assert use.tool_use_id == result.tool_use_id == "exec-1"
+ assert use.title == "搜索 a b" and use.input["command"] == "rg 'a b' ."
+ assert use.input["cwd"] == "/project" and use.input["process_id"] == "pid-1"
+ assert use.ts == stamp(2) and result.ts == stamp(4)
+ assert result.exit_code == 1 and result.status == "failed" and result.is_error
+ assert result.duration_ms == 2500 and result.truncated
+
+
+def test_unknown_source_time_never_becomes_reconstruction_time(tmp_path):
+ path = tmp_path / "unknown-time.jsonl"
+ write(path, [{"type": "event_msg", "payload": {"type": "user_message", "message": "hi"}},
+ {"type": "event_msg", "payload": {"type": "agent_message", "message": "hello"}}])
+ assert all(e.ts == 0 for e in codex_translate_history(str(path), 1024)[0])
diff --git a/tests/test_codex_steer_pagination.py b/tests/test_codex_steer_pagination.py
index c29aa22..57267fd 100644
--- a/tests/test_codex_steer_pagination.py
+++ b/tests/test_codex_steer_pagination.py
@@ -5,6 +5,7 @@
import pytest
from cc_remote.wrapper import machine as mm
+from cc_remote.wrapper import codex_history as history_module
from cc_remote.wrapper.codex_history import CodexOfficialHistory
from cc_remote.wrapper.codex_rpc import CodexRpcResponseTooLarge
from cc_remote.wrapper.history_store import history_image_from_events
@@ -123,6 +124,59 @@ async def test_source_proven_steers_are_not_deleted_when_official_items_are_inco
assert not machine._codex_rollout_history_active("steered")
+@pytest.mark.asyncio
+async def test_bounded_steer_head_uses_one_source_pagination_family(monkeypatch, tmp_path):
+ machine, calls = setup_history(monkeypatch, tmp_path, oversized=True)
+ monkeypatch.setattr(history_module, "_MAX_ITEM_PAGES", 1)
+ revision = machine._history_revision("steered")
+ page = await machine._build_requested_history(
+ "steered", before=None, limit=8, cwd="/tmp", detail="summary")
+ assert page.error is None and page.authoritative
+ assert page.revision != revision
+ assert machine._codex_rollout_history_active("steered")
+ prompts = [turn.prompt for turn in page.turns]
+ native_reads = len(calls)
+ while page.has_more:
+ page = await machine._build_requested_history(
+ "steered", before=page.oldest_id, limit=8, cwd="/tmp", detail="summary")
+ assert page.error is None and page.authoritative
+ prompts = [turn.prompt for turn in page.turns] + prompts
+ assert len(calls) == native_reads
+ assert prompts == [f"prompt {i}/{j}" for i in range(8)
+ for j in range({0: 4, 1: 2}.get(i, 1))]
+
+
+@pytest.mark.asyncio
+async def test_bounded_older_steer_page_keeps_official_cursor_family(monkeypatch, tmp_path):
+ machine, _ = setup_history(monkeypatch, tmp_path, oversized=True)
+ head = await machine._build_requested_history(
+ "steered", before=None, limit=6, cwd="/tmp", detail="summary")
+ monkeypatch.setattr(history_module, "_MAX_ITEM_PAGES", 1)
+ old = await machine._build_requested_history(
+ "steered", before=head.oldest_id, limit=2, cwd="/tmp", detail="summary")
+ assert old.error and not old.authoritative
+ assert not machine._codex_rollout_history_active("steered")
+
+
+@pytest.mark.asyncio
+async def test_bounded_steer_head_does_not_fallback_after_source_changes(monkeypatch, tmp_path):
+ machine, _ = setup_history(monkeypatch, tmp_path, oversized=True)
+ source = tmp_path / "steered.jsonl"
+
+ async def changing_items(*_args):
+ with source.open("a") as stream:
+ stream.write(json.dumps({"type": "event_msg", "payload": {
+ "type": "agent_message", "message": "new live output",
+ }}) + "\n")
+ raise history_module.CodexHistoryProjectionTooLarge("item budget")
+
+ monkeypatch.setattr(machine._codex_history, "_items_for_turn", changing_items)
+ head = await machine._build_requested_history(
+ "steered", before=None, limit=8, cwd="/tmp", detail="summary")
+ assert head.error and not head.authoritative
+ assert not machine._codex_rollout_history_active("steered")
+
+
@pytest.mark.asyncio
async def test_recovered_steer_keeps_exact_active_turn_running():
first = [_user("initial", "first"), _agent("answer-first", "first reply")]
diff --git a/tests/test_history.py b/tests/test_history.py
index b48fda3..ba49a85 100644
--- a/tests/test_history.py
+++ b/tests/test_history.py
@@ -126,6 +126,8 @@ def test_codex_process_clock_overlay_requires_exact_logical_and_native_owner(
),
]
+ for turn in turns:
+ turn["processDetailState"] = "unknown"
mm._apply_codex_process_clocks(turns, clocks)
assert turns[0]["processStartedTs"] == 12_345
@@ -136,7 +138,8 @@ def test_codex_process_clock_overlay_requires_exact_logical_and_native_owner(
assert "processStartedTs" not in turns[2]
-def test_codex_process_clock_cannot_resurrect_exact_empty_steer(tmp_path):
+@pytest.mark.parametrize("native_turn_id", [None, "native-turn"])
+def test_codex_process_clock_cannot_resurrect_exact_empty_steer(tmp_path, native_turn_id):
rollout = tmp_path / "empty-steer.jsonl"
rollout.write_text('{"type":"session_meta"}\n')
machine, _transport = _mk_machine()
@@ -144,6 +147,7 @@ def test_codex_process_clock_cannot_resurrect_exact_empty_steer(tmp_path):
rollout, "accepted-during-compaction", "native-turn", 30_000)
empty = _empty_summary_turn(
"native-user", client_message_id="accepted-during-compaction",
+ native_turn_id=native_turn_id,
)
mm._apply_codex_process_clocks(
@@ -2443,6 +2447,7 @@ async def summary_page(self, _sid, **kwargs):
client_message_id=client_message_id,
native_turn_id="native-turn",
)
+ turn["processDetailState"] = "unknown"
return CodexHistoryPage(
events=(),
turns=(turn,),
@@ -2853,9 +2858,8 @@ async def run():
assert first.turns[0].processDetailState == "none"
assert first.turns[0].processStartedTs is None
- # The sidecar changes while rollout bytes and the cached SQLite source
- # fingerprint stay identical. The cache-hit path must overlay it rather
- # than returning the old compact-derived clock.
+ # A new clock cannot contradict this exact final-only source segment,
+ # including on the cache-hit path and with a native terminal/fork id.
machine._codex_process_clocks.observe_start(
rollout,
"browser-message",
@@ -2864,8 +2868,8 @@ async def run():
)
cached = await machine._build_history(
"cached-clock", limit=4, detail="summary")
- assert cached.turns[0].processDetailState == "present"
- assert cached.turns[0].processStartedTs == 77_000
+ assert cached.turns[0].processDetailState == "none"
+ assert cached.turns[0].processStartedTs is None
asyncio.run(run())
diff --git a/tests/test_history_store.py b/tests/test_history_store.py
index e7c5ba4..4419082 100644
--- a/tests/test_history_store.py
+++ b/tests/test_history_store.py
@@ -28,6 +28,30 @@ def _page(label: str, *, more: bool = False) -> MaterializedHistoryPage:
)
+@pytest.mark.parametrize("old_version", [44, 45])
+def test_public_codex_item_migration_invalidates_only_codex_projections(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 ("codex", "claude", "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, engine.encode())
+ with sqlite3.connect(store.path) as connection:
+ 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] == 46
+ for table in ("history_pages", "history_turn_details"):
+ assert sorted(connection.execute(f"SELECT engine FROM {table}").fetchall()) == [
+ ("claude",), ("dsh",)]
+ assert connection.execute("SELECT COUNT(*) FROM history_image_assets").fetchone()[0] == 3
+ migrated.put_page("codex", "codex", source, before=None, limit=4, page=_page("new"))
+ reopened = HistoryIndexStore(tmp_path / "state")
+ assert reopened.get_page("codex", "codex", source, before=None, limit=4) == _page("new")
+
+
def test_history_index_roundtrip_is_bound_to_exact_source_snapshot(tmp_path):
source_path = tmp_path / "rollout.jsonl"
source_path.write_text('{"type":"first"}\n')
@@ -461,7 +485,7 @@ 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] == 44
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 46
for table in ("history_pages", "history_turn_details"):
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='codex'"
@@ -490,11 +514,11 @@ def test_compact_migration_rebuilds_claude_details_and_preserves_assets(tmp_path
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] == 44
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 46
assert connection.execute(
- "SELECT engine FROM history_pages").fetchall() == ([] if old_version == 41 else [("codex",)])
+ "SELECT engine FROM history_pages").fetchall() == []
assert connection.execute(
- "SELECT engine FROM history_turn_details").fetchall() == [("codex",)]
+ "SELECT engine FROM history_turn_details").fetchall() == []
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")
@@ -565,7 +589,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] == 44
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 46
assert connection.execute(
"SELECT COUNT(*) FROM history_agent_details").fetchone()[0] == 0
@@ -593,7 +617,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] == 44
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 46
for table in ("history_pages", "history_turn_details"):
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='claude'"
@@ -654,7 +678,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] == 44
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 46
for table in ("history_pages", "history_turn_details"):
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='claude'"
@@ -711,7 +735,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] == 44
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 46
for table in ("history_pages", "history_turn_details"):
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='claude'"
@@ -773,7 +797,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] == 44
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 46
for table in ("history_pages", "history_turn_details"):
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='claude'"
@@ -844,7 +868,7 @@ def test_async_question_migration_preserves_assets(
migrated = HistoryIndexStore(state_dir)
with sqlite3.connect(migrated.path) as connection:
- assert connection.execute("PRAGMA user_version").fetchone()[0] == 44
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 46
for table in ("history_pages", "history_turn_details"):
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='claude'"
@@ -953,7 +977,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] == 44
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 46
for table in (
"history_pages",
"history_turn_details",
@@ -1000,7 +1024,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] == 44
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 46
for table in (
"history_pages", "history_turn_details", "history_image_assets",
):
@@ -1051,7 +1075,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] == 44
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 46
tables = {
row[0] for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
@@ -1097,7 +1121,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] == 44
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 46
for table in (
"history_pages", "history_turn_details", "history_image_assets",
):
@@ -1139,7 +1163,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] == 44
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 46
for table in ("history_pages", "history_turn_details"):
assert connection.execute(
f"SELECT COUNT(*) FROM {table} WHERE engine='claude'"
@@ -1203,7 +1227,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] == 44
+ assert connection.execute("PRAGMA user_version").fetchone()[0] == 46
assert connection.execute(
"SELECT COUNT(*) FROM history_pages"
).fetchone()[0] == 0
diff --git a/tests/test_query_rejection.py b/tests/test_query_rejection.py
new file mode 100644
index 0000000..4c206cc
--- /dev/null
+++ b/tests/test_query_rejection.py
@@ -0,0 +1,75 @@
+"""A rejected send is private command feedback, never a failed engine turn."""
+
+import asyncio
+
+import pytest
+
+from cc_remote.protocol import CommandAck, Error, Query, StateEvent
+from tests.test_claude_autocompact import (
+ SESSION_ID, _AutoCompactSdk, _machine_with_sdk,
+)
+
+
+@pytest.mark.parametrize("race", ["running", "notification", "preflight"])
+def test_rejected_query_is_private_and_reliable_without_touching_active_turn(race):
+ async def run():
+ machine, transport, ctx = _machine_with_sdk(_AutoCompactSdk())
+ ctx.active_msg_id = "original-task"
+ if race == "running":
+ ctx.state = "running"
+ elif race == "notification":
+ ctx.claude_background_followup_pending = True
+ else:
+ async def ownership(_sid):
+ ctx.claude_background_followup_pending = True
+ return False
+
+ machine._prime_claude_ownership = ownership
+
+ await machine._emit(ctx, StateEvent(
+ state=ctx.state, msg_id="original-task"))
+ original_seq = ctx.buffer.tail_seq
+ command = Query(sid=SESSION_ID, msg_id="rejected-message",
+ prompt="new instruction", client_id="sender",
+ cmd_id="send-command")
+ await machine._process_command(command)
+ assert ctx.state == ("running" if race == "running" else "idle")
+ assert ctx.active_msg_id == "original-task"
+ # Lost ACK: a reliable retry must replay the rejection privately, never
+ # rerun the query after the original background response becomes idle.
+ ctx.state = "idle"
+ ctx.claude_background_followup_pending = False
+ await machine._process_command(command)
+
+ errors = [event for event in transport.sent if isinstance(event, Error)]
+ assert len(errors) == 2
+ assert all(event.code == "busy" for event in errors)
+ assert all(event.to == "sender" for event in errors)
+ assert all(event.request_id == "send-command" for event in errors)
+ assert all(event.msg_id == "rejected-message" for event in errors)
+ assert all(event.sid == SESSION_ID for event in errors)
+ assert ctx.active_msg_id == "original-task"
+ assert ctx.turn_task is None
+ assert ctx.buffer.tail_seq == original_seq
+ assert not any(isinstance(event, Error) for event in ctx.buffer.replay_from(
+ 0, cc_session_id=SESSION_ID, state=ctx.state, rebuild=True))
+ assert len([e for e in transport.sent if isinstance(e, CommandAck)]) == 2
+
+ asyncio.run(run())
+
+
+def test_missing_session_query_rejection_stays_correlated_to_sender():
+ async def run():
+ machine, transport, _ctx = _machine_with_sdk(_AutoCompactSdk())
+ result = await machine._handle_query(Query(
+ sid="missing-session", msg_id="unaccepted-message", prompt="hi",
+ client_id="sender", cmd_id="send-command"))
+ assert isinstance(result, Error)
+ assert result.code == "not_running"
+ assert result.to == "sender"
+ assert result.request_id == "send-command"
+ assert result.sid == "missing-session"
+ assert result.msg_id == "unaccepted-message"
+ assert transport.sent[-1] is result
+
+ asyncio.run(run())
diff --git a/web/package.json b/web/package.json
index 2634d44..c7c9a20 100644
--- a/web/package.json
+++ b/web/package.json
@@ -20,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/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/clipboard-paste-guard.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/query-rejection.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/btw-history.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/clipboard-paste-guard.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/src/App.tsx b/web/src/App.tsx
index d211ff0..85a05a4 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -3617,7 +3617,7 @@ export default function App() {
const eventEngine = session?.engine
?? (stateRef.current.focusedSid === msg.sid
? engineRef.current : undefined);
- if (eventEngine === "codex") {
+ if (eventEngine === "codex" || eventEngine === "claude") {
if (stateRef.current.runtimes[msg.sid]?.statusRequestId) {
deferredStatusRefreshRef.current.add(msg.sid);
} else {
@@ -4346,7 +4346,7 @@ export default function App() {
]);
const refreshStatus = useCallback(() => {
- if (!focusedSid || focusedEngine !== "codex") return;
+ if (!focusedSid) return;
const current = stateRef.current;
if (current.sessions.find(
(session) => session.session_id === focusedSid)?.tag === "archived") return;
@@ -4355,7 +4355,7 @@ export default function App() {
if (requestId) {
dispatch({ type: "begin_status_request", sid: focusedSid, requestId });
}
- }, [focusedEngine, focusedSid]);
+ }, [focusedSid]);
const consumeResetCredit = useCallback((creditId?: string | null) => {
if (!focusedSid || focusedEngine !== "codex") return false;
const current = stateRef.current;
@@ -4384,23 +4384,23 @@ export default function App() {
return requestId !== null;
}, [focusedEngine, focusedSid]);
useEffect(() => {
- if (!authed || !focusedSid || focusedEngine !== "codex" || state.newChat
+ if (!authed || !focusedSid || state.newChat
|| archivedBrowse
- || rt.state !== "idle"
+ || (focusedEngine === "codex" && rt.state !== "idle")
|| state.connState !== "connected" || !state.wrapperOnline) return;
- if (stateRef.current.runtimes[focusedSid]?.statusRequestId) return;
refreshStatus();
- }, [
- authed,
- archivedBrowse,
- focusedEngine,
- focusedSid,
- refreshStatus,
- rt.state,
- state.connState,
- state.newChat,
- state.wrapperOnline,
- ]);
+ if (focusedEngine !== "claude") return;
+ // Account reads do not use the chat reader. The Wrapper coalesces requests
+ // across sessions/tabs; background pages need no periodic refresh.
+ const refreshVisible = () => {
+ if (document.visibilityState === "visible") refreshStatus();
+ };
+ const timer = window.setInterval(refreshVisible, 60_000);
+ return () => {
+ window.clearInterval(timer);
+ };
+ }, [authed, focusedSid, focusedEngine, state.newChat, archivedBrowse,
+ state.connState, state.wrapperOnline, refreshStatus, rt.state]);
useEffect(() => {
const artifact = state.artifact;
diff --git a/web/src/components/BtwPanel.css b/web/src/components/BtwPanel.css
index 3e9e399..bac27b0 100644
--- a/web/src/components/BtwPanel.css
+++ b/web/src/components/BtwPanel.css
@@ -16,6 +16,7 @@
.btw-chat-close{ width:26px; height:28px; flex:none; display:grid; place-items:center; border:0; border-left:1px solid transparent; background:transparent; color:var(--dim); cursor:pointer; }
.btw-chat-close:hover{ color:var(--danger); }
.btw-body{ flex:1; min-height:0; display:flex; flex-direction:column; overflow:hidden; padding:4px 2px; }
+.btw-history-note{ padding:8px 20px; color:var(--dim); font-size:12px; line-height:1.5; }
.btw-empty{ flex:1; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:28px 20px; color:var(--dim); font-size:13px; line-height:1.7; text-align:center; }
.btw-composer{ position:relative; flex:none; padding:9px 12px 8px; border-top:1px solid var(--border); background:var(--bg); }
.btw-composer-notice{ position:absolute; left:50%; bottom:calc(100% + 8px); z-index:3; transform:translateX(-50%); padding:7px 11px; border:1px solid var(--border); border-radius:9px; background:var(--surface); box-shadow:var(--shadow-sm); color:var(--dim); font-size:11px; white-space:nowrap; }
diff --git a/web/src/components/BtwPanel.tsx b/web/src/components/BtwPanel.tsx
index f6af533..7cc9825 100644
--- a/web/src/components/BtwPanel.tsx
+++ b/web/src/components/BtwPanel.tsx
@@ -490,6 +490,9 @@ export function BtwPanel(p: Props) {