4435 lines
182 KiB
Python
4435 lines
182 KiB
Python
"""Background transcript forwarding for native Claude Code sessions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
import time
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from omnigent._native_post_delivery import (
|
|
append_dead_letter,
|
|
post_external_session_status,
|
|
post_may_have_been_delivered,
|
|
)
|
|
from omnigent.claude_native_bridge import (
|
|
BRIDGE_ID_LABEL_KEY,
|
|
ClaudeHookRecord,
|
|
ClaudeMessageDelta,
|
|
ClaudeTranscriptItem,
|
|
HookReadResult,
|
|
TranscriptReadResult,
|
|
compute_transcript_cumulative_cost,
|
|
read_active_session_id,
|
|
read_bridge_id,
|
|
read_claude_context_state,
|
|
read_claude_session_id,
|
|
read_hook_events_from_offset,
|
|
read_hook_events_since_with_position,
|
|
read_message_deltas_from_offset,
|
|
read_transcript_items_from_offset,
|
|
read_transcript_items_since_with_position,
|
|
read_transcript_path,
|
|
transcript_has_forked_from_marker,
|
|
transcript_has_recent_local_command,
|
|
url_component,
|
|
write_active_session_id,
|
|
)
|
|
from omnigent.claude_native_message_display_hook import MESSAGE_DELTAS_FILE
|
|
from omnigent.entities.session_resources import terminal_resource_id
|
|
from omnigent.reasoning_effort import CLAUDE_EFFORTS, EFFORT_CLEAR_VALUES
|
|
|
|
_FORWARDER_STATE_FILE = "transcript_forwarder.json"
|
|
_HOOK_STATE_FILE = "hook_forwarder.json"
|
|
_SUBAGENT_STATE_FILE = "subagent_forwarder.json"
|
|
_DELTA_STATE_FILE = "message_deltas_forwarder.json"
|
|
_HOOKS_FILE = "hooks.jsonl"
|
|
|
|
# Cap on the in-memory ``(message_id, index)`` dedupe ring for streamed
|
|
# deltas. The byte offset already prevents re-reading on the normal
|
|
# path; this guards the rare truncation/rewind case where the deltas
|
|
# file is reset and the reader restarts from 0. Generous because one
|
|
# prose answer can be hundreds of chunks.
|
|
_MAX_SEEN_DELTA_KEYS = 5000
|
|
|
|
# Max time an assistant ``message`` item is held waiting for its
|
|
# streamed deltas to forward first. The transcript and deltas file have
|
|
# independent writers, so a short reply's record can hit disk a poll
|
|
# BEFORE its deltas — inverting the deltas-before-done order and
|
|
# rendering the message twice. ~8 polls at 0.25s: well past the one-poll
|
|
# race, short enough that an unmatched item (dropped deltas, or a
|
|
# multi-block message that never byte-equals the whole-message stream)
|
|
# posts with barely noticeable delay — and has no preview to duplicate.
|
|
_ASSISTANT_ITEM_DELTA_HOLD_S = 2.0
|
|
|
|
# Cap on the delta-ordering bookkeeping. Entries are consumed on match /
|
|
# never revisited after post, so this is a backstop against pathological
|
|
# sessions, not a working-set size.
|
|
_MAX_DELTA_ORDERING_ENTRIES = 256
|
|
|
|
|
|
@dataclass
|
|
class _ForwardedDeltaText:
|
|
"""
|
|
Forwarded streamed-text accumulation for one assistant message.
|
|
|
|
:param parts: Forwarded delta strings in arrival order, e.g.
|
|
``["Hello ", "world"]``.
|
|
:param final: Whether the ``final: true`` chunk has forwarded — only
|
|
then is ``"".join(parts)`` the complete text, safe to byte-compare
|
|
against a transcript item.
|
|
"""
|
|
|
|
parts: list[str] = field(default_factory=list)
|
|
final: bool = False
|
|
|
|
|
|
@dataclass
|
|
class _DeltaOrderingState:
|
|
"""
|
|
Cross-poll state enforcing deltas-before-done item ordering.
|
|
|
|
Filled by :func:`_forward_available_deltas` (forwarded chunk text per
|
|
``message_id``) and consumed by :func:`_hold_assistant_item_for_deltas`,
|
|
which matches an assistant ``message`` item to its forwarded stream by
|
|
byte-equal text (the transcript carries no ``message_id``).
|
|
|
|
:param texts: ``message_id`` → forwarded delta text state. Popped
|
|
when an item matches it.
|
|
:param held_since: ``source_id`` → monotonic time first held. Kept
|
|
after the timeout releases the item so a failed post's retry
|
|
isn't re-held; bounded.
|
|
"""
|
|
|
|
texts: dict[str, _ForwardedDeltaText] = field(default_factory=dict)
|
|
held_since: dict[str, float] = field(default_factory=dict)
|
|
|
|
|
|
def _hold_monotonic() -> float:
|
|
"""
|
|
Monotonic clock for the assistant-item hold timeout.
|
|
|
|
Indirection so tests patch THIS, not the process-global
|
|
``time.monotonic`` (see the no-global-singleton-patch test rule).
|
|
|
|
:returns: Seconds from an unspecified monotonic epoch.
|
|
"""
|
|
return time.monotonic()
|
|
|
|
|
|
def _item_output_text(data: dict[str, Any]) -> str | None:
|
|
"""
|
|
Join the ``output_text`` blocks of a message item's content.
|
|
|
|
:param data: Item payload, e.g. ``{"role": "assistant", "content":
|
|
[{"type": "output_text", "text": "Hi"}]}``.
|
|
:returns: The joined text, or ``None`` when the item carries none.
|
|
"""
|
|
content = data.get("content")
|
|
if not isinstance(content, list):
|
|
return None
|
|
parts: list[str] = []
|
|
for block in content:
|
|
if isinstance(block, dict) and block.get("type") == "output_text":
|
|
text = block.get("text")
|
|
if isinstance(text, str):
|
|
parts.append(text)
|
|
if not parts:
|
|
return None
|
|
return "".join(parts)
|
|
|
|
|
|
def _hold_assistant_item_for_deltas(
|
|
item: ClaudeTranscriptItem,
|
|
ordering: _DeltaOrderingState | None,
|
|
bridge_dir: Path,
|
|
) -> bool:
|
|
"""
|
|
Decide whether to defer an assistant message item to a later poll.
|
|
|
|
Enforces deltas-before-done: an assistant ``message`` item posts only
|
|
once a complete (``final``-seen) forwarded stream byte-equals its
|
|
text, or after :data:`_ASSISTANT_ITEM_DELTA_HOLD_S`. Holding returns
|
|
``True`` and the caller stops the batch here (cursor unadvanced) so
|
|
later items can't overtake it. Items that can't have a preview — tool
|
|
calls, user/text-less messages, no-deltas-file sessions — never hold.
|
|
The timeout is safe: a message whose deltas never arrive has no live
|
|
preview, so a late post renders once, like any non-streamed message.
|
|
|
|
:param item: The transcript item about to be posted.
|
|
:param ordering: Shared ordering state, or ``None`` to disable
|
|
holding (parsing-only test paths).
|
|
:param bridge_dir: Native Claude bridge directory (for the
|
|
deltas-file existence check).
|
|
:returns: ``True`` to hold the item (and the rest of the batch)
|
|
until the next poll; ``False`` to post it now.
|
|
"""
|
|
if ordering is None:
|
|
return False
|
|
if item.item_type != "message" or item.data.get("role") != "assistant":
|
|
return False
|
|
text = _item_output_text(item.data)
|
|
if not text:
|
|
return False
|
|
if not (bridge_dir / MESSAGE_DELTAS_FILE).exists():
|
|
return False
|
|
for message_id, entry in ordering.texts.items():
|
|
if entry.final and "".join(entry.parts) == text:
|
|
# Deltas fully forwarded — consume the stream (a later
|
|
# identical-text message matches its own) and post.
|
|
ordering.texts.pop(message_id)
|
|
ordering.held_since.pop(item.source_id, None)
|
|
return False
|
|
now = _hold_monotonic()
|
|
first_held = ordering.held_since.setdefault(item.source_id, now)
|
|
while len(ordering.held_since) > _MAX_DELTA_ORDERING_ENTRIES:
|
|
del ordering.held_since[next(iter(ordering.held_since))]
|
|
if now - first_held >= _ASSISTANT_ITEM_DELTA_HOLD_S:
|
|
# Timestamp kept: a failed post's retry next poll is released
|
|
# immediately by the elapsed check, not re-held for a full timeout.
|
|
_logger.debug(
|
|
"Posting assistant transcript item without matching forwarded "
|
|
"deltas after %.1fs hold; source_id=%s",
|
|
_ASSISTANT_ITEM_DELTA_HOLD_S,
|
|
item.source_id,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
# Seconds of transcript inactivity after which we publish ``idle`` for
|
|
# a sub-agent. The transcript is the only signal we have for sub-agent
|
|
# completion in Phase A (no SubagentStop hook is subscribed); 5s is the
|
|
# shortest window that comfortably absorbs a stalled tool call without
|
|
# flickering the badge. Phase B will replace this with an authoritative
|
|
# hook signal and drop the heuristic.
|
|
_SUBAGENT_IDLE_QUIESCENCE_S = 5.0
|
|
|
|
# Meta-file glob inside ``~/.claude/projects/<encoded>/<session>/subagents/``.
|
|
# One per Claude Task-tool subagent; appears alongside the matching
|
|
# ``agent-<id>.jsonl`` transcript.
|
|
_SUBAGENT_META_GLOB = "agent-*.meta.json"
|
|
_DEFAULT_POLL_INTERVAL_S = 0.25
|
|
_POST_TIMEOUT_S = 10.0
|
|
_MAX_SEEN_SOURCE_IDS = 2000
|
|
_CURSOR_FINGERPRINT_BYTES = 256
|
|
_FORK_COMMAND_NAMES = frozenset({"/branch", "/fork"})
|
|
_HTTP_POST_MAX_PERMANENT_FAILURES = 3
|
|
_HTTP_POST_RETRY_BASE_DELAY_S = 1.0
|
|
_HTTP_POST_RETRY_MAX_DELAY_S = 30.0
|
|
_HTTP_TRANSIENT_STATUS_CODES = {408, 409, 425, 429}
|
|
_SUPERVISOR_INITIAL_BACKOFF_S = 1.0
|
|
_SUPERVISOR_MAX_BACKOFF_S = 30.0
|
|
_SUPERVISOR_HEALTHY_UPTIME_S = 60.0
|
|
|
|
# Claude Code hook event names → Omnigent session-status values
|
|
# published on the per-conversation SSE stream. Unmapped events emit
|
|
# no status.
|
|
#
|
|
# ``Stop`` → idle and ``StopFailure`` → failed are the authoritative
|
|
# turn-end edges (each fires once when Claude finishes / errors a turn);
|
|
# they drive sub-agent terminal delivery via the codex-shared
|
|
# ``external_session_status`` path (→ parent inbox + wake). The
|
|
# PTY-activity ``idle`` cannot: it is a ~1s-quiescence heuristic that
|
|
# oscillates on every mid-turn lull, so delivering on it fired a
|
|
# premature completion and idempotently locked out the real one.
|
|
# ``UserPromptSubmit`` → running stays PTY-derived — the pane watcher
|
|
# drives the UI running/idle badge and catches what ``Stop`` misses
|
|
# (interrupts, compaction failures, TUI edits). ``_publish_status``
|
|
# keeps ``failed`` sticky against the trailing PTY idle.
|
|
_HOOK_EVENT_TO_STATUS: dict[str, str] = {
|
|
"Stop": "idle",
|
|
"StopFailure": "failed",
|
|
}
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class _ForwardHealth:
|
|
"""
|
|
Process-level health of Omnigent transcript/usage forwarding (#1120).
|
|
|
|
Network trouble (connect timeouts, 503s, resets) makes the forwarder's
|
|
event posts fail. Transient failures are retried indefinitely and
|
|
permanent ones are eventually dropped, but either way a sustained
|
|
outage previously surfaced only as scattered per-item warnings. This
|
|
tracks consecutive post failures so a real outage escalates to a
|
|
single loud signal instead of staying effectively silent.
|
|
|
|
Unlike the codex forwarder (which counts only its bounded-retry give-ups),
|
|
the claude forwarder retries transient failures forever, so every failed
|
|
post is counted here — that is what makes the indicator fire for the
|
|
503/connect-timeout outages #1120 is about, not just permanent 4xx drops.
|
|
|
|
:param consecutive_failures: Post failures since the last success.
|
|
:param degraded_logged: Whether the degraded-sync edge has already
|
|
been logged for the current outage (so it logs once, not per item).
|
|
"""
|
|
|
|
consecutive_failures: int = 0
|
|
degraded_logged: bool = False
|
|
|
|
|
|
# After this many consecutive post failures, sync is treated as degraded and
|
|
# escalated once to ERROR. Small enough to fire during a real outage, large
|
|
# enough to ride out a transient blip the retries already cover.
|
|
_FORWARD_DEGRADED_THRESHOLD = 5
|
|
_forward_health = _ForwardHealth()
|
|
|
|
|
|
def _reset_forward_health() -> None:
|
|
"""
|
|
Reset forward-health tracking (test seam / new forwarder lifetime).
|
|
|
|
:returns: None.
|
|
"""
|
|
global _forward_health
|
|
_forward_health = _ForwardHealth()
|
|
|
|
|
|
def _note_forward_success() -> None:
|
|
"""
|
|
Record a successful (or ambiguously-delivered) forward, clearing any
|
|
degraded-sync state.
|
|
|
|
:returns: None.
|
|
"""
|
|
if _forward_health.degraded_logged:
|
|
_logger.info(
|
|
"claude-native forward sync recovered after %d consecutive failures",
|
|
_forward_health.consecutive_failures,
|
|
)
|
|
_forward_health.consecutive_failures = 0
|
|
_forward_health.degraded_logged = False
|
|
|
|
|
|
def _note_forward_failure(retry_key: str) -> None:
|
|
"""
|
|
Record a forward post failure; escalate once when sync degrades.
|
|
|
|
:param retry_key: Stable retry key of the failed post, e.g.
|
|
``"item:source-1"``.
|
|
:returns: None.
|
|
"""
|
|
_forward_health.consecutive_failures += 1
|
|
if (
|
|
_forward_health.consecutive_failures >= _FORWARD_DEGRADED_THRESHOLD
|
|
and not _forward_health.degraded_logged
|
|
):
|
|
_logger.error(
|
|
"claude-native forward sync degraded: %d consecutive Omnigent "
|
|
"event-post failures; transcript/usage mirroring may be incomplete "
|
|
"(latest key=%s)",
|
|
_forward_health.consecutive_failures,
|
|
retry_key,
|
|
)
|
|
_forward_health.degraded_logged = True
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HookForwardState:
|
|
"""
|
|
Durable cursor for the hooks-to-status forwarder.
|
|
|
|
:param event_cursor: One-based hook record index already
|
|
forwarded. ``0`` means no hook events have been forwarded yet.
|
|
:param byte_offset: Byte offset already forwarded. ``None`` means
|
|
the state was written by an older line-cursor-only forwarder
|
|
and must be migrated with one compatibility scan.
|
|
:param cursor_fingerprint: Hash of bytes immediately before
|
|
``byte_offset``. Used to detect truncation/replacement before
|
|
seeking into a stale offset.
|
|
"""
|
|
|
|
event_cursor: int
|
|
byte_offset: int | None = None
|
|
cursor_fingerprint: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SubagentEntry:
|
|
"""
|
|
Per-sub-agent forwarder cursor.
|
|
|
|
One of these per Claude-side sub-agent. Tracks the Omnigent child
|
|
Conversation id we minted (so subsequent items POST to the
|
|
right session), the transcript file byte offset already
|
|
forwarded, and the wall-clock timestamp of the last item we
|
|
saw (for the idle-status heuristic).
|
|
|
|
:param subagent_id: Stable Claude-side identifier, also the
|
|
``agent-<id>`` filename stem, e.g. ``"a5c7effac5a9a35ab"``.
|
|
:param child_conversation_id: Omnigent child Conversation id minted
|
|
by the server's ``external_subagent_start`` handler,
|
|
e.g. ``"conv_child456"``.
|
|
:param byte_offset: Bytes already forwarded from the sub-agent's
|
|
``.jsonl``. ``0`` means we haven't read anything yet (the
|
|
common case when the sub-agent has just been created).
|
|
:param seen_source_ids: Recently-posted transcript item source
|
|
ids for this child. Preserved separately from ``byte_offset``
|
|
so a failed later item can leave the cursor behind without
|
|
re-posting earlier accepted items on the next poll.
|
|
:param last_activity_ts: Unix timestamp of the most recent item
|
|
observed in this sub-agent's transcript. Used by the idle
|
|
heuristic — when ``now - last_activity_ts >
|
|
_SUBAGENT_IDLE_QUIESCENCE_S`` we publish an
|
|
``external_session_status: idle`` event. ``None`` when no
|
|
items have been seen yet (so the heuristic doesn't fire
|
|
before there's anything to be quiescent about).
|
|
:param last_status: Last status string POSTed for this
|
|
sub-agent — used to dedupe so we don't spam ``running`` or
|
|
``idle`` events on every tick when nothing changed. ``None``
|
|
means no status has been posted yet.
|
|
"""
|
|
|
|
subagent_id: str
|
|
child_conversation_id: str
|
|
byte_offset: int = 0
|
|
seen_source_ids: tuple[str, ...] = ()
|
|
last_activity_ts: float | None = None
|
|
last_status: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SubagentForwardState:
|
|
"""
|
|
Durable cursor map for the claude-native sub-agent forwarder.
|
|
|
|
Persisted at ``{bridge_dir}/subagent_forwarder.json`` so a
|
|
forwarder restart picks up where we left off — re-reading the
|
|
on-disk ``subagents/`` directory and posting only items past
|
|
each tracked sub-agent's ``byte_offset``.
|
|
|
|
:param subagents: Map from Claude-side ``subagent_id`` to the
|
|
per-sub-agent entry. New sub-agents discovered on disk are
|
|
inserted here after the Omnigent server returns a child
|
|
Conversation id.
|
|
"""
|
|
|
|
subagents: dict[str, SubagentEntry]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TranscriptForwardState:
|
|
"""
|
|
Durable cursor for a Claude transcript forwarder.
|
|
|
|
:param transcript_path: Transcript JSONL file whose cursor was
|
|
recorded.
|
|
:param line_cursor: One-based line cursor already forwarded into
|
|
AP. ``0`` means no lines from the current transcript have
|
|
been forwarded yet.
|
|
:param byte_offset: Transcript byte offset already forwarded.
|
|
``None`` means the state was written by an older
|
|
line-cursor-only forwarder and must be migrated with one
|
|
compatibility scan.
|
|
:param current_response_id: Response id for a Claude assistant
|
|
turn that spans multiple forwarder polls.
|
|
:param seen_source_ids: Recently-posted transcript item source
|
|
ids. This makes retries and restarts idempotent even if the
|
|
line cursor was not advanced before a cancellation.
|
|
:param cursor_fingerprint: Hash of bytes immediately before
|
|
``byte_offset``. Used to detect truncation/replacement before
|
|
seeking into a stale offset.
|
|
"""
|
|
|
|
transcript_path: Path
|
|
line_cursor: int
|
|
byte_offset: int | None = None
|
|
current_response_id: str | None = None
|
|
seen_source_ids: tuple[str, ...] = ()
|
|
cursor_fingerprint: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeltaForwardState:
|
|
"""
|
|
Durable cursor for the assistant-text delta forwarder.
|
|
|
|
Tracks the byte offset already consumed from
|
|
``<bridge_dir>/message_deltas.jsonl``. Unlike the transcript cursor
|
|
this is NOT tied to a transcript path and is NOT reset on
|
|
``/clear`` / ``/fork``: the deltas file belongs to the long-lived
|
|
Claude process and keeps growing across Omnigent session rotations, so the
|
|
offset stays monotonic and each new chunk is forwarded to whatever
|
|
Omnigent session is active when it is read.
|
|
|
|
:param byte_offset: Byte offset after the last forwarded chunk.
|
|
``0`` means nothing has been forwarded yet.
|
|
"""
|
|
|
|
byte_offset: int = 0
|
|
|
|
|
|
@dataclass
|
|
class _ForwardDedupeState:
|
|
"""
|
|
Last values the forwarder POSTed, kept to suppress duplicate
|
|
``external_*`` events when Claude rewrites the same block each poll.
|
|
|
|
Mutated in place by :func:`_forward_available_items` so the run loop
|
|
carries the dedupe baseline across polls without threading a
|
|
positional tuple back out. Reset on ``/clear`` and ``/fork``
|
|
rotations alongside the other per-session state.
|
|
|
|
:param usage: Last ``message.usage`` snapshot POSTed via
|
|
``external_session_usage``, or ``None`` if none yet.
|
|
:param context_window: Last context-window POSTed, or ``None``.
|
|
:param observed_model: Last tier alias seen in the transcript,
|
|
sticky across polls (the incremental window often carries no
|
|
fresh ``message.model``), e.g. ``"opus"``. ``None`` until first
|
|
seen.
|
|
:param posted_model: Last tier alias POSTed via
|
|
``external_model_change``. Seeded from the first observation
|
|
WITHOUT a POST so a passive spawn default never overwrites a
|
|
pending silent model handoff; only a later in-TUI switch is
|
|
mirrored. Left behind ``observed_model`` on a failed POST so the
|
|
next poll retries. ``None`` until the first observation.
|
|
:param posted_cost: Last DISPLAY cost (USD) POSTed as
|
|
``cumulative_cost_usd`` — the statusLine total ``S`` verbatim.
|
|
``None`` until the first cost post. Used to dedupe so a steady
|
|
cost isn't re-POSTed every poll.
|
|
:param posted_policy_cost: Last POLICY/budget cost (USD) POSTed as
|
|
``policy_cost_usd`` — ``max(S, transcript estimate)``, the
|
|
real-time figure the cost-budget gate reads. Tracked separately
|
|
from ``posted_cost`` because it advances mid-turn (with in-flight
|
|
sub-agent spend) while ``S`` stays frozen. ``None`` until first
|
|
post.
|
|
"""
|
|
|
|
usage: dict[str, float] | None = None
|
|
context_window: int | None = None
|
|
observed_model: str | None = None
|
|
posted_model: str | None = None
|
|
# Last DISPLAY cost (USD) POSTed as ``cumulative_cost_usd`` — the
|
|
# statusLine total ``S`` verbatim (matches /cost in the Claude TUI).
|
|
# Kept to suppress duplicate posts when S hasn't advanced.
|
|
posted_cost: float | None = None
|
|
# Last POLICY/budget cost (USD) POSTed as ``policy_cost_usd`` —
|
|
# ``max(S, forwarder transcript estimate)``, which reflects in-flight
|
|
# sub-agent spend so the gate can block mid-turn. Separate baseline
|
|
# because it can advance while ``posted_cost`` (S) is frozen.
|
|
posted_policy_cost: float | None = None
|
|
# Response id of the last turn-start ``running`` status POSTed, so the
|
|
# id-bearing running edge fires exactly once per turn even when an
|
|
# assistant item is held across polls for delta ordering (which leaves
|
|
# ``state.current_response_id`` unadvanced). ``None`` until the first
|
|
# turn-start edge. Reset on /clear and /fork like the other baselines.
|
|
posted_running_response_id: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _TranscriptCostCacheEntry:
|
|
"""
|
|
Cached cumulative-cost computation for one transcript file.
|
|
|
|
The cost is recomputed only when the file's byte size changes, so the
|
|
forwarder doesn't re-parse an unchanged transcript on every (0.25s)
|
|
poll. Append-only JSONL makes byte size a sound cache key.
|
|
|
|
:param size: File size in bytes when ``cost_usd`` was computed,
|
|
e.g. ``81920``.
|
|
:param cost_usd: Cumulative USD cost computed from the transcript at
|
|
that size, or ``None`` when nothing could be priced.
|
|
"""
|
|
|
|
size: int
|
|
cost_usd: float | None
|
|
|
|
|
|
@dataclass
|
|
class _PostRetryEntry:
|
|
"""
|
|
In-memory retry state for one outbound Omnigent event.
|
|
|
|
:param attempts: Number of failed post attempts observed.
|
|
:param next_attempt_at: Monotonic timestamp before which the
|
|
forwarder should not retry this event.
|
|
"""
|
|
|
|
attempts: int = 0
|
|
next_attempt_at: float = 0.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _PostRetryDecision:
|
|
"""
|
|
Result of recording one outbound Omnigent post failure.
|
|
|
|
:param attempts: Number of failed attempts for this event after
|
|
the current failure.
|
|
:param delay_s: Seconds until the next retry should be attempted.
|
|
:param exhausted: Whether a permanent failure exceeded the retry
|
|
budget and the cursor should advance past the event.
|
|
:param permanent: Whether the failure is classified as a
|
|
permanent HTTP rejection.
|
|
"""
|
|
|
|
attempts: int
|
|
delay_s: float
|
|
exhausted: bool
|
|
permanent: bool
|
|
|
|
|
|
class _PostRetryTracker:
|
|
"""
|
|
Track bounded retries and backoff for Omnigent event posts.
|
|
|
|
Permanent 4xx-style HTTP rejections are retried a small number of
|
|
times before the forwarder marks the item failed and advances the
|
|
cursor. Transient HTTP/network failures keep retrying with
|
|
backoff so Omnigent outages do not silently drop transcript data.
|
|
|
|
This is not a :mod:`tenacity` wrapper because retry attempts must
|
|
be interleaved with durable cursor writes and the forwarder's poll
|
|
loop. Sleeping inside a decorator would block unrelated hook/item
|
|
work behind one poisoned event.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
max_permanent_attempts: int = _HTTP_POST_MAX_PERMANENT_FAILURES,
|
|
base_delay_s: float = _HTTP_POST_RETRY_BASE_DELAY_S,
|
|
max_delay_s: float = _HTTP_POST_RETRY_MAX_DELAY_S,
|
|
) -> None:
|
|
"""
|
|
Initialize an empty retry tracker.
|
|
|
|
:param max_permanent_attempts: Attempts before a permanent
|
|
failure is exhausted.
|
|
:param base_delay_s: Initial retry delay in seconds.
|
|
:param max_delay_s: Maximum retry delay in seconds.
|
|
:returns: None.
|
|
"""
|
|
self._max_permanent_attempts = max(1, max_permanent_attempts)
|
|
self._base_delay_s = max(0.0, base_delay_s)
|
|
self._max_delay_s = max(0.0, max_delay_s)
|
|
self._entries: dict[str, _PostRetryEntry] = {}
|
|
|
|
def retry_delay_s(self, key: str) -> float | None:
|
|
"""
|
|
Return remaining delay for ``key`` if a retry is not due yet.
|
|
|
|
:param key: Stable retry key, e.g. ``"item:source-1"``.
|
|
:returns: Remaining seconds to wait, or ``None`` when the
|
|
caller may attempt the post now.
|
|
"""
|
|
entry = self._entries.get(key)
|
|
if entry is None:
|
|
return None
|
|
remaining = entry.next_attempt_at - time.monotonic()
|
|
if remaining <= 0:
|
|
return None
|
|
return remaining
|
|
|
|
def clear(self, key: str) -> None:
|
|
"""
|
|
Remove retry state for a successfully handled event.
|
|
|
|
:param key: Stable retry key, e.g. ``"hook:2:idle"``.
|
|
:returns: None.
|
|
"""
|
|
self._entries.pop(key, None)
|
|
# A cleared key means the post got through (or was ambiguously
|
|
# delivered); reset process-level forward-sync health (#1120).
|
|
_note_forward_success()
|
|
|
|
def record_failure(self, key: str, exc: httpx.HTTPError) -> _PostRetryDecision:
|
|
"""
|
|
Record one failed post and compute the next retry action.
|
|
|
|
:param key: Stable retry key, e.g. ``"item:source-1"``.
|
|
:param exc: HTTP exception raised while posting the event.
|
|
:returns: Retry decision for this failure.
|
|
"""
|
|
# Count every failed post (transient or permanent) so a sustained
|
|
# outage escalates once to a degraded-sync signal (#1120).
|
|
_note_forward_failure(key)
|
|
entry = self._entries.get(key)
|
|
if entry is None:
|
|
entry = _PostRetryEntry()
|
|
self._entries[key] = entry
|
|
entry.attempts += 1
|
|
permanent = _is_permanent_http_error(exc)
|
|
if permanent and entry.attempts >= self._max_permanent_attempts:
|
|
self._entries.pop(key, None)
|
|
return _PostRetryDecision(
|
|
attempts=entry.attempts,
|
|
delay_s=0.0,
|
|
exhausted=True,
|
|
permanent=True,
|
|
)
|
|
delay_s = min(
|
|
self._base_delay_s * (2 ** max(0, entry.attempts - 1)),
|
|
self._max_delay_s,
|
|
)
|
|
entry.next_attempt_at = time.monotonic() + delay_s
|
|
return _PostRetryDecision(
|
|
attempts=entry.attempts,
|
|
delay_s=delay_s,
|
|
exhausted=False,
|
|
permanent=permanent,
|
|
)
|
|
|
|
|
|
async def forward_claude_transcript_to_session(
|
|
*,
|
|
base_url: str,
|
|
headers: dict[str, str],
|
|
session_id: str,
|
|
bridge_dir: Path,
|
|
agent_name: str,
|
|
start_at_end: bool,
|
|
poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S,
|
|
auth: httpx.Auth | None = None,
|
|
skip_user_messages: bool = False,
|
|
) -> None:
|
|
"""
|
|
Tail Claude's JSONL transcript and mirror semantic items into AP.
|
|
|
|
This loop is intentionally independent of Claude Channels. It
|
|
runs while the native terminal is attached, watches the transcript
|
|
path reported by Claude hooks, and posts new user text,
|
|
assistant text, tool calls, and tool results as external AP
|
|
conversation items.
|
|
|
|
:param base_url: Omnigent server base URL.
|
|
:param headers: Static HTTP headers for Omnigent requests. Authorization
|
|
is normally supplied via ``auth`` instead so OAuth tokens are
|
|
refreshed per request; any ``Authorization`` value here is
|
|
overridden by ``auth`` when both are set.
|
|
:param session_id: Omnigent session/conversation id.
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param agent_name: Agent/model name to stamp on mirrored output.
|
|
:param start_at_end: When ``True`` and no prior forward cursor
|
|
exists, start from the current transcript end. This is used
|
|
for reattach so old transcript lines are not duplicated.
|
|
:param poll_interval_s: Seconds between transcript polls.
|
|
:param auth: Optional httpx Auth that mints a fresh bearer token
|
|
per request, e.g. ``_server_auth(profile)`` for a Databricks
|
|
Apps deployment. ``None`` for local servers that don't need
|
|
auth. Required for long-lived remote sessions — Databricks
|
|
OAuth tokens expire after ~1 hour and a static header captured
|
|
at startup would stop authenticating mid-session.
|
|
:returns: Never normally returns; cancel the task to stop it.
|
|
"""
|
|
state = _read_forward_state(bridge_dir)
|
|
hook_state: HookForwardState | None = None
|
|
subagent_state = _read_subagent_forward_state(bridge_dir)
|
|
# Live assistant-text streaming. The delta cursor is independent of
|
|
# the transcript/subagent cursors and survives /clear and /fork
|
|
# (the deltas file belongs to the long-lived Claude process). The
|
|
# dedupe ring is per-process and not persisted: the byte offset
|
|
# prevents re-reads on the normal path.
|
|
delta_state = _read_delta_forward_state(bridge_dir)
|
|
seen_delta_keys: dict[tuple[str, int], None] = {}
|
|
# Deltas-before-done ordering across the two independent tails: the
|
|
# deltas forwarder records each message's forwarded text, the items
|
|
# forwarder holds an assistant item until its text matches a complete
|
|
# forwarded stream (or a short timeout). Per-process like
|
|
# ``seen_delta_keys``; survives /clear and /fork (message_ids belong
|
|
# to the long-lived Claude process).
|
|
delta_ordering = _DeltaOrderingState()
|
|
item_retries = _PostRetryTracker()
|
|
status_retries = _PostRetryTracker()
|
|
subagent_start_retries = _PostRetryTracker()
|
|
subagent_item_retries = _PostRetryTracker()
|
|
subagent_status_retries = _PostRetryTracker()
|
|
# Dedupe: Claude rewrites the same usage block every poll until
|
|
# the next assistant entry; only POST on real change. Mutated in
|
|
# place by ``_forward_available_items`` and carried across polls.
|
|
dedupe = _ForwardDedupeState()
|
|
# Size-keyed transcript cost cache for ``_forward_session_cost`` — keeps
|
|
# the per-poll cost reconciliation from re-parsing unchanged transcripts.
|
|
# Reset on /clear and /fork rotations alongside ``dedupe``.
|
|
cost_cache: dict[Path, _TranscriptCostCacheEntry] = {}
|
|
# Per-process latch: once we PATCH the conversation with the
|
|
# Claude-native session id, never PATCH again. Persists for the
|
|
# lifetime of the forwarder task; the server's idempotence handles
|
|
# the rare case where two forwarder processes race the same conv.
|
|
external_session_id_mirrored = False
|
|
# Native task system state: maps and ordered list accumulated from
|
|
# TaskCreated / TaskCompleted / PostToolUse/TaskUpdate hook events.
|
|
# Reset on /clear and /fork rotations alongside other session state.
|
|
task_subjects: dict[str, str] = {}
|
|
task_statuses: dict[str, str] = {}
|
|
task_order: list[str] = []
|
|
timeout = httpx.Timeout(_POST_TIMEOUT_S)
|
|
async with httpx.AsyncClient(
|
|
base_url=base_url, headers=headers, auth=auth, timeout=timeout
|
|
) as client:
|
|
while True:
|
|
try:
|
|
current_session_id = read_active_session_id(bridge_dir) or session_id
|
|
if hook_state is None:
|
|
hook_state = await _ensure_hook_state(
|
|
bridge_dir,
|
|
start_at_end=start_at_end,
|
|
session_id=current_session_id,
|
|
)
|
|
rotation = await _maybe_rotate_session_on_clear(
|
|
client=client,
|
|
session_id=current_session_id,
|
|
bridge_dir=bridge_dir,
|
|
state=hook_state,
|
|
)
|
|
if rotation is not None:
|
|
# Tell the superseded (old) conversation it was cleared:
|
|
# persist a notice linking to the rotated-to session and
|
|
# emit a live redirect event. Use the loop's ``session_id``
|
|
# (the session being forwarded BEFORE this poll), NOT
|
|
# ``current_session_id``: when the hook rotated the bridge's
|
|
# active session synchronously, ``current_session_id`` already
|
|
# reads the NEW id, whereas ``session_id`` is not reassigned
|
|
# to ``rotation`` until below. The call is fully best-effort
|
|
# (swallows its own errors) so the state reset below always
|
|
# runs.
|
|
await _post_clear_supersession(
|
|
client,
|
|
old_session_id=session_id,
|
|
new_session_id=rotation,
|
|
agent_name=agent_name,
|
|
)
|
|
session_id = rotation
|
|
state = None
|
|
hook_state = None
|
|
# After a /clear or /fork the parent now resolves
|
|
# to a new ``<session_uuid>/subagents/`` directory
|
|
# on disk, so old sub-agent entries are dead. Drop
|
|
# them; the watcher will rediscover any new ones
|
|
# under the rotated session's dir.
|
|
subagent_state = SubagentForwardState(subagents={})
|
|
await _write_subagent_forward_state_async(bridge_dir, subagent_state)
|
|
item_retries = _PostRetryTracker()
|
|
status_retries = _PostRetryTracker()
|
|
subagent_start_retries = _PostRetryTracker()
|
|
subagent_item_retries = _PostRetryTracker()
|
|
subagent_status_retries = _PostRetryTracker()
|
|
external_session_id_mirrored = False
|
|
task_subjects = {}
|
|
task_statuses = {}
|
|
task_order = []
|
|
# A rotated session is a fresh dedupe context — reseed
|
|
# so the new session's first model observation doesn't
|
|
# post against the prior session's baseline.
|
|
dedupe = _ForwardDedupeState()
|
|
# The rotated session resolves to a new transcript +
|
|
# subagents/ dir, so prior cost entries are dead; drop
|
|
# them so cost is recomputed fresh for the new session.
|
|
cost_cache = {}
|
|
await asyncio.sleep(poll_interval_s)
|
|
continue
|
|
rotation = await _maybe_rotate_session_on_fork(
|
|
client=client,
|
|
session_id=current_session_id,
|
|
bridge_dir=bridge_dir,
|
|
state=hook_state,
|
|
)
|
|
if rotation is not None:
|
|
session_id = rotation
|
|
state = None
|
|
hook_state = None
|
|
# After a /clear or /fork the parent now resolves
|
|
# to a new ``<session_uuid>/subagents/`` directory
|
|
# on disk, so old sub-agent entries are dead. Drop
|
|
# them; the watcher will rediscover any new ones
|
|
# under the rotated session's dir.
|
|
subagent_state = SubagentForwardState(subagents={})
|
|
await _write_subagent_forward_state_async(bridge_dir, subagent_state)
|
|
item_retries = _PostRetryTracker()
|
|
status_retries = _PostRetryTracker()
|
|
subagent_start_retries = _PostRetryTracker()
|
|
subagent_item_retries = _PostRetryTracker()
|
|
subagent_status_retries = _PostRetryTracker()
|
|
external_session_id_mirrored = False
|
|
task_subjects = {}
|
|
task_statuses = {}
|
|
task_order = []
|
|
# A rotated session is a fresh dedupe context — reseed
|
|
# so the new session's first model observation doesn't
|
|
# post against the prior session's baseline.
|
|
dedupe = _ForwardDedupeState()
|
|
# The rotated session resolves to a new transcript +
|
|
# subagents/ dir, so prior cost entries are dead; drop
|
|
# them so cost is recomputed fresh for the new session.
|
|
cost_cache = {}
|
|
await asyncio.sleep(poll_interval_s)
|
|
continue
|
|
if not external_session_id_mirrored:
|
|
external_session_id_mirrored = await _maybe_mirror_external_session_id(
|
|
client=client,
|
|
session_id=current_session_id,
|
|
bridge_dir=bridge_dir,
|
|
)
|
|
transcript_path = read_transcript_path(bridge_dir)
|
|
if transcript_path is not None:
|
|
state = await _ensure_state_for_transcript(
|
|
bridge_dir=bridge_dir,
|
|
state=state,
|
|
transcript_path=transcript_path,
|
|
start_at_end=start_at_end,
|
|
session_id=current_session_id,
|
|
)
|
|
# Forward streamed deltas BEFORE the transcript items so a
|
|
# message's live chunks (incl. its ``final`` chunk) always
|
|
# precede its own authoritative ``output_item.done``. If
|
|
# items led, a message's final chunk — written to the
|
|
# deltas file moments before the transcript record flushed
|
|
# — would land just AFTER its done event and re-create the
|
|
# already-finalized preview on the client (duplicate bubble
|
|
# + a stale trailing preview). See the web reconciler.
|
|
# Within-poll order can't cover the cross-poll race
|
|
# (transcript flushed, hook delta write not yet);
|
|
# ``delta_ordering`` closes it by holding the assistant
|
|
# item until its deltas byte-match or a timeout expires.
|
|
delta_state = await _forward_available_deltas(
|
|
client=client,
|
|
session_id=current_session_id,
|
|
bridge_dir=bridge_dir,
|
|
state=delta_state,
|
|
seen_keys=seen_delta_keys,
|
|
ordering=delta_ordering,
|
|
)
|
|
state = await _forward_available_items(
|
|
client=client,
|
|
session_id=current_session_id,
|
|
bridge_dir=bridge_dir,
|
|
agent_name=agent_name,
|
|
state=state,
|
|
retry_tracker=item_retries,
|
|
skip_user_messages=skip_user_messages,
|
|
dedupe=dedupe,
|
|
ordering=delta_ordering,
|
|
)
|
|
hook_state = await _forward_available_status_events(
|
|
client=client,
|
|
session_id=current_session_id,
|
|
bridge_dir=bridge_dir,
|
|
state=hook_state,
|
|
retry_tracker=status_retries,
|
|
task_subjects=task_subjects,
|
|
task_statuses=task_statuses,
|
|
task_order=task_order,
|
|
# The turn-end edges (Stop→idle / StopFailure→failed)
|
|
# carry the turn's response id so ap-web can CLOSE the
|
|
# streaming ``activeResponse`` opened by the turn-start
|
|
# ``running`` edge (_forward_available_items). The
|
|
# transcript forwarder ran just above, so
|
|
# ``state.current_response_id`` is the active turn's id
|
|
# (the user-message reset only fires on the next turn).
|
|
response_id=state.current_response_id,
|
|
)
|
|
subagent_state = await _forward_available_subagents(
|
|
client=client,
|
|
parent_session_id=current_session_id,
|
|
bridge_dir=bridge_dir,
|
|
transcript_path=transcript_path,
|
|
state=subagent_state,
|
|
agent_name=agent_name,
|
|
start_retry_tracker=subagent_start_retries,
|
|
item_retry_tracker=subagent_item_retries,
|
|
status_retry_tracker=subagent_status_retries,
|
|
)
|
|
# Reconcile + POST cumulative cost AFTER sub-agents are
|
|
# forwarded so the estimate sees this poll's sub-agent
|
|
# transcript growth. This is what lets the parent's
|
|
# cost-budget policy block a sub-agent's tool calls
|
|
# mid-turn (the statusLine total alone lags until the
|
|
# sub-agent finishes).
|
|
await _forward_session_cost(
|
|
client=client,
|
|
session_id=current_session_id,
|
|
bridge_dir=bridge_dir,
|
|
parent_transcript_path=transcript_path,
|
|
subagent_state=subagent_state,
|
|
dedupe=dedupe,
|
|
cost_cache=cost_cache,
|
|
)
|
|
# Mirror the live statusLine model EVERY poll (not just
|
|
# when a turn produced new transcript items, which
|
|
# _forward_available_items early-returns without). This
|
|
# propagates an in-pane /model switch to model_override
|
|
# before the user's next message, so model-gated policies
|
|
# (cost-budget hard cap) no longer lag a switch by one turn.
|
|
await _forward_model_from_status(
|
|
client=client,
|
|
session_id=current_session_id,
|
|
bridge_dir=bridge_dir,
|
|
dedupe=dedupe,
|
|
)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
_logger.exception(
|
|
"Claude transcript forwarder loop failed; session=%s bridge_dir=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
)
|
|
await asyncio.sleep(poll_interval_s)
|
|
|
|
|
|
def _subagents_dir_for_transcript(transcript_path: Path) -> Path:
|
|
"""
|
|
Resolve the on-disk ``subagents/`` directory for a Claude session.
|
|
|
|
Claude Code writes each Task-tool sub-agent's transcript to
|
|
``~/.claude/projects/<encoded>/<session>/subagents/agent-*.jsonl``
|
|
where ``<session>`` matches the parent transcript's filename stem.
|
|
The parent transcript itself lives at
|
|
``~/.claude/projects/<encoded>/<session>.jsonl`` alongside that
|
|
directory.
|
|
|
|
:param transcript_path: Parent's transcript JSONL,
|
|
e.g. ``"~/.claude/projects/-Users-x-repo/85a2b8ac.jsonl"``.
|
|
:returns: Path to the parent's ``subagents/`` directory (may not
|
|
exist yet — caller is responsible for handling the "no
|
|
sub-agents have been spawned yet" case).
|
|
"""
|
|
return transcript_path.parent / transcript_path.stem / "subagents"
|
|
|
|
|
|
def _read_subagent_forward_state(bridge_dir: Path) -> SubagentForwardState:
|
|
"""
|
|
Read the sub-agent forwarder's durable cursor map.
|
|
|
|
Returns an empty state when no file has been persisted yet (the
|
|
first time the watcher runs for this bridge directory). Malformed
|
|
JSON / corrupt rows are treated as empty so a botched write can't
|
|
permanently wedge the watcher.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:returns: A :class:`SubagentForwardState`, possibly empty.
|
|
"""
|
|
try:
|
|
raw = json.loads((bridge_dir / _SUBAGENT_STATE_FILE).read_text(encoding="utf-8"))
|
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
return SubagentForwardState(subagents={})
|
|
if not isinstance(raw, dict):
|
|
return SubagentForwardState(subagents={})
|
|
subagents_raw = raw.get("subagents", {})
|
|
if not isinstance(subagents_raw, dict):
|
|
return SubagentForwardState(subagents={})
|
|
entries: dict[str, SubagentEntry] = {}
|
|
for subagent_id, row in subagents_raw.items():
|
|
if not isinstance(subagent_id, str) or not isinstance(row, dict):
|
|
continue
|
|
child_id = row.get("child_conversation_id")
|
|
byte_offset = row.get("byte_offset", 0)
|
|
seen_source_ids = row.get("seen_source_ids", [])
|
|
last_activity_ts = row.get("last_activity_ts")
|
|
last_status = row.get("last_status")
|
|
# Empty string is a valid parked sentinel written by
|
|
# ``_forward_available_subagents`` after the start POST exhausts
|
|
# its permanent-failure budget. Preserving it across restarts is
|
|
# what keeps the parked sub-agent from being retried.
|
|
if not isinstance(child_id, str):
|
|
continue
|
|
if not isinstance(byte_offset, int) or byte_offset < 0:
|
|
byte_offset = 0
|
|
if not isinstance(seen_source_ids, list) or not all(
|
|
isinstance(source_id, str) for source_id in seen_source_ids
|
|
):
|
|
seen_source_ids = []
|
|
if last_activity_ts is not None and not isinstance(last_activity_ts, (int, float)):
|
|
last_activity_ts = None
|
|
if last_status is not None and not isinstance(last_status, str):
|
|
last_status = None
|
|
entries[subagent_id] = SubagentEntry(
|
|
subagent_id=subagent_id,
|
|
child_conversation_id=child_id,
|
|
byte_offset=byte_offset,
|
|
seen_source_ids=tuple(seen_source_ids),
|
|
last_activity_ts=last_activity_ts,
|
|
last_status=last_status,
|
|
)
|
|
return SubagentForwardState(subagents=entries)
|
|
|
|
|
|
def _write_subagent_forward_state(bridge_dir: Path, state: SubagentForwardState) -> None:
|
|
"""
|
|
Write the sub-agent forwarder's cursor map to disk atomically.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param state: Cursor map to persist.
|
|
:returns: None.
|
|
"""
|
|
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
payload: dict[str, Any] = {
|
|
"subagents": {
|
|
entry.subagent_id: {
|
|
"child_conversation_id": entry.child_conversation_id,
|
|
"byte_offset": entry.byte_offset,
|
|
"seen_source_ids": list(entry.seen_source_ids),
|
|
"last_activity_ts": entry.last_activity_ts,
|
|
"last_status": entry.last_status,
|
|
}
|
|
for entry in state.subagents.values()
|
|
},
|
|
"updated_at": time.time(),
|
|
}
|
|
_write_json_atomic(bridge_dir / _SUBAGENT_STATE_FILE, payload)
|
|
|
|
|
|
async def _write_subagent_forward_state_async(
|
|
bridge_dir: Path,
|
|
state: SubagentForwardState,
|
|
) -> None:
|
|
"""
|
|
Persist sub-agent state without blocking the asyncio event loop.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param state: Cursor map to persist.
|
|
:returns: None.
|
|
"""
|
|
await asyncio.to_thread(_write_subagent_forward_state, bridge_dir, state)
|
|
|
|
|
|
def _parse_json_response(resp: httpx.Response, *, context: str) -> Any:
|
|
"""
|
|
Parse an Omnigent JSON response, failing loudly on a non-JSON body.
|
|
|
|
The forwarder calls ``resp.json()`` on Sessions API responses after
|
|
``resp.raise_for_status()``. That guards non-2xx statuses but not a
|
|
2xx body that simply is not JSON: an auth or proxy layer in front of
|
|
the server — most commonly an expired Databricks Apps OAuth session —
|
|
can serve an HTML login or error page with a 200 status. A bare
|
|
``resp.json()`` then raises an opaque ``json.JSONDecodeError``
|
|
("Expecting value: line 1 column 1 (char 0)") with no hint that the
|
|
body was HTML, and the forwarder supervisor turns that into a silent
|
|
restart loop. This wrapper re-raises with the response content type
|
|
and a body snippet so the cause is obvious in logs.
|
|
|
|
:param resp: HTTP response whose body is expected to be JSON.
|
|
:param context: Short request description for the error message,
|
|
e.g. ``"session conv_abc123 snapshot"``.
|
|
:returns: The parsed JSON value (object, array, or scalar).
|
|
:raises RuntimeError: If the response body is not valid JSON.
|
|
"""
|
|
try:
|
|
return resp.json()
|
|
except ValueError as exc:
|
|
content_type = resp.headers.get("content-type") or "<unknown>"
|
|
snippet = " ".join(resp.text[:200].split())
|
|
raise RuntimeError(
|
|
f"{context} returned a non-JSON body (content-type "
|
|
f"{content_type!r}); an auth or proxy page was likely served "
|
|
f"instead of the API response (e.g. an expired login session). "
|
|
f"Body starts with: {snippet!r}"
|
|
) from exc
|
|
|
|
|
|
async def _post_external_subagent_start(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
parent_session_id: str,
|
|
subagent_id: str,
|
|
agent_type: str,
|
|
description: str,
|
|
tool_use_id: str,
|
|
) -> str:
|
|
"""
|
|
POST ``external_subagent_start`` to the Omnigent server and return the
|
|
minted child Conversation id.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param parent_session_id: Parent (claude-native) conversation id,
|
|
e.g. ``"conv_parent987"``.
|
|
:param subagent_id: Stable Claude-side identifier read from
|
|
``agent-<id>.meta.json``'s filename, e.g.
|
|
``"a5c7effac5a9a35ab"``.
|
|
:param agent_type: Claude sub-agent type from the meta file,
|
|
e.g. ``"Explore"``.
|
|
:param description: Free-form description from the meta file,
|
|
e.g. ``"Investigate web UI session data flow"``.
|
|
:param tool_use_id: Parent transcript's ``Task`` tool-use block
|
|
id this sub-agent was spawned from, e.g. ``"toolu_..."``.
|
|
:returns: The Omnigent child conversation id, e.g. ``"conv_child456"``.
|
|
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
|
:raises KeyError: If the server response is missing
|
|
``child_session_id`` — indicates a server/forwarder version
|
|
mismatch and is unrecoverable for this sub-agent.
|
|
:raises RuntimeError: If the server response body is not JSON.
|
|
"""
|
|
resp = await client.post(
|
|
f"/v1/sessions/{parent_session_id}/events",
|
|
json={
|
|
"type": "external_subagent_start",
|
|
"data": {
|
|
"subagent_id": subagent_id,
|
|
"agent_type": agent_type,
|
|
"description": description,
|
|
"tool_use_id": tool_use_id,
|
|
},
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
body = _parse_json_response(resp, context=f"sub-agent start for {parent_session_id!r}")
|
|
return body["child_session_id"]
|
|
|
|
|
|
def _read_subagent_meta(meta_path: Path) -> dict[str, str] | None:
|
|
"""
|
|
Read a Claude sub-agent's ``.meta.json`` file, validating the
|
|
fields the forwarder needs.
|
|
|
|
Returns ``None`` (rather than raising) when the file is missing
|
|
or malformed so the watcher can skip it gracefully and try again
|
|
on the next tick.
|
|
|
|
:param meta_path: Path to ``agent-<id>.meta.json``.
|
|
:returns: A dict with string-typed ``agentType``, ``description``,
|
|
and ``toolUseId``; or ``None`` when the file is missing /
|
|
malformed / missing any required key.
|
|
"""
|
|
try:
|
|
raw = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
return None
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
agent_type = raw.get("agentType")
|
|
description = raw.get("description")
|
|
tool_use_id = raw.get("toolUseId")
|
|
if not isinstance(agent_type, str) or not agent_type:
|
|
return None
|
|
if not isinstance(description, str):
|
|
return None
|
|
if not isinstance(tool_use_id, str) or not tool_use_id:
|
|
return None
|
|
return {
|
|
"agentType": agent_type,
|
|
"description": description,
|
|
"toolUseId": tool_use_id,
|
|
}
|
|
|
|
|
|
async def _forward_available_subagents(
|
|
*,
|
|
client: httpx.AsyncClient,
|
|
parent_session_id: str,
|
|
bridge_dir: Path,
|
|
transcript_path: Path,
|
|
state: SubagentForwardState,
|
|
agent_name: str,
|
|
start_retry_tracker: _PostRetryTracker,
|
|
item_retry_tracker: _PostRetryTracker,
|
|
status_retry_tracker: _PostRetryTracker,
|
|
) -> SubagentForwardState:
|
|
"""
|
|
Discover new Claude Task-tool sub-agents on disk, mint Omnigent child
|
|
conversations for them, tail their transcripts, and publish
|
|
quiescence-based status.
|
|
|
|
Idempotent across forwarder restarts: ``state`` (persisted to
|
|
``subagent_forwarder.json``) holds the Omnigent child id and byte
|
|
offset for every sub-agent already seen. Sub-agents whose
|
|
``.meta.json`` appears for the first time are registered with AP
|
|
via ``external_subagent_start``; sub-agents already in ``state``
|
|
just have their ``.jsonl`` tailed forward.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param parent_session_id: Parent (claude-native) conversation id.
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param transcript_path: Parent's transcript JSONL — used to
|
|
locate the sibling ``subagents/`` directory.
|
|
:param state: Current sub-agent cursor map.
|
|
:param agent_name: Agent/model name to stamp on mirrored items
|
|
(mirrors the value used for the parent's transcript).
|
|
:param start_retry_tracker: Backoff tracker for failed
|
|
``external_subagent_start`` POSTs (keyed by ``subagent_id``).
|
|
:param item_retry_tracker: Backoff tracker for failed
|
|
``external_conversation_item`` POSTs (keyed by source id).
|
|
:param status_retry_tracker: Backoff tracker for failed
|
|
``external_session_status`` POSTs (keyed by
|
|
``status:<child_id>``).
|
|
:returns: Updated state with new sub-agents registered and
|
|
existing sub-agents' cursors advanced.
|
|
"""
|
|
subagents_dir = _subagents_dir_for_transcript(transcript_path)
|
|
if not subagents_dir.is_dir():
|
|
return state
|
|
|
|
# ── Register newly-appeared sub-agents ──────────────
|
|
# ``glob`` is sync; offload to a thread so we don't stat the
|
|
# filesystem on the event loop.
|
|
meta_paths = await asyncio.to_thread(lambda: sorted(subagents_dir.glob(_SUBAGENT_META_GLOB)))
|
|
updated = state
|
|
for meta_path in meta_paths:
|
|
# ``agent-<id>.meta.json`` → ``<id>``
|
|
subagent_id = meta_path.stem.removeprefix("agent-").removesuffix(".meta")
|
|
if subagent_id in updated.subagents:
|
|
continue
|
|
retry_key = f"subagent_start:{subagent_id}"
|
|
if start_retry_tracker.retry_delay_s(retry_key) is not None:
|
|
continue
|
|
meta = await asyncio.to_thread(_read_subagent_meta, meta_path)
|
|
if meta is None:
|
|
# File may be mid-write; try again on the next tick.
|
|
continue
|
|
try:
|
|
child_id = await _post_external_subagent_start(
|
|
client,
|
|
parent_session_id=parent_session_id,
|
|
subagent_id=subagent_id,
|
|
agent_type=meta["agentType"],
|
|
description=meta["description"],
|
|
tool_use_id=meta["toolUseId"],
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
decision = start_retry_tracker.record_failure(retry_key, exc)
|
|
if decision.exhausted:
|
|
_logger.error(
|
|
"Dropping claude-native sub-agent after permanent HTTP failures; "
|
|
"parent_session=%s subagent_id=%s attempts=%s http_status=%s",
|
|
parent_session_id,
|
|
subagent_id,
|
|
decision.attempts,
|
|
_http_status_for_log(exc),
|
|
)
|
|
# Dead-letter the dropped payload for recovery (#1120; replay #1579).
|
|
append_dead_letter(
|
|
bridge_dir,
|
|
session_id=parent_session_id,
|
|
event_type="external_subagent_start",
|
|
payload={
|
|
"subagent_id": subagent_id,
|
|
"agent_type": meta["agentType"],
|
|
"description": meta["description"],
|
|
"tool_use_id": meta["toolUseId"],
|
|
},
|
|
reason="permanent HTTP failure after retries",
|
|
# Claude only dead-letters permanent 4xx (it retries
|
|
# transient failures forever), so the server proved it
|
|
# rejected the item: never ambiguous, never replayable (#1579).
|
|
delivered_ambiguous=False,
|
|
http_status=_http_status_for_log(exc),
|
|
)
|
|
# Park this sub-agent: insert a sentinel entry so we
|
|
# don't keep retrying. ``child_conversation_id=""``
|
|
# is filtered out by the tail / status loops below.
|
|
updated = SubagentForwardState(
|
|
subagents={
|
|
**updated.subagents,
|
|
subagent_id: SubagentEntry(
|
|
subagent_id=subagent_id,
|
|
child_conversation_id="",
|
|
),
|
|
}
|
|
)
|
|
await _write_subagent_forward_state_async(bridge_dir, updated)
|
|
continue
|
|
_logger.warning(
|
|
"Failed to register claude-native sub-agent; parent_session=%s "
|
|
"subagent_id=%s attempt=%s permanent=%s next_retry_s=%.3f "
|
|
"http_status=%s",
|
|
parent_session_id,
|
|
subagent_id,
|
|
decision.attempts,
|
|
decision.permanent,
|
|
decision.delay_s,
|
|
_http_status_for_log(exc),
|
|
exc_info=True,
|
|
)
|
|
continue
|
|
start_retry_tracker.clear(retry_key)
|
|
updated = SubagentForwardState(
|
|
subagents={
|
|
**updated.subagents,
|
|
subagent_id: SubagentEntry(
|
|
subagent_id=subagent_id,
|
|
child_conversation_id=child_id,
|
|
),
|
|
}
|
|
)
|
|
await _write_subagent_forward_state_async(bridge_dir, updated)
|
|
|
|
# ── Tail each tracked sub-agent's transcript ────────
|
|
now = time.time()
|
|
for subagent_id, entry in list(updated.subagents.items()):
|
|
if not entry.child_conversation_id:
|
|
# Parked after exhausted start retries — nothing to tail.
|
|
continue
|
|
jsonl_path = subagents_dir / f"agent-{subagent_id}.jsonl"
|
|
if not jsonl_path.exists():
|
|
continue
|
|
# Reuse the parent-transcript parser, but pass
|
|
# ``include_sidechains=True`` — every record in a sub-agent's
|
|
# own ``agent-<id>.jsonl`` carries ``isSidechain: true``
|
|
# (that's the whole point of the file's existence as a
|
|
# separate transcript), and the parser's default ``False``
|
|
# would strip every line and leave the child conversation
|
|
# empty.
|
|
result = await asyncio.to_thread(
|
|
read_transcript_items_from_offset,
|
|
jsonl_path,
|
|
entry.byte_offset,
|
|
start_line=0,
|
|
agent_name=agent_name,
|
|
current_response_id=None,
|
|
include_sidechains=True,
|
|
)
|
|
new_entry = entry
|
|
had_item = False
|
|
items_failed = False
|
|
seen_source_ids = list(entry.seen_source_ids)
|
|
seen = set(seen_source_ids)
|
|
for item in result.items:
|
|
if item.source_id in seen:
|
|
continue
|
|
retry_key = f"subagent_item:{entry.child_conversation_id}:{item.source_id}"
|
|
if item_retry_tracker.retry_delay_s(retry_key) is not None:
|
|
# Try again on a later tick — leave the cursor where
|
|
# it was so we re-read the same items.
|
|
items_failed = True
|
|
break
|
|
try:
|
|
await _post_external_conversation_item(
|
|
client,
|
|
session_id=entry.child_conversation_id,
|
|
item=item,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
decision = item_retry_tracker.record_failure(retry_key, exc)
|
|
if decision.exhausted:
|
|
_logger.error(
|
|
"Dropping claude-native sub-agent transcript item after "
|
|
"permanent HTTP failures; child=%s source_id=%s "
|
|
"attempts=%s http_status=%s",
|
|
entry.child_conversation_id,
|
|
item.source_id,
|
|
decision.attempts,
|
|
_http_status_for_log(exc),
|
|
)
|
|
# Dead-letter the dropped item for recovery (#1120; replay #1579).
|
|
append_dead_letter(
|
|
bridge_dir,
|
|
session_id=entry.child_conversation_id,
|
|
event_type="external_conversation_item",
|
|
payload={
|
|
"item_type": item.item_type,
|
|
"item_data": item.data,
|
|
"response_id": item.response_id,
|
|
},
|
|
reason="permanent HTTP failure after retries",
|
|
# Claude only dead-letters permanent 4xx (it retries
|
|
# transient failures forever), so the server proved it
|
|
# rejected the item: never ambiguous, never replayable (#1579).
|
|
delivered_ambiguous=False,
|
|
http_status=_http_status_for_log(exc),
|
|
)
|
|
# Skip this item and continue — alternative is to
|
|
# block the whole sub-agent forever on one poison
|
|
# record. The full transcript is still on disk if
|
|
# someone needs to recover it.
|
|
seen.add(item.source_id)
|
|
seen_source_ids.append(item.source_id)
|
|
new_entry = SubagentEntry(
|
|
subagent_id=entry.subagent_id,
|
|
child_conversation_id=entry.child_conversation_id,
|
|
byte_offset=entry.byte_offset,
|
|
seen_source_ids=_bounded_seen_source_ids(seen_source_ids),
|
|
last_activity_ts=new_entry.last_activity_ts,
|
|
last_status=new_entry.last_status,
|
|
)
|
|
updated = SubagentForwardState(
|
|
subagents={**updated.subagents, subagent_id: new_entry}
|
|
)
|
|
await _write_subagent_forward_state_async(bridge_dir, updated)
|
|
continue
|
|
if post_may_have_been_delivered(exc):
|
|
# Ambiguous failure: the item may already be committed
|
|
# (no external-item dedup), so a retry would duplicate
|
|
# it. Skip rather than re-post.
|
|
_logger.warning(
|
|
"Skipping claude-native sub-agent item after an ambiguous POST "
|
|
"failure (may already be committed); not retrying to avoid a "
|
|
"duplicate; child=%s source_id=%s http_status=%s",
|
|
entry.child_conversation_id,
|
|
item.source_id,
|
|
_http_status_for_log(exc),
|
|
exc_info=True,
|
|
)
|
|
item_retry_tracker.clear(retry_key)
|
|
seen.add(item.source_id)
|
|
seen_source_ids.append(item.source_id)
|
|
new_entry = SubagentEntry(
|
|
subagent_id=entry.subagent_id,
|
|
child_conversation_id=entry.child_conversation_id,
|
|
byte_offset=entry.byte_offset,
|
|
seen_source_ids=_bounded_seen_source_ids(seen_source_ids),
|
|
last_activity_ts=new_entry.last_activity_ts,
|
|
last_status=new_entry.last_status,
|
|
)
|
|
updated = SubagentForwardState(
|
|
subagents={**updated.subagents, subagent_id: new_entry}
|
|
)
|
|
await _write_subagent_forward_state_async(bridge_dir, updated)
|
|
continue
|
|
_logger.warning(
|
|
"Failed to forward claude-native sub-agent item; child=%s "
|
|
"source_id=%s attempt=%s permanent=%s next_retry_s=%.3f "
|
|
"http_status=%s",
|
|
entry.child_conversation_id,
|
|
item.source_id,
|
|
decision.attempts,
|
|
decision.permanent,
|
|
decision.delay_s,
|
|
_http_status_for_log(exc),
|
|
exc_info=True,
|
|
)
|
|
# Hold byte_offset where it was so the next tick
|
|
# re-reads the failed item (and everything after).
|
|
# ``seen_source_ids`` suppresses successfully-posted
|
|
# earlier items locally so retry safety does not
|
|
# depend on AP-side item-id idempotency.
|
|
items_failed = True
|
|
break
|
|
item_retry_tracker.clear(retry_key)
|
|
had_item = True
|
|
seen.add(item.source_id)
|
|
seen_source_ids.append(item.source_id)
|
|
new_entry = SubagentEntry(
|
|
subagent_id=entry.subagent_id,
|
|
child_conversation_id=entry.child_conversation_id,
|
|
byte_offset=entry.byte_offset,
|
|
seen_source_ids=_bounded_seen_source_ids(seen_source_ids),
|
|
last_activity_ts=now,
|
|
last_status=new_entry.last_status,
|
|
)
|
|
updated = SubagentForwardState(subagents={**updated.subagents, subagent_id: new_entry})
|
|
await _write_subagent_forward_state_async(bridge_dir, updated)
|
|
# Only advance the cursor when every item this tick was
|
|
# posted successfully (or there were no items at all).
|
|
# Advancing past a failed item permanently skips it.
|
|
if not items_failed and (result.byte_offset != entry.byte_offset or had_item):
|
|
new_entry = SubagentEntry(
|
|
subagent_id=entry.subagent_id,
|
|
child_conversation_id=entry.child_conversation_id,
|
|
byte_offset=result.byte_offset,
|
|
seen_source_ids=_bounded_seen_source_ids(seen_source_ids),
|
|
last_activity_ts=now if had_item else entry.last_activity_ts,
|
|
last_status=entry.last_status,
|
|
)
|
|
elif had_item:
|
|
# Items DID flow but a later post failed — still record
|
|
# the activity timestamp so the status badge advances,
|
|
# but leave byte_offset at the previous tick's value so
|
|
# the failed items get retried.
|
|
new_entry = SubagentEntry(
|
|
subagent_id=entry.subagent_id,
|
|
child_conversation_id=entry.child_conversation_id,
|
|
byte_offset=entry.byte_offset,
|
|
seen_source_ids=_bounded_seen_source_ids(seen_source_ids),
|
|
last_activity_ts=now,
|
|
last_status=entry.last_status,
|
|
)
|
|
|
|
# Quiescence-based status. Sub-agent transcripts don't carry
|
|
# an explicit "done" record (Claude doesn't expose one), so
|
|
# we infer "running" from item flow and "idle" from quiet
|
|
# time. The dedupe on ``last_status`` avoids spamming the
|
|
# cache on every tick when nothing changed.
|
|
desired_status: str | None = None
|
|
if had_item:
|
|
desired_status = "running"
|
|
elif (
|
|
new_entry.last_activity_ts is not None
|
|
and now - new_entry.last_activity_ts > _SUBAGENT_IDLE_QUIESCENCE_S
|
|
and new_entry.last_status != "idle"
|
|
):
|
|
desired_status = "idle"
|
|
if desired_status is not None and desired_status != new_entry.last_status:
|
|
retry_key = f"subagent_status:{entry.child_conversation_id}"
|
|
if status_retry_tracker.retry_delay_s(retry_key) is None:
|
|
try:
|
|
await post_external_session_status(
|
|
client,
|
|
session_id=entry.child_conversation_id,
|
|
status=desired_status,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
decision = status_retry_tracker.record_failure(retry_key, exc)
|
|
_logger.warning(
|
|
"Failed to forward claude-native sub-agent status; "
|
|
"child=%s status=%s attempt=%s next_retry_s=%.3f "
|
|
"http_status=%s",
|
|
entry.child_conversation_id,
|
|
desired_status,
|
|
decision.attempts,
|
|
decision.delay_s,
|
|
_http_status_for_log(exc),
|
|
exc_info=True,
|
|
)
|
|
else:
|
|
status_retry_tracker.clear(retry_key)
|
|
new_entry = SubagentEntry(
|
|
subagent_id=new_entry.subagent_id,
|
|
child_conversation_id=new_entry.child_conversation_id,
|
|
byte_offset=new_entry.byte_offset,
|
|
seen_source_ids=new_entry.seen_source_ids,
|
|
last_activity_ts=new_entry.last_activity_ts,
|
|
last_status=desired_status,
|
|
)
|
|
|
|
if new_entry is not entry:
|
|
updated = SubagentForwardState(subagents={**updated.subagents, subagent_id: new_entry})
|
|
await _write_subagent_forward_state_async(bridge_dir, updated)
|
|
|
|
return updated
|
|
|
|
|
|
def _cumulative_cost_from_status_state(state: dict[str, Any] | None) -> float | None:
|
|
"""
|
|
Extract Claude Code's cumulative session cost from a statusLine snapshot.
|
|
|
|
:param state: Parsed ``context.json`` payload from
|
|
:func:`read_claude_context_state`, or ``None`` when none captured
|
|
yet.
|
|
:returns: ``state["total_cost_usd"]`` as a non-negative float, or
|
|
``None`` when absent / malformed. This is the authoritative
|
|
whole-session total — it includes Task sub-agent spend once Claude
|
|
Code settles it — but lags while a sub-agent is still running.
|
|
"""
|
|
if not isinstance(state, dict):
|
|
return None
|
|
raw = state.get("total_cost_usd")
|
|
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
|
|
return None
|
|
if raw < 0:
|
|
return None
|
|
return float(raw)
|
|
|
|
|
|
def _transcript_cost_size_cached(
|
|
transcript_path: Path,
|
|
*,
|
|
include_sidechains: bool,
|
|
cache: dict[Path, _TranscriptCostCacheEntry],
|
|
) -> float | None:
|
|
"""
|
|
Cumulative transcript cost, recomputed only when the file grows.
|
|
|
|
Wraps :func:`compute_transcript_cumulative_cost` with a per-process
|
|
size-keyed cache so an unchanged transcript isn't re-parsed every
|
|
poll. On a forwarder restart the cache starts empty and the first
|
|
call recomputes from the full file, so the estimate is correct across
|
|
restarts (unlike an in-memory running sum, which would lose the
|
|
pre-restart portion).
|
|
|
|
:param transcript_path: Transcript JSONL path.
|
|
:param include_sidechains: Forwarded to
|
|
:func:`compute_transcript_cumulative_cost` — ``False`` for a
|
|
parent transcript (sub-agent records are sidechains counted
|
|
elsewhere), ``True`` for a sub-agent's own transcript.
|
|
:param cache: Per-session cache mapping transcript path to its last
|
|
computed :class:`_TranscriptCostCacheEntry`. Mutated in place.
|
|
:returns: Cumulative USD cost, or ``None`` when nothing is priceable
|
|
(missing file included).
|
|
"""
|
|
try:
|
|
size = transcript_path.stat().st_size
|
|
except OSError:
|
|
return None
|
|
cached = cache.get(transcript_path)
|
|
if cached is not None and cached.size == size:
|
|
return cached.cost_usd
|
|
cost = compute_transcript_cumulative_cost(
|
|
transcript_path, include_sidechains=include_sidechains
|
|
)
|
|
cache[transcript_path] = _TranscriptCostCacheEntry(size=size, cost_usd=cost)
|
|
return cost
|
|
|
|
|
|
def _session_cost_estimate(
|
|
*,
|
|
parent_transcript_path: Path,
|
|
active_subagents: list[SubagentEntry],
|
|
status_cost: float | None,
|
|
cost_cache: dict[Path, _TranscriptCostCacheEntry],
|
|
) -> float | None:
|
|
"""
|
|
Compute ``max(S, C)`` for the parent session's POLICY/budget cost.
|
|
|
|
This is the value the cost-budget gate reads (``policy_cost_usd``),
|
|
NOT the displayed cost — display uses ``S`` alone so the badge matches
|
|
the Claude TUI ``/cost``. Synchronous (does transcript file I/O) —
|
|
call via :func:`asyncio.to_thread`. ``C`` is the forwarder's real-time
|
|
estimate: the parent transcript's own cost (sidechains excluded) plus
|
|
the sum of each tracked sub-agent's own transcript cost (each priced
|
|
once per ``requestId`` — see
|
|
:func:`compute_transcript_cumulative_cost`). ``S`` is the statusLine
|
|
total. See :func:`_forward_session_cost` for why the two are combined
|
|
with ``max`` rather than added.
|
|
|
|
:param parent_transcript_path: Parent transcript JSONL path; its
|
|
sibling ``subagents/`` directory holds the sub-agent transcripts.
|
|
:param active_subagents: Sub-agents with a minted child conversation
|
|
(only these have an ``agent-<id>.jsonl`` on disk to price).
|
|
:param status_cost: ``S`` — the statusLine total, or ``None`` when
|
|
not captured yet.
|
|
:param cost_cache: Per-session size-keyed transcript cost cache,
|
|
mutated in place.
|
|
:returns: ``max(S, C)`` in USD, or ``None`` when neither source
|
|
yields a priceable cost.
|
|
"""
|
|
subagents_dir = _subagents_dir_for_transcript(parent_transcript_path)
|
|
estimate: float | None = _transcript_cost_size_cached(
|
|
parent_transcript_path, include_sidechains=False, cache=cost_cache
|
|
)
|
|
for entry in active_subagents:
|
|
jsonl_path = subagents_dir / f"agent-{entry.subagent_id}.jsonl"
|
|
sub_cost = _transcript_cost_size_cached(
|
|
jsonl_path, include_sidechains=True, cache=cost_cache
|
|
)
|
|
if sub_cost is not None:
|
|
# Seed the accumulator from the parent cost, or 0.0 when the parent
|
|
# had nothing priceable — so sub-agent cost still contributes to C.
|
|
estimate = (estimate or 0.0) + sub_cost
|
|
candidates = [cost for cost in (status_cost, estimate) if cost is not None]
|
|
if not candidates:
|
|
return None
|
|
return max(candidates)
|
|
|
|
|
|
async def _forward_session_cost(
|
|
*,
|
|
client: httpx.AsyncClient,
|
|
session_id: str,
|
|
bridge_dir: Path,
|
|
parent_transcript_path: Path,
|
|
subagent_state: SubagentForwardState,
|
|
dedupe: _ForwardDedupeState,
|
|
cost_cache: dict[Path, _TranscriptCostCacheEntry],
|
|
) -> None:
|
|
"""
|
|
POST the parent session's cost as TWO values: display and policy.
|
|
|
|
The parent session's cost-budget policy gates EVERY tool call in the
|
|
Claude process — including a Task sub-agent's, whose ``PreToolUse``
|
|
hook the runner evaluates against this parent session (the bridge has
|
|
one active session id; there is no per-sub-agent policy routing). But
|
|
Claude Code's statusLine ``total_cost_usd`` (``S``) is **frozen for
|
|
the entire duration of a sub-agent run** — the statusLine isn't even
|
|
invoked while a sub-agent runs; ``S`` jumps to the sub-agent-inclusive
|
|
total only when the sub-agent returns (verified live). So a value
|
|
based on ``S`` alone can't gate a runaway sub-agent mid-turn.
|
|
|
|
Display and enforcement therefore need different numbers, posted as
|
|
two separate fields the server persists independently:
|
|
|
|
- ``cumulative_cost_usd`` = ``S`` verbatim — the DISPLAY cost. The
|
|
parent badge then matches ``/cost`` in the Claude TUI exactly (``S``
|
|
is Claude's own billing and already includes sub-agent spend once
|
|
settled). It is frozen during a sub-agent run; that's acceptable
|
|
for display.
|
|
- ``policy_cost_usd`` = ``max(S, C)`` — the POLICY/budget cost. ``C``
|
|
is the forwarder's real-time estimate (parent transcript own
|
|
messages + each tracked sub-agent's transcript, each priced once
|
|
per ``requestId``). ``C`` advances while ``S`` is frozen, so the
|
|
gate sees in-flight sub-agent spend and can block mid-turn. With no
|
|
sub-agent there is no lag, so it equals ``S``.
|
|
|
|
The brief intra-turn divergence (badge shows frozen ``S`` while the
|
|
gate uses the higher live ``C``) is intentional and reconciles at the
|
|
turn boundary when ``S`` jumps; ``max`` keeps both monotonic.
|
|
|
|
Best-effort, like the other forwarder posts: a failed POST is retried
|
|
on the next poll (the ``dedupe`` baselines advance only on success).
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Parent (claude-native) conversation id the cost is
|
|
attributed to, e.g. ``"conv_abc123"``.
|
|
:param bridge_dir: Native Claude bridge directory (holds the
|
|
statusLine snapshot read for ``S``).
|
|
:param parent_transcript_path: Parent transcript JSONL path — used for
|
|
the ``C`` estimate and to locate the ``subagents/`` directory.
|
|
:param subagent_state: Current sub-agent cursor map; its tracked
|
|
sub-agents' transcripts contribute to ``C``.
|
|
:param dedupe: Carries ``posted_cost`` (display ``S``) and
|
|
``posted_policy_cost`` (``max(S, C)``) so steady values aren't
|
|
re-POSTed each poll; mutated in place on a successful post.
|
|
:param cost_cache: Per-session size-keyed transcript cost cache,
|
|
mutated in place.
|
|
:returns: None.
|
|
"""
|
|
status_state = await asyncio.to_thread(read_claude_context_state, bridge_dir)
|
|
status_cost = _cumulative_cost_from_status_state(status_state)
|
|
active_subagents = [
|
|
entry for entry in subagent_state.subagents.values() if entry.child_conversation_id
|
|
]
|
|
# Display cost: the statusLine total S verbatim (matches /cost).
|
|
display_cost = status_cost
|
|
# Policy/budget cost: with no sub-agent it equals S; with a sub-agent
|
|
# running it is max(S, real-time transcript estimate) so the gate sees
|
|
# in-flight spend while S is frozen.
|
|
if not active_subagents:
|
|
policy_cost = status_cost
|
|
else:
|
|
policy_cost = await asyncio.to_thread(
|
|
_session_cost_estimate,
|
|
parent_transcript_path=parent_transcript_path,
|
|
active_subagents=active_subagents,
|
|
status_cost=status_cost,
|
|
cost_cache=cost_cache,
|
|
)
|
|
# Build the payload from whichever values are present AND have advanced.
|
|
# Monotonic per field: never walk a total backwards — guards a transient
|
|
# lower transcript read (e.g. just after a rotation) and suppresses
|
|
# steady-state churn. The two fields advance independently (policy_cost
|
|
# moves mid-turn while display_cost/S is frozen).
|
|
payload: dict[str, float | str] = {}
|
|
if display_cost is not None and (
|
|
dedupe.posted_cost is None or display_cost > dedupe.posted_cost
|
|
):
|
|
payload["cumulative_cost_usd"] = display_cost
|
|
if policy_cost is not None and (
|
|
dedupe.posted_policy_cost is None or policy_cost > dedupe.posted_policy_cost
|
|
):
|
|
payload["policy_cost_usd"] = policy_cost
|
|
if not payload:
|
|
return
|
|
# Tag a display-cost (S) advance with the active model captured by the
|
|
# statusLine wrapper (``{"model": "claude-opus-4-8", ...}`` in context.json).
|
|
# claude-native sends no token counts with its cost, so the server has
|
|
# nothing to attribute the cost to per-model without this — leaving it out
|
|
# of the TOKEN USAGE breakdown while the session total still counts it. Sent
|
|
# only when the display cost moves: that is the value being attributed
|
|
# (``policy_cost_usd``-only mid-turn posts carry no new display cost).
|
|
if "cumulative_cost_usd" in payload and isinstance(status_state, dict):
|
|
model = status_state.get("model")
|
|
if isinstance(model, str) and model:
|
|
payload["model"] = model
|
|
try:
|
|
await _post_external_session_usage(
|
|
client,
|
|
session_id=session_id,
|
|
usage=payload,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
_logger.warning(
|
|
"Failed to forward Claude session cost; session=%s bridge_dir=%s http_status=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
_http_status_for_log(exc),
|
|
exc_info=True,
|
|
)
|
|
return
|
|
if "cumulative_cost_usd" in payload:
|
|
dedupe.posted_cost = display_cost
|
|
if "policy_cost_usd" in payload:
|
|
dedupe.posted_policy_cost = policy_cost
|
|
|
|
|
|
async def _supervisor_sleep(seconds: float) -> None:
|
|
"""
|
|
Sleep helper used between forwarder restarts.
|
|
|
|
Exists as a private indirection so tests can stub the wait
|
|
without monkeypatching the global ``asyncio.sleep`` (which would
|
|
leak across the whole pytest process; see project test rule 14).
|
|
|
|
:param seconds: Duration to sleep, e.g. ``1.0``.
|
|
"""
|
|
await asyncio.sleep(seconds)
|
|
|
|
|
|
def _supervisor_monotonic() -> float:
|
|
"""
|
|
Monotonic clock reading used to measure forwarder uptime.
|
|
|
|
Exists as a private indirection so tests can drive the
|
|
healthy-uptime branch deterministically without touching the
|
|
global ``time.monotonic`` (same module-singleton hazard as
|
|
``asyncio.sleep``).
|
|
|
|
:returns: Seconds from an unspecified monotonic epoch.
|
|
"""
|
|
return time.monotonic()
|
|
|
|
|
|
async def supervise_forwarder(
|
|
*,
|
|
base_url: str,
|
|
headers: dict[str, str],
|
|
session_id: str,
|
|
bridge_dir: Path,
|
|
agent_name: str,
|
|
start_at_end: bool,
|
|
poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S,
|
|
auth: httpx.Auth | None = None,
|
|
skip_user_messages: bool = False,
|
|
) -> None:
|
|
"""
|
|
Run :func:`forward_claude_transcript_to_session` under a restart supervisor.
|
|
|
|
The forwarder's own loop catches :class:`Exception` per iteration,
|
|
but an error raised outside that catch (e.g. during the
|
|
``async with httpx.AsyncClient`` setup) or an unexpected normal
|
|
return would otherwise kill the task silently and leave the chat
|
|
view permanently desynced from the running terminal. This
|
|
supervisor restarts the forwarder with bounded exponential
|
|
backoff so a transient crash recovers without operator action.
|
|
|
|
Cancellation is honored: :class:`asyncio.CancelledError` exits
|
|
the loop cleanly so the parent's teardown sequence (terminal
|
|
stop, bridge cleanup) runs as before. Other
|
|
:class:`BaseException` subclasses (``KeyboardInterrupt``,
|
|
``SystemExit``, ``GeneratorExit``) also propagate — only
|
|
:class:`Exception` subclasses trigger a restart, so process-
|
|
shutdown signals are not swallowed.
|
|
|
|
The on-disk cursor in ``bridge_dir`` is the durable source of
|
|
truth for progress, so restarts resume exactly where the prior
|
|
run left off — ``start_at_end`` is only consulted on a cold
|
|
bridge with no persisted cursor.
|
|
|
|
:param base_url: Omnigent server base URL, e.g.
|
|
``"http://localhost:6767"``.
|
|
:param headers: Static HTTP headers for Omnigent requests. Authorization
|
|
is normally supplied via ``auth`` instead so OAuth tokens are
|
|
refreshed per request.
|
|
:param session_id: Omnigent session/conversation id, e.g.
|
|
``"conv_abc123"``.
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param agent_name: Agent/model name to stamp on mirrored output.
|
|
:param start_at_end: When ``True`` and no prior forward cursor
|
|
exists, start from the current transcript end.
|
|
:param poll_interval_s: Seconds between transcript polls inside
|
|
the forwarder loop. Forwarded verbatim.
|
|
:param auth: Optional httpx Auth that mints a fresh bearer token
|
|
per request, e.g. ``_server_auth(profile)``. Forwarded verbatim
|
|
to :func:`forward_claude_transcript_to_session`.
|
|
:returns: Never normally returns; cancel the task to stop it.
|
|
"""
|
|
backoff_s = _SUPERVISOR_INITIAL_BACKOFF_S
|
|
while True:
|
|
run_started_at = _supervisor_monotonic()
|
|
crash_exc: Exception | None = None
|
|
try:
|
|
await forward_claude_transcript_to_session(
|
|
base_url=base_url,
|
|
headers=headers,
|
|
session_id=session_id,
|
|
bridge_dir=bridge_dir,
|
|
agent_name=agent_name,
|
|
start_at_end=start_at_end,
|
|
poll_interval_s=poll_interval_s,
|
|
auth=auth,
|
|
skip_user_messages=skip_user_messages,
|
|
)
|
|
# The forwarder loop is ``while True`` and is not expected
|
|
# to return normally. Treat any normal return as a crash
|
|
# and restart.
|
|
_logger.warning(
|
|
"Claude transcript forwarder returned unexpectedly; restarting; "
|
|
"session=%s bridge_dir=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001 — supervisor restarts on any Exception
|
|
crash_exc = exc
|
|
run_duration_s = _supervisor_monotonic() - run_started_at
|
|
if run_duration_s >= _SUPERVISOR_HEALTHY_UPTIME_S:
|
|
backoff_s = _SUPERVISOR_INITIAL_BACKOFF_S
|
|
if crash_exc is not None:
|
|
# Log AFTER the healthy-uptime reset so the reported delay
|
|
# matches the sleep that actually follows.
|
|
_logger.error(
|
|
"Claude transcript forwarder crashed; restarting in %.1fs; "
|
|
"session=%s bridge_dir=%s",
|
|
backoff_s,
|
|
session_id,
|
|
bridge_dir,
|
|
exc_info=crash_exc,
|
|
)
|
|
await _supervisor_sleep(backoff_s)
|
|
backoff_s = min(backoff_s * 2.0, _SUPERVISOR_MAX_BACKOFF_S)
|
|
|
|
|
|
async def _maybe_rotate_session_on_clear(
|
|
*,
|
|
client: httpx.AsyncClient,
|
|
session_id: str,
|
|
bridge_dir: Path,
|
|
state: HookForwardState,
|
|
) -> str | None:
|
|
"""
|
|
Rotate the active Omnigent session when Claude reports ``/clear``.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Currently active Omnigent session id, e.g.
|
|
``"conv_old"``.
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param state: Current hook cursor state.
|
|
:returns: New active session id when rotation succeeded, otherwise
|
|
``None`` (no clear pending, or the rotation failed and was consumed
|
|
to avoid a re-rotation loop).
|
|
"""
|
|
result = await asyncio.to_thread(_read_hook_events_for_state, bridge_dir, state)
|
|
clear_record = next(
|
|
(
|
|
record
|
|
for record in result.records
|
|
if record.event_name == "SessionStart" and record.source == "clear"
|
|
),
|
|
None,
|
|
)
|
|
if clear_record is None:
|
|
return None
|
|
|
|
# Consume this clear hook EXACTLY ONCE. If the rotation raises partway
|
|
# (e.g. the terminal transfer returns 400 because the target already owns a
|
|
# terminal), we must still advance the cursor: otherwise the forwarder's
|
|
# next poll re-reads the same clear record and re-rotates — creating a fresh
|
|
# replacement session every poll, unbounded. A single /clear rotates at most
|
|
# once; a failed rotation is logged and skipped (the old session simply
|
|
# keeps running) rather than retried forever.
|
|
durable = HookForwardState(
|
|
event_cursor=clear_record.event_cursor,
|
|
byte_offset=clear_record.byte_offset,
|
|
cursor_fingerprint=_jsonl_cursor_fingerprint(
|
|
bridge_dir / _HOOKS_FILE,
|
|
clear_record.byte_offset,
|
|
),
|
|
)
|
|
new_session_id: str | None = None
|
|
try:
|
|
if clear_record.clear_rotated_to:
|
|
new_session_id = clear_record.clear_rotated_to
|
|
else:
|
|
new_session_id = await _create_clear_replacement_session(
|
|
client=client,
|
|
old_session_id=session_id,
|
|
bridge_dir=bridge_dir,
|
|
)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
_logger.exception(
|
|
"Claude /clear rotation failed; consuming the clear hook to avoid a "
|
|
"re-rotation loop. old_session=%s",
|
|
session_id,
|
|
)
|
|
await _write_hook_state_async(bridge_dir, durable)
|
|
reset_transcript_forward_state(bridge_dir, reset_hooks=False)
|
|
return new_session_id
|
|
|
|
|
|
async def _seed_fork_transcript_forward_state(
|
|
*,
|
|
bridge_dir: Path,
|
|
transcript_path: Path | None,
|
|
) -> None:
|
|
"""
|
|
Seed transcript forwarding after Omnigent has forked history.
|
|
|
|
Claude fork transcripts start with copied source-session records.
|
|
The Omnigent fork endpoint has already copied those conversation items,
|
|
so forwarding must begin at the current end of the new Claude
|
|
transcript rather than replaying the copied prefix.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param transcript_path: New Claude fork transcript path, e.g.
|
|
``"/home/user/.claude/projects/x/session.jsonl"``. ``None``
|
|
falls back to removing the stale cursor.
|
|
:returns: None.
|
|
"""
|
|
if transcript_path is None:
|
|
reset_transcript_forward_state(bridge_dir, reset_hooks=False)
|
|
return
|
|
reset_transcript_forward_state(bridge_dir, reset_hooks=False)
|
|
byte_offset = await asyncio.to_thread(_transcript_end_offset, transcript_path)
|
|
state = TranscriptForwardState(
|
|
transcript_path=transcript_path,
|
|
line_cursor=0,
|
|
byte_offset=byte_offset,
|
|
cursor_fingerprint=_jsonl_cursor_fingerprint(transcript_path, byte_offset),
|
|
)
|
|
await _write_forward_state_async(bridge_dir, state)
|
|
|
|
|
|
async def _create_clear_replacement_session(
|
|
*,
|
|
client: httpx.AsyncClient,
|
|
old_session_id: str,
|
|
bridge_dir: Path,
|
|
) -> str:
|
|
"""
|
|
Create the fresh Omnigent session for a Claude ``/clear`` event.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param old_session_id: Session being rotated away from, e.g.
|
|
``"conv_old"``.
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:returns: New Omnigent session id, e.g. ``"conv_new"``.
|
|
:raises httpx.HTTPError: If Omnigent rejects session creation, new-session
|
|
binding, or terminal transfer. Clearing the old runner binding is
|
|
best-effort after the bridge has rotated.
|
|
:raises RuntimeError: If the old session snapshot is malformed.
|
|
"""
|
|
old = await _fetch_session_snapshot(client, old_session_id)
|
|
agent_id = old.get("agent_id")
|
|
if not isinstance(agent_id, str) or not agent_id:
|
|
raise RuntimeError(f"session {old_session_id!r} has no agent_id")
|
|
runner_id = old.get("runner_id")
|
|
labels = old.get("labels") if isinstance(old.get("labels"), dict) else {}
|
|
labels = {str(key): str(value) for key, value in labels.items()}
|
|
labels.setdefault(BRIDGE_ID_LABEL_KEY, read_bridge_id(bridge_dir) or old_session_id)
|
|
|
|
create_resp = await client.post(
|
|
"/v1/sessions",
|
|
json={
|
|
"agent_id": agent_id,
|
|
"labels": labels,
|
|
},
|
|
)
|
|
create_resp.raise_for_status()
|
|
created = _parse_json_response(create_resp, context="clear-replacement session create")
|
|
new_session_id = created.get("id")
|
|
if not isinstance(new_session_id, str) or not new_session_id:
|
|
raise RuntimeError("clear replacement session response did not include id")
|
|
|
|
if isinstance(runner_id, str) and runner_id:
|
|
bind_resp = await client.patch(
|
|
f"/v1/sessions/{url_component(new_session_id)}",
|
|
json={"runner_id": runner_id},
|
|
)
|
|
bind_resp.raise_for_status()
|
|
|
|
terminal_id = terminal_resource_id("claude", "main")
|
|
transfer_resp = await client.post(
|
|
(
|
|
f"/v1/sessions/{url_component(old_session_id)}"
|
|
f"/resources/terminals/{url_component(terminal_id)}/transfer"
|
|
),
|
|
json={"target_session_id": new_session_id},
|
|
)
|
|
transfer_resp.raise_for_status()
|
|
|
|
write_active_session_id(bridge_dir, new_session_id)
|
|
clear_resp = await client.patch(
|
|
f"/v1/sessions/{url_component(old_session_id)}",
|
|
json={
|
|
"runner_id": "",
|
|
# Re-key the superseded session onto a DISTINCT "-cleared" bridge id.
|
|
# The new session keeps the original bridge id (set above) and owns
|
|
# the live terminal/pane in D(original); the old session must NOT
|
|
# share that dir, or resuming it (host wake-on-message /
|
|
# ``omnigent claude --resume``) would put a second forwarder on the
|
|
# live transcript (duplicate items) and trip the executor's
|
|
# "no longer active after /clear" guard. ``_auto_create_claude_terminal``
|
|
# recognises this exact marker and cold-resumes the old session in
|
|
# its own isolated D("{id}-cleared"); the executor spawn_env resolves
|
|
# the same label, so both agree.
|
|
"labels": {BRIDGE_ID_LABEL_KEY: f"{old_session_id}-cleared"},
|
|
},
|
|
)
|
|
if clear_resp.status_code >= 400:
|
|
_logger.warning(
|
|
"Failed to clear old claude-native runner binding after /clear; "
|
|
"old_session=%s new_session=%s status=%s body=%s",
|
|
old_session_id,
|
|
new_session_id,
|
|
clear_resp.status_code,
|
|
clear_resp.text,
|
|
)
|
|
return new_session_id
|
|
|
|
|
|
async def _maybe_rotate_session_on_fork(
|
|
*,
|
|
client: httpx.AsyncClient,
|
|
session_id: str,
|
|
bridge_dir: Path,
|
|
state: HookForwardState,
|
|
) -> str | None:
|
|
"""
|
|
Fork the active Omnigent session when Claude reports ``/fork``/``/branch``.
|
|
|
|
The hook annotates branch-created ``SessionStart source=resume``
|
|
records before recording them. The forwarder consumes that
|
|
annotation so it does not have to infer branch state after
|
|
``state.json`` already points at the new Claude session id.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Currently active Omnigent session id, e.g.
|
|
``"conv_old"``.
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param state: Current hook cursor state.
|
|
:returns: New active session id when fork rotation succeeded, otherwise
|
|
``None`` (no fork pending, or the rotation failed and was consumed to
|
|
avoid a re-rotation loop).
|
|
"""
|
|
result = await asyncio.to_thread(_read_hook_events_for_state, bridge_dir, state)
|
|
fork_record = next((record for record in result.records if _is_fork_hook_record(record)), None)
|
|
if fork_record is None:
|
|
return None
|
|
|
|
# Consume this fork hook EXACTLY ONCE — see the matching guard in
|
|
# _maybe_rotate_session_on_clear. A rotation that raises partway (e.g. a
|
|
# terminal-transfer 400) must still advance the cursor so the next poll does
|
|
# not re-read the same fork record and create another replacement session
|
|
# without bound.
|
|
durable = HookForwardState(
|
|
event_cursor=fork_record.event_cursor,
|
|
byte_offset=fork_record.byte_offset,
|
|
cursor_fingerprint=_jsonl_cursor_fingerprint(
|
|
bridge_dir / _HOOKS_FILE,
|
|
fork_record.byte_offset,
|
|
),
|
|
)
|
|
new_session_id: str | None = None
|
|
try:
|
|
if fork_record.fork_rotated_to:
|
|
new_session_id = fork_record.fork_rotated_to
|
|
else:
|
|
new_session_id = await _create_fork_replacement_session(
|
|
client=client,
|
|
old_session_id=session_id,
|
|
bridge_dir=bridge_dir,
|
|
)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
_logger.exception(
|
|
"Claude /fork rotation failed; consuming the fork hook to avoid a "
|
|
"re-rotation loop. old_session=%s",
|
|
session_id,
|
|
)
|
|
await _write_hook_state_async(bridge_dir, durable)
|
|
await _seed_fork_transcript_forward_state(
|
|
bridge_dir=bridge_dir,
|
|
transcript_path=fork_record.transcript_path,
|
|
)
|
|
return new_session_id
|
|
|
|
|
|
async def _create_fork_replacement_session(
|
|
*,
|
|
client: httpx.AsyncClient,
|
|
old_session_id: str,
|
|
bridge_dir: Path,
|
|
) -> str:
|
|
"""
|
|
Create the forked Omnigent session for a Claude ``/fork``/``/branch``.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param old_session_id: Session being forked away from, e.g.
|
|
``"conv_old"``.
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:returns: New Omnigent session id, e.g. ``"conv_fork"``.
|
|
:raises httpx.HTTPError: If Omnigent rejects session fetch, fork,
|
|
new-session binding, or terminal transfer. Clearing the old
|
|
runner binding is best-effort after the bridge has rotated.
|
|
:raises RuntimeError: If the Omnigent fork response is malformed.
|
|
"""
|
|
old = await _fetch_session_snapshot(client, old_session_id)
|
|
runner_id = old.get("runner_id")
|
|
|
|
fork_resp = await client.post(
|
|
f"/v1/sessions/{url_component(old_session_id)}/fork",
|
|
json={},
|
|
)
|
|
fork_resp.raise_for_status()
|
|
forked = _parse_json_response(fork_resp, context=f"fork of session {old_session_id!r}")
|
|
new_session_id = forked.get("id")
|
|
if not isinstance(new_session_id, str) or not new_session_id:
|
|
raise RuntimeError("fork replacement session response did not include id")
|
|
|
|
if isinstance(runner_id, str) and runner_id:
|
|
bind_resp = await client.patch(
|
|
f"/v1/sessions/{url_component(new_session_id)}",
|
|
json={"runner_id": runner_id},
|
|
)
|
|
bind_resp.raise_for_status()
|
|
|
|
terminal_id = terminal_resource_id("claude", "main")
|
|
transfer_resp = await client.post(
|
|
(
|
|
f"/v1/sessions/{url_component(old_session_id)}"
|
|
f"/resources/terminals/{url_component(terminal_id)}/transfer"
|
|
),
|
|
json={"target_session_id": new_session_id},
|
|
)
|
|
transfer_resp.raise_for_status()
|
|
|
|
write_active_session_id(bridge_dir, new_session_id)
|
|
clear_resp = await client.patch(
|
|
f"/v1/sessions/{url_component(old_session_id)}",
|
|
json={"runner_id": ""},
|
|
)
|
|
if clear_resp.status_code >= 400:
|
|
_logger.warning(
|
|
"Failed to clear old claude-native runner binding after /fork; "
|
|
"old_session=%s new_session=%s status=%s body=%s",
|
|
old_session_id,
|
|
new_session_id,
|
|
clear_resp.status_code,
|
|
clear_resp.text,
|
|
)
|
|
return new_session_id
|
|
|
|
|
|
def _is_subagent_hook_record(record: ClaudeHookRecord) -> bool:
|
|
"""
|
|
Return whether a hook record originated from a Claude subagent.
|
|
|
|
Claude Code subagent transcripts live under a ``subagents/``
|
|
subdirectory (e.g.
|
|
``~/.claude/projects/<encoded>/<session>/subagents/agent-<id>.jsonl``).
|
|
When a subagent fires a lifecycle hook (``Stop``,
|
|
``UserPromptSubmit``), its ``transcript_path`` contains that
|
|
``subagents`` component. The parent process's transcript lives
|
|
one level up (``<session>.jsonl``) and never contains it.
|
|
|
|
:param record: Claude hook record read from ``hooks.jsonl``.
|
|
:returns: ``True`` when the record's transcript path indicates a
|
|
subagent, ``False`` otherwise (including when no transcript
|
|
path is available — conservative default so parent events
|
|
are never accidentally dropped).
|
|
"""
|
|
if record.transcript_path is None:
|
|
return False
|
|
return "subagents" in record.transcript_path.parts
|
|
|
|
|
|
def _is_fork_hook_record(record: ClaudeHookRecord) -> bool:
|
|
"""
|
|
Return whether a hook record represents Claude ``/fork``.
|
|
|
|
The stable signal comes from Claude's structured ``forkedFrom``
|
|
transcript metadata or a recent local-command record, not from the
|
|
human-facing session title. Hook-side annotations are still
|
|
honored for idempotency when the synchronous hook has already
|
|
completed the Omnigent fork.
|
|
|
|
:param record: Claude hook record read from hooks.jsonl.
|
|
:returns: ``True`` when the active Omnigent session should be forked.
|
|
"""
|
|
if record.fork_detected or record.fork_rotated_to:
|
|
return True
|
|
if record.event_name != "SessionStart" or record.source != "resume":
|
|
return False
|
|
if record.transcript_path is None or record.claude_session_id is None:
|
|
return False
|
|
if record.recorded_at is None:
|
|
return False
|
|
if record.previous_claude_session_id is None:
|
|
return False
|
|
if record.claude_session_was_seen is not False:
|
|
return False
|
|
return transcript_has_forked_from_marker(
|
|
record.transcript_path,
|
|
claude_session_id=record.claude_session_id,
|
|
source_claude_session_id=record.previous_claude_session_id,
|
|
) or transcript_has_recent_local_command(
|
|
record.transcript_path,
|
|
claude_session_id=record.claude_session_id,
|
|
recorded_at=record.recorded_at,
|
|
command_names=_FORK_COMMAND_NAMES,
|
|
)
|
|
|
|
|
|
async def _fetch_session_snapshot(
|
|
client: httpx.AsyncClient,
|
|
session_id: str,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Fetch one Omnigent session snapshot.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session id, e.g. ``"conv_abc123"``.
|
|
:returns: Parsed JSON snapshot.
|
|
:raises httpx.HTTPError: If Omnigent returns a non-2xx status.
|
|
:raises RuntimeError: If the response body is not a JSON object.
|
|
"""
|
|
resp = await client.get(f"/v1/sessions/{url_component(session_id)}")
|
|
resp.raise_for_status()
|
|
payload = _parse_json_response(resp, context=f"session {session_id!r} snapshot")
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError(f"session {session_id!r} snapshot was not an object")
|
|
return payload
|
|
|
|
|
|
async def _maybe_mirror_external_session_id(
|
|
*,
|
|
client: httpx.AsyncClient,
|
|
session_id: str,
|
|
bridge_dir: Path,
|
|
) -> bool:
|
|
"""
|
|
Mirror Claude's native session id onto the Omnigent conversation row.
|
|
|
|
Reads the latest captured Claude-native session id from the
|
|
bridge state file and, if present, PATCHes
|
|
``external_session_id`` on the Omnigent conversation. Best-effort: a
|
|
transient HTTP failure logs a warning and returns ``False`` so
|
|
the caller retries on the next poll. Once the PATCH succeeds we
|
|
return ``True`` and the caller latches off — the value is
|
|
durable server-side from that point on.
|
|
|
|
A 4xx (e.g. the server rejects an attempted overwrite of an
|
|
already-set different value) also latches off — the divergence
|
|
is logged loudly but retrying would just hammer the server.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id.
|
|
:param bridge_dir: Native Claude bridge directory; the source of
|
|
the captured Claude session id.
|
|
:returns: ``True`` once mirroring is finished (or has been
|
|
determined to be unrecoverable); ``False`` to retry next
|
|
poll.
|
|
"""
|
|
claude_sid = read_claude_session_id(bridge_dir)
|
|
if claude_sid is None:
|
|
return False
|
|
try:
|
|
await _patch_external_session_id(
|
|
client,
|
|
session_id=session_id,
|
|
external_session_id=claude_sid,
|
|
)
|
|
except httpx.HTTPStatusError as exc:
|
|
# 4xx means the server rejected the write outright (e.g.
|
|
# overwrite conflict or schema validation). Retrying won't
|
|
# help; latch off and let the operator see the log.
|
|
if 400 <= exc.response.status_code < 500:
|
|
_logger.warning(
|
|
"AP rejected external_session_id PATCH (%s); session=%s claude_sid=%s",
|
|
exc.response.status_code,
|
|
session_id,
|
|
claude_sid,
|
|
)
|
|
return True
|
|
_logger.warning(
|
|
"Transient Omnigent error PATCHing external_session_id (%s); session=%s — will retry",
|
|
exc.response.status_code,
|
|
session_id,
|
|
)
|
|
return False
|
|
except httpx.HTTPError:
|
|
_logger.warning(
|
|
"Transient transport error PATCHing external_session_id; session=%s — will retry",
|
|
session_id,
|
|
exc_info=True,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def reset_transcript_forward_state(bridge_dir: Path, *, reset_hooks: bool = True) -> None:
|
|
"""
|
|
Remove the durable transcript-forward cursor for a fresh launch.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param reset_hooks: Whether to also remove the hook cursor. Keep
|
|
``False`` after consuming a ``/clear`` hook so the same clear
|
|
record is not processed again.
|
|
:returns: None.
|
|
"""
|
|
filenames = [
|
|
_FORWARDER_STATE_FILE,
|
|
"transcript_forwarder.pause.json",
|
|
]
|
|
if reset_hooks:
|
|
filenames.append(_HOOK_STATE_FILE)
|
|
for filename in filenames:
|
|
with contextlib.suppress(FileNotFoundError):
|
|
(bridge_dir / filename).unlink()
|
|
|
|
|
|
async def _ensure_hook_state(
|
|
bridge_dir: Path,
|
|
*,
|
|
start_at_end: bool,
|
|
session_id: str,
|
|
) -> HookForwardState:
|
|
"""
|
|
Return the hook cursor state, seeding it on first use.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param start_at_end: When ``True`` and no prior cursor exists,
|
|
start after the current complete hook records so prior records
|
|
(e.g. an earlier ``Stop`` from a stale session) are not
|
|
re-published on reattach while a partial trailing record can
|
|
still complete and be read.
|
|
:param session_id: Omnigent session/conversation id, e.g.
|
|
``"conv_abc123"``. Used for stale-cursor diagnostics.
|
|
:returns: The cursor state to use for the next hook poll.
|
|
"""
|
|
state = _read_hook_state(bridge_dir)
|
|
if state is not None:
|
|
return _validated_hook_state(bridge_dir, state, session_id=session_id)
|
|
byte_offset = 0
|
|
if start_at_end:
|
|
byte_offset = await asyncio.to_thread(_hook_end_offset, bridge_dir)
|
|
state = HookForwardState(
|
|
event_cursor=0,
|
|
byte_offset=byte_offset,
|
|
cursor_fingerprint=_jsonl_cursor_fingerprint(bridge_dir / _HOOKS_FILE, byte_offset),
|
|
)
|
|
await _write_hook_state_async(bridge_dir, state)
|
|
return state
|
|
|
|
|
|
def _compaction_status_for_record(record: ClaudeHookRecord) -> str | None:
|
|
"""
|
|
Map a hook record to a compaction-status value, if it is one.
|
|
|
|
Claude Code brackets a compaction with two hooks the forwarder
|
|
translates into ``external_compaction_status`` events:
|
|
|
|
* ``PreCompact`` → ``"in_progress"`` — fires right before Claude
|
|
compacts (manual ``/compact`` or automatic context overflow).
|
|
* ``SessionStart`` with ``source == "compact"`` → ``"completed"``
|
|
— fires when Claude resumes on the freshly-compacted context.
|
|
(Claude Code has no dedicated post-compaction hook, so the
|
|
``source == "compact"`` SessionStart is the completion signal.)
|
|
|
|
Other ``SessionStart`` sources (``startup`` / ``resume`` /
|
|
``clear``) are not compaction and return ``None``.
|
|
|
|
:param record: One parsed hook JSONL record.
|
|
:returns: ``"in_progress"``, ``"completed"``, or ``None`` when the
|
|
record is not a compaction boundary.
|
|
"""
|
|
if record.event_name == "PreCompact":
|
|
return "in_progress"
|
|
if record.event_name == "SessionStart" and record.source == "compact":
|
|
return "completed"
|
|
return None
|
|
|
|
|
|
async def _forward_available_status_events(
|
|
*,
|
|
client: httpx.AsyncClient,
|
|
session_id: str,
|
|
bridge_dir: Path,
|
|
state: HookForwardState,
|
|
retry_tracker: _PostRetryTracker,
|
|
task_subjects: dict[str, str],
|
|
task_statuses: dict[str, str],
|
|
task_order: list[str],
|
|
response_id: str | None = None,
|
|
) -> HookForwardState:
|
|
"""
|
|
Forward currently available hook events as ``session.status``.
|
|
|
|
Maps ``Stop`` → ``idle`` and ``StopFailure`` → ``failed`` via
|
|
``POST /v1/sessions/{id}/events`` with type ``external_session_status``
|
|
— the authoritative turn-end edges that drive sub-agent terminal
|
|
delivery (see :data:`_HOOK_EVENT_TO_STATUS`). ``running`` stays
|
|
PTY-derived (the pane-activity watcher drives the UI badge). Other hook
|
|
event names advance the cursor without emitting (no status meaning).
|
|
|
|
Also forwards native task state changes (``TaskCreated``,
|
|
``TaskCompleted``, ``PostToolUse``/``TaskUpdate``) and
|
|
``PostToolUse``/``TodoWrite`` todo updates as
|
|
``external_session_todos`` events. The ``task_subjects``,
|
|
``task_statuses``, and ``task_order`` dicts are mutated in-place
|
|
to accumulate per-session task state across polls.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id.
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param state: Current hook cursor state.
|
|
:param retry_tracker: In-memory retry/backoff tracker for hook
|
|
status posts.
|
|
:param task_subjects: Mutable map of task_id → subject text for the
|
|
native task system, e.g. ``{"1": "Create folder 'abc'"}``.
|
|
Updated in-place from ``TaskCreated`` hook events.
|
|
:param task_statuses: Mutable map of task_id → status string for the
|
|
native task system, e.g. ``{"1": "in_progress", "2": "pending"}``.
|
|
Updated in-place from ``TaskCreated``, ``TaskCompleted``, and
|
|
``PostToolUse``/``TaskUpdate`` hook events.
|
|
:param task_order: Mutable ordered list of task ids in creation order,
|
|
e.g. ``["1", "2", "3"]``. Appended in-place from ``TaskCreated``
|
|
events. Used to render the task list in a stable order.
|
|
:param response_id: Active turn's response id, stamped on the
|
|
``Stop``→``idle`` / ``StopFailure``→``failed`` edges so ap-web
|
|
closes the streaming ``activeResponse`` opened by the matching
|
|
turn-start ``running`` edge. ``None`` when no turn id is known
|
|
(the status still posts, just without a turn association).
|
|
:returns: Updated state. On post failure, returns the last
|
|
durable state so successfully-posted statuses are not
|
|
retried and the failing event is retried later.
|
|
"""
|
|
result = await asyncio.to_thread(_read_hook_events_for_state, bridge_dir, state)
|
|
if not result.records:
|
|
if result.event_cursor == state.event_cursor and result.byte_offset == (
|
|
state.byte_offset or 0
|
|
):
|
|
return state
|
|
durable = HookForwardState(
|
|
event_cursor=result.event_cursor,
|
|
byte_offset=result.byte_offset,
|
|
cursor_fingerprint=_jsonl_cursor_fingerprint(
|
|
bridge_dir / _HOOKS_FILE, result.byte_offset
|
|
),
|
|
)
|
|
await _write_hook_state_async(bridge_dir, durable)
|
|
return durable
|
|
durable = state
|
|
for record in result.records:
|
|
status = _HOOK_EVENT_TO_STATUS.get(record.event_name or "")
|
|
next_durable = HookForwardState(
|
|
event_cursor=record.event_cursor,
|
|
byte_offset=record.byte_offset,
|
|
cursor_fingerprint=_jsonl_cursor_fingerprint(
|
|
bridge_dir / _HOOKS_FILE, record.byte_offset
|
|
),
|
|
)
|
|
# Subagent lifecycle hooks land in the same hooks.jsonl as parent
|
|
# events because subagent processes inherit the parent's hook
|
|
# settings. With running/idle now PTY-derived, the only mapped
|
|
# status left is ``StopFailure`` → ``failed``: a subagent's
|
|
# failure must NOT flip the parent session to ``failed`` — the
|
|
# parent turn is still running while it awaits the Agent tool
|
|
# result.
|
|
if status is not None and _is_subagent_hook_record(record):
|
|
_logger.debug(
|
|
"Skipping subagent hook status; session=%s event=%s status=%s transcript=%s",
|
|
session_id,
|
|
record.event_name,
|
|
status,
|
|
record.transcript_path,
|
|
)
|
|
durable = next_durable
|
|
await _write_hook_state_async(bridge_dir, durable)
|
|
continue
|
|
if status is None:
|
|
# Compaction boundary (PreCompact / SessionStart source=compact)
|
|
# → forward as a compaction-status event so the web UI brackets
|
|
# Claude's real terminal compaction with its spinner. Best-effort:
|
|
# advance the cursor on failure so one failed post doesn't stall
|
|
# the rest of the hook stream.
|
|
compaction_status = _compaction_status_for_record(record)
|
|
if compaction_status is not None:
|
|
try:
|
|
await _post_external_compaction_status(
|
|
client,
|
|
session_id=session_id,
|
|
status=compaction_status,
|
|
)
|
|
except httpx.HTTPError:
|
|
_logger.warning(
|
|
"Failed to forward Claude compaction status; "
|
|
"session=%s event_cursor=%s status=%s",
|
|
session_id,
|
|
record.event_cursor,
|
|
compaction_status,
|
|
exc_info=True,
|
|
)
|
|
# Persist a compaction item so session resume knows
|
|
# the compaction boundary. Without this, rebuilding
|
|
# the transcript from the conversation DB loads the
|
|
# full pre-compaction history.
|
|
if compaction_status == "completed":
|
|
try:
|
|
await _persist_native_compaction_item(
|
|
client,
|
|
session_id=session_id,
|
|
bridge_dir=bridge_dir,
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
_logger.warning(
|
|
"Failed to persist compaction item for %s",
|
|
session_id,
|
|
exc_info=True,
|
|
)
|
|
durable = next_durable
|
|
await _write_hook_state_async(bridge_dir, durable)
|
|
continue
|
|
# Handle native task system events (TaskCreated, TaskCompleted,
|
|
# PostToolUse/TaskUpdate). Mutate the caller-owned maps in-place
|
|
# so task state accumulates across multiple polls within a session.
|
|
native_todos_changed = False
|
|
if record.event_name == "TaskCreated" and record.task_id is not None:
|
|
if record.task_id not in task_subjects:
|
|
task_order.append(record.task_id)
|
|
if record.task_subject is not None:
|
|
task_subjects[record.task_id] = record.task_subject
|
|
task_statuses[record.task_id] = "pending"
|
|
native_todos_changed = True
|
|
elif record.event_name == "TaskCompleted" and record.task_id is not None:
|
|
task_statuses[record.task_id] = "completed"
|
|
native_todos_changed = True
|
|
elif (
|
|
record.event_name == "PostToolUse"
|
|
and record.task_id is not None
|
|
and record.task_status is not None
|
|
):
|
|
# PostToolUse/TaskUpdate — update status only; subject
|
|
# already in map from the TaskCreated event.
|
|
task_statuses[record.task_id] = record.task_status
|
|
native_todos_changed = True
|
|
|
|
# Forward todo updates from PostToolUse/TodoWrite hook events.
|
|
# Best-effort: log and advance the cursor on failure so a
|
|
# single failed post doesn't stall hook processing.
|
|
todos_to_post: list[dict[str, Any]] | None = None
|
|
if record.todos is not None:
|
|
todos_to_post = record.todos
|
|
elif native_todos_changed and task_order:
|
|
todos_to_post = [
|
|
{
|
|
"content": task_subjects.get(tid, tid),
|
|
"status": task_statuses.get(tid, "pending"),
|
|
# activeForm is the gerund form used by Claude's TodoWrite tool.
|
|
# Native task hooks don't provide it, so we intentionally
|
|
# reuse the content string here. TodoPanel reads activeForm
|
|
# for in-progress items when it differs from content, so
|
|
# keeping them equal suppresses duplicate rendering.
|
|
"activeForm": task_subjects.get(tid, tid),
|
|
}
|
|
for tid in task_order
|
|
]
|
|
if todos_to_post is not None:
|
|
try:
|
|
await _post_external_session_todos(
|
|
client,
|
|
session_id=session_id,
|
|
todos=todos_to_post,
|
|
)
|
|
except httpx.HTTPError:
|
|
_logger.warning(
|
|
"Failed to forward Claude todos from hook; session=%s event_cursor=%s",
|
|
session_id,
|
|
record.event_cursor,
|
|
exc_info=True,
|
|
)
|
|
durable = next_durable
|
|
await _write_hook_state_async(bridge_dir, durable)
|
|
continue
|
|
retry_key = f"hook:{record.event_cursor}:{record.byte_offset}:{status}"
|
|
if retry_tracker.retry_delay_s(retry_key) is not None:
|
|
return durable
|
|
effective_status = status
|
|
if status == "idle" and record.background_task_count > 0:
|
|
effective_status = "waiting"
|
|
try:
|
|
await post_external_session_status(
|
|
client,
|
|
session_id=session_id,
|
|
status=effective_status,
|
|
response_id=response_id,
|
|
# Only the ``Stop`` (idle/waiting) edge carries an authoritative
|
|
# background-shell count — ``0`` clears the tally, ``N`` sets it.
|
|
# ``StopFailure`` (failed) clears it on the server regardless, so
|
|
# leave its count off the wire.
|
|
background_task_count=(
|
|
None if status == "failed" else record.background_task_count
|
|
),
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
decision = retry_tracker.record_failure(retry_key, exc)
|
|
if decision.exhausted:
|
|
_logger.error(
|
|
"Dropping Claude hook status after permanent HTTP failures; "
|
|
"session=%s bridge_dir=%s event_cursor=%s status=%s "
|
|
"attempts=%s http_status=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
record.event_cursor,
|
|
status,
|
|
decision.attempts,
|
|
_http_status_for_log(exc),
|
|
)
|
|
if status != "failed":
|
|
await _post_forwarder_failed_status(
|
|
client,
|
|
session_id=session_id,
|
|
bridge_dir=bridge_dir,
|
|
reason=f"hook status {status} rejected",
|
|
response_id=response_id,
|
|
)
|
|
durable = next_durable
|
|
await _write_hook_state_async(bridge_dir, durable)
|
|
continue
|
|
_logger.warning(
|
|
"Failed to forward Claude hook status; session=%s bridge_dir=%s "
|
|
"event_cursor=%s status=%s attempt=%s permanent=%s "
|
|
"next_retry_s=%.3f http_status=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
record.event_cursor,
|
|
status,
|
|
decision.attempts,
|
|
decision.permanent,
|
|
decision.delay_s,
|
|
_http_status_for_log(exc),
|
|
exc_info=True,
|
|
)
|
|
return durable
|
|
retry_tracker.clear(retry_key)
|
|
durable = next_durable
|
|
await _write_hook_state_async(bridge_dir, durable)
|
|
durable = HookForwardState(
|
|
event_cursor=result.event_cursor,
|
|
byte_offset=result.byte_offset,
|
|
cursor_fingerprint=_jsonl_cursor_fingerprint(bridge_dir / _HOOKS_FILE, result.byte_offset),
|
|
)
|
|
await _write_hook_state_async(bridge_dir, durable)
|
|
return durable
|
|
|
|
|
|
async def _ensure_state_for_transcript(
|
|
*,
|
|
bridge_dir: Path,
|
|
state: TranscriptForwardState | None,
|
|
transcript_path: Path,
|
|
start_at_end: bool,
|
|
session_id: str,
|
|
) -> TranscriptForwardState:
|
|
"""
|
|
Return a cursor state compatible with the observed transcript.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param state: Existing cursor state, or ``None``.
|
|
:param transcript_path: Current transcript path from hooks.
|
|
:param start_at_end: Whether a missing cursor should skip the
|
|
transcript's existing lines.
|
|
:param session_id: Omnigent session/conversation id, e.g.
|
|
``"conv_abc123"``. Used for stale-cursor diagnostics.
|
|
:returns: Cursor state for ``transcript_path``.
|
|
"""
|
|
if state is not None and state.transcript_path == transcript_path:
|
|
validated = _validated_transcript_state(
|
|
state,
|
|
bridge_dir=bridge_dir,
|
|
session_id=session_id,
|
|
)
|
|
if validated != state:
|
|
await _write_forward_state_async(bridge_dir, validated)
|
|
return validated
|
|
disk_state = _read_forward_state(bridge_dir)
|
|
if disk_state is not None and disk_state.transcript_path == transcript_path:
|
|
validated = _validated_transcript_state(
|
|
disk_state,
|
|
bridge_dir=bridge_dir,
|
|
session_id=session_id,
|
|
)
|
|
if validated != disk_state:
|
|
await _write_forward_state_async(bridge_dir, validated)
|
|
return validated
|
|
byte_offset = 0
|
|
if start_at_end:
|
|
byte_offset = await asyncio.to_thread(_transcript_end_offset, transcript_path)
|
|
state = TranscriptForwardState(
|
|
transcript_path=transcript_path,
|
|
line_cursor=0,
|
|
byte_offset=byte_offset,
|
|
cursor_fingerprint=_jsonl_cursor_fingerprint(transcript_path, byte_offset),
|
|
)
|
|
await _write_forward_state_async(bridge_dir, state)
|
|
return state
|
|
|
|
|
|
def _turn_has_assistant_output(items: list[ClaudeTranscriptItem], response_id: str) -> bool:
|
|
"""
|
|
Whether ``response_id`` has assistant-generated output among ``items``.
|
|
|
|
The turn-start ``running`` edge should open a streaming turn only for an id
|
|
that a later ``Stop``/``StopFailure`` hook will close — i.e. one produced by
|
|
an actual LLM turn. Assistant text (``message`` with ``role=assistant``) and
|
|
tool calls (``function_call``) qualify; a ``slash_command`` (``/model``,
|
|
``/effort``) or ``terminal_command`` (``!cmd``) item opens an id with no LLM
|
|
turn behind it, so it must not.
|
|
|
|
:param items: Transcript items read this poll.
|
|
:param response_id: The current turn's response id.
|
|
:returns: ``True`` when an assistant-output item carries ``response_id``.
|
|
"""
|
|
for item in items:
|
|
if item.response_id != response_id:
|
|
continue
|
|
if item.item_type == "function_call":
|
|
return True
|
|
if item.item_type == "message" and item.data.get("role") == "assistant":
|
|
return True
|
|
return False
|
|
|
|
|
|
async def _forward_available_items(
|
|
*,
|
|
client: httpx.AsyncClient,
|
|
session_id: str,
|
|
bridge_dir: Path,
|
|
agent_name: str,
|
|
state: TranscriptForwardState,
|
|
retry_tracker: _PostRetryTracker,
|
|
skip_user_messages: bool = False,
|
|
dedupe: _ForwardDedupeState,
|
|
ordering: _DeltaOrderingState | None = None,
|
|
) -> TranscriptForwardState:
|
|
"""
|
|
Forward currently available transcript items after ``state``.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id.
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param agent_name: Agent/model name to stamp on mirrored output.
|
|
:param state: Current transcript cursor state.
|
|
:param retry_tracker: In-memory retry/backoff tracker for
|
|
transcript item posts.
|
|
:param dedupe: Last usage / context-window / model values POSTed;
|
|
mutated in place to suppress duplicate ``external_*`` events.
|
|
:param ordering: Delta-ordering state shared with
|
|
:func:`_forward_available_deltas`. An assistant ``message`` item
|
|
whose deltas haven't fully forwarded is held (batch stops, cursor
|
|
unadvanced) until they have or a timeout expires — see
|
|
:func:`_hold_assistant_item_for_deltas`. ``None`` disables holding.
|
|
:returns: The updated transcript cursor state. On post failure it
|
|
is the last durable cursor so retries don't re-post successful
|
|
items.
|
|
"""
|
|
result = await asyncio.to_thread(_read_transcript_items_for_state, state, agent_name)
|
|
items = result.items
|
|
if not items:
|
|
if result.line_cursor == state.line_cursor and result.byte_offset == (
|
|
state.byte_offset or 0
|
|
):
|
|
return state
|
|
current_response_id = result.current_response_id
|
|
seen_source_ids = list(state.seen_source_ids)
|
|
seen = set(seen_source_ids)
|
|
# NOTE: the old "re-assert running on resumed agent output" hack lived
|
|
# here. It only existed to paper over the hook model's compaction
|
|
# blind spot (``Stop`` → idle, then an ``isCompactSummary`` resume that
|
|
# never fired ``UserPromptSubmit``). PTY-activity status makes it
|
|
# obsolete: the pane keeps changing through a mid-turn compaction, so
|
|
# the runner's watcher holds the session ``running`` directly.
|
|
#
|
|
# Turn-start edge: the first time we see a turn's response id, publish a
|
|
# ``running`` status carrying it. The PTY watcher already drives the
|
|
# running/idle BADGE with a bare (id-less) status; this id-bearing edge is
|
|
# what lets ap-web open a *streaming* ``activeResponse`` for the turn, so
|
|
# the forwarded tool-call cards (which carry the same response id) render
|
|
# LIVE — spinner + elapsed timer — instead of as static completed cards.
|
|
# Deduped on the persistent ``dedupe`` baseline (NOT ``state``): when an
|
|
# assistant item is held across polls for delta ordering, this function
|
|
# early-returns with ``state`` unadvanced, so a ``state``-based guard would
|
|
# re-fire ``running`` every poll of the hold window. Best-effort — a failed
|
|
# status post must not abort item forwarding (the items below are the
|
|
# primary payload); the turn-end idle/failed edge still carries the id to
|
|
# close the lifecycle, and the badge is unaffected either way.
|
|
#
|
|
# Only open the streaming turn for an id that has ASSISTANT output in this
|
|
# poll's items. A surfaced CLI built-in (``/model``, ``/effort``) or a
|
|
# ``!cmd`` becomes a slash_command / terminal_command item that opens its
|
|
# own response id but runs no LLM turn, so no ``Stop`` hook ever fires to
|
|
# close it — a ``running`` opened for it would strand the web composer in
|
|
# its "Stop"/busy state until the next real message. A skill that DOES
|
|
# trigger an LLM turn shares its id with the assistant text it produces, so
|
|
# ``running`` still fires — one poll later, when that output appears.
|
|
if (
|
|
current_response_id is not None
|
|
and dedupe.posted_running_response_id != current_response_id
|
|
and _turn_has_assistant_output(items, current_response_id)
|
|
):
|
|
try:
|
|
await post_external_session_status(
|
|
client,
|
|
session_id=session_id,
|
|
status="running",
|
|
response_id=current_response_id,
|
|
)
|
|
dedupe.posted_running_response_id = current_response_id
|
|
except httpx.HTTPError:
|
|
_logger.warning(
|
|
"Failed to forward Claude turn-start running status; session=%s response_id=%s",
|
|
session_id,
|
|
current_response_id,
|
|
exc_info=True,
|
|
)
|
|
updated = state
|
|
for item in items:
|
|
if item.source_id in seen:
|
|
continue
|
|
if skip_user_messages and item.item_type == "message" and item.data.get("role") == "user":
|
|
seen_source_ids.append(item.source_id)
|
|
seen.add(item.source_id)
|
|
continue
|
|
# Deltas-before-done: defer an assistant message whose deltas
|
|
# haven't forwarded yet. Stop the batch here (cursor before this
|
|
# item) so later items can't overtake it.
|
|
if _hold_assistant_item_for_deltas(item, ordering, bridge_dir):
|
|
return updated
|
|
retry_key = f"item:{item.source_id}"
|
|
if retry_tracker.retry_delay_s(retry_key) is not None:
|
|
return updated
|
|
try:
|
|
await _post_external_conversation_item(
|
|
client,
|
|
session_id=session_id,
|
|
item=item,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
decision = retry_tracker.record_failure(retry_key, exc)
|
|
if decision.exhausted:
|
|
_logger.error(
|
|
"Dropping Claude transcript item after permanent HTTP failures; "
|
|
"session=%s bridge_dir=%s source_id=%s item_type=%s "
|
|
"attempts=%s http_status=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
item.source_id,
|
|
item.item_type,
|
|
decision.attempts,
|
|
_http_status_for_log(exc),
|
|
)
|
|
# Dead-letter the dropped item for recovery (#1120; replay #1579).
|
|
append_dead_letter(
|
|
bridge_dir,
|
|
session_id=session_id,
|
|
event_type="external_conversation_item",
|
|
payload={
|
|
"item_type": item.item_type,
|
|
"item_data": item.data,
|
|
"response_id": item.response_id,
|
|
},
|
|
reason="permanent HTTP failure after retries",
|
|
# Claude only dead-letters permanent 4xx (it retries
|
|
# transient failures forever), so the server proved it
|
|
# rejected the item: never ambiguous, never replayable (#1579).
|
|
delivered_ambiguous=False,
|
|
http_status=_http_status_for_log(exc),
|
|
)
|
|
await _post_forwarder_failed_status(
|
|
client,
|
|
session_id=session_id,
|
|
bridge_dir=bridge_dir,
|
|
reason=f"transcript item {item.source_id} rejected",
|
|
response_id=current_response_id,
|
|
)
|
|
seen.add(item.source_id)
|
|
seen_source_ids.append(item.source_id)
|
|
updated = TranscriptForwardState(
|
|
transcript_path=state.transcript_path,
|
|
line_cursor=state.line_cursor,
|
|
byte_offset=state.byte_offset,
|
|
current_response_id=current_response_id,
|
|
seen_source_ids=_bounded_seen_source_ids(seen_source_ids),
|
|
cursor_fingerprint=state.cursor_fingerprint,
|
|
)
|
|
await _write_forward_state_async(bridge_dir, updated)
|
|
continue
|
|
if post_may_have_been_delivered(exc):
|
|
# Ambiguous failure: the server may have committed this
|
|
# item before the response was lost. External items aren't
|
|
# deduped, so a retry would duplicate the bubble —
|
|
# skip it. At worst one item is lost on a flaky POST.
|
|
_logger.warning(
|
|
"Skipping Claude transcript item after an ambiguous POST failure "
|
|
"(may already be committed); not retrying to avoid a duplicate; "
|
|
"session=%s bridge_dir=%s source_id=%s item_type=%s http_status=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
item.source_id,
|
|
item.item_type,
|
|
_http_status_for_log(exc),
|
|
exc_info=True,
|
|
)
|
|
retry_tracker.clear(retry_key)
|
|
seen.add(item.source_id)
|
|
seen_source_ids.append(item.source_id)
|
|
updated = TranscriptForwardState(
|
|
transcript_path=state.transcript_path,
|
|
line_cursor=state.line_cursor,
|
|
byte_offset=state.byte_offset,
|
|
current_response_id=current_response_id,
|
|
seen_source_ids=_bounded_seen_source_ids(seen_source_ids),
|
|
cursor_fingerprint=state.cursor_fingerprint,
|
|
)
|
|
await _write_forward_state_async(bridge_dir, updated)
|
|
continue
|
|
_logger.warning(
|
|
"Failed to forward Claude transcript item; session=%s bridge_dir=%s "
|
|
"source_id=%s item_type=%s attempt=%s permanent=%s "
|
|
"next_retry_s=%.3f http_status=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
item.source_id,
|
|
item.item_type,
|
|
decision.attempts,
|
|
decision.permanent,
|
|
decision.delay_s,
|
|
_http_status_for_log(exc),
|
|
exc_info=True,
|
|
)
|
|
return updated
|
|
retry_tracker.clear(retry_key)
|
|
await _maybe_sync_effort_from_slash_command(client, session_id=session_id, item=item)
|
|
seen.add(item.source_id)
|
|
seen_source_ids.append(item.source_id)
|
|
updated = TranscriptForwardState(
|
|
transcript_path=state.transcript_path,
|
|
line_cursor=state.line_cursor,
|
|
byte_offset=state.byte_offset,
|
|
current_response_id=current_response_id,
|
|
seen_source_ids=_bounded_seen_source_ids(seen_source_ids),
|
|
cursor_fingerprint=state.cursor_fingerprint,
|
|
)
|
|
await _write_forward_state_async(bridge_dir, updated)
|
|
updated = TranscriptForwardState(
|
|
transcript_path=state.transcript_path,
|
|
line_cursor=result.line_cursor,
|
|
byte_offset=result.byte_offset,
|
|
current_response_id=current_response_id,
|
|
seen_source_ids=_bounded_seen_source_ids(seen_source_ids),
|
|
cursor_fingerprint=_jsonl_cursor_fingerprint(state.transcript_path, result.byte_offset),
|
|
)
|
|
await _write_forward_state_async(bridge_dir, updated)
|
|
# POST usage AFTER items so the ring never leads the transcript.
|
|
# Best-effort: a failed post is retried on the next poll.
|
|
#
|
|
# Authoritative source for both numerator and denominator is the
|
|
# statusLine stdin captured by ``omnigent.claude_native_status``
|
|
# — Claude Code knows the real context window for the active
|
|
# model + beta tier. The JSONL ``message.usage`` is used as a
|
|
# numerator fallback only when the statusLine hasn't fired yet
|
|
# (e.g. cold-resume before the first render tick).
|
|
status_state = await asyncio.to_thread(read_claude_context_state, bridge_dir)
|
|
resolved_context_window = (
|
|
status_state.get("context_window_size") if status_state is not None else None
|
|
)
|
|
usage_from_status = (
|
|
_usage_from_status_state(status_state) if status_state is not None else None
|
|
)
|
|
posted_usage = usage_from_status if usage_from_status is not None else result.latest_usage
|
|
# Cost (``cumulative_cost_usd``) is POSTed separately by
|
|
# ``_forward_session_cost``, which reconciles the statusLine total with the
|
|
# forwarder's real-time sub-agent transcript estimate via max(). Strip it
|
|
# here so this token/context-window post and the cost post don't both SET
|
|
# ``total_cost_usd`` with different values and flap it on alternating polls.
|
|
if posted_usage is not None and "cumulative_cost_usd" in posted_usage:
|
|
posted_usage = {
|
|
key: value for key, value in posted_usage.items() if key != "cumulative_cost_usd"
|
|
}
|
|
usage_changed = posted_usage is not None and posted_usage != dedupe.usage
|
|
window_changed = (
|
|
resolved_context_window is not None and resolved_context_window != dedupe.context_window
|
|
)
|
|
if usage_changed or window_changed:
|
|
try:
|
|
await _post_external_session_usage(
|
|
client,
|
|
session_id=session_id,
|
|
usage=posted_usage,
|
|
context_window=resolved_context_window,
|
|
)
|
|
if usage_changed:
|
|
dedupe.usage = posted_usage
|
|
if window_changed:
|
|
dedupe.context_window = resolved_context_window
|
|
except httpx.HTTPError as exc:
|
|
_logger.warning(
|
|
"Failed to forward Claude transcript usage; session=%s bridge_dir=%s "
|
|
"http_status=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
_http_status_for_log(exc),
|
|
exc_info=True,
|
|
)
|
|
# Mirror a TUI-side `/model` switch to the web picker. The transcript
|
|
# records the resolved concrete id (e.g. "claude-opus-4-8"); collapse
|
|
# it to the picker's tier alias. This transcript-derived observation
|
|
# only fires when a turn produces a fresh ``message.model``, so it lags
|
|
# an in-pane switch by one turn — the per-poll statusLine sync
|
|
# (:func:`_forward_model_from_status`) is the primary, low-latency
|
|
# source; this stays as a fallback for cold-resume before the first
|
|
# statusLine render. Both share ``dedupe`` so neither double-posts.
|
|
await _post_model_change_if_new(
|
|
client,
|
|
session_id=session_id,
|
|
dedupe=dedupe,
|
|
alias=_model_alias_for(result.latest_model),
|
|
)
|
|
return updated
|
|
|
|
|
|
def _read_hook_events_for_state(
|
|
bridge_dir: Path,
|
|
state: HookForwardState,
|
|
) -> HookReadResult:
|
|
"""
|
|
Read hook events using the best cursor available in ``state``.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param state: Current hook forwarder state.
|
|
:returns: Hook records and updated cursors. States without a
|
|
byte offset are migrated by one line-cursor compatibility scan.
|
|
"""
|
|
if state.byte_offset is None:
|
|
return read_hook_events_since_with_position(bridge_dir, state.event_cursor)
|
|
return read_hook_events_from_offset(
|
|
bridge_dir,
|
|
state.byte_offset,
|
|
start_event_count=state.event_cursor,
|
|
)
|
|
|
|
|
|
def _validated_hook_state(
|
|
bridge_dir: Path,
|
|
state: HookForwardState,
|
|
*,
|
|
session_id: str,
|
|
) -> HookForwardState:
|
|
"""
|
|
Reset a hook cursor if its byte-offset fingerprint is stale.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param state: Hook cursor loaded from memory or disk.
|
|
:param session_id: Omnigent session/conversation id, e.g.
|
|
``"conv_abc123"``. Used for diagnostics.
|
|
:returns: ``state`` when its byte cursor still matches the file,
|
|
otherwise a fresh cursor at the beginning of ``hooks.jsonl``.
|
|
"""
|
|
if state.byte_offset is None:
|
|
return state
|
|
hooks_path = bridge_dir / _HOOKS_FILE
|
|
current_fingerprint = _jsonl_cursor_fingerprint(hooks_path, state.byte_offset)
|
|
if current_fingerprint is None:
|
|
_logger.warning(
|
|
"Claude hook JSONL cursor invalid; resetting cursor; "
|
|
"session=%s bridge_dir=%s byte_offset=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
state.byte_offset,
|
|
)
|
|
elif state.cursor_fingerprint is None:
|
|
_logger.warning(
|
|
"Claude hook JSONL cursor missing fingerprint; resetting cursor; "
|
|
"session=%s bridge_dir=%s byte_offset=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
state.byte_offset,
|
|
)
|
|
elif current_fingerprint == state.cursor_fingerprint:
|
|
return state
|
|
else:
|
|
_logger.warning(
|
|
"Claude hook JSONL cursor fingerprint changed; resetting cursor; "
|
|
"session=%s bridge_dir=%s byte_offset=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
state.byte_offset,
|
|
)
|
|
return HookForwardState(
|
|
event_cursor=0,
|
|
byte_offset=0,
|
|
cursor_fingerprint=_jsonl_cursor_fingerprint(hooks_path, 0),
|
|
)
|
|
|
|
|
|
def _read_transcript_items_for_state(
|
|
state: TranscriptForwardState,
|
|
agent_name: str,
|
|
) -> TranscriptReadResult:
|
|
"""
|
|
Read transcript items using the best cursor available in ``state``.
|
|
|
|
:param state: Current transcript forwarder state.
|
|
:param agent_name: Agent/model name to stamp on mirrored output.
|
|
:returns: Transcript items and updated cursors. States without a
|
|
byte offset are migrated by one line-cursor compatibility scan.
|
|
"""
|
|
if state.byte_offset is None:
|
|
return read_transcript_items_since_with_position(
|
|
state.transcript_path,
|
|
state.line_cursor,
|
|
agent_name=agent_name,
|
|
current_response_id=state.current_response_id,
|
|
)
|
|
return read_transcript_items_from_offset(
|
|
state.transcript_path,
|
|
state.byte_offset,
|
|
start_line=state.line_cursor,
|
|
agent_name=agent_name,
|
|
current_response_id=state.current_response_id,
|
|
)
|
|
|
|
|
|
def _validated_transcript_state(
|
|
state: TranscriptForwardState,
|
|
*,
|
|
bridge_dir: Path,
|
|
session_id: str,
|
|
) -> TranscriptForwardState:
|
|
"""
|
|
Reset a transcript cursor if its byte-offset fingerprint is stale.
|
|
|
|
:param state: Transcript cursor loaded from memory or disk.
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param session_id: Omnigent session/conversation id, e.g.
|
|
``"conv_abc123"``. Used for diagnostics.
|
|
:returns: ``state`` unchanged when its byte cursor still matches
|
|
the file; ``state`` with an adopted fingerprint (no reset)
|
|
when the cursor is at byte 0 / line 0 and the file just
|
|
appeared; otherwise a cursor skipped to end-of-file with
|
|
``seen_source_ids`` preserved so already-forwarded items
|
|
are not re-posted.
|
|
"""
|
|
if state.byte_offset is None:
|
|
return state
|
|
current_fingerprint = _jsonl_cursor_fingerprint(state.transcript_path, state.byte_offset)
|
|
if current_fingerprint is None:
|
|
if not state.transcript_path.exists():
|
|
return state
|
|
_logger.warning(
|
|
"Claude transcript cursor invalid; skipping to end of transcript; "
|
|
"session=%s bridge_dir=%s transcript=%s byte_offset=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
state.transcript_path,
|
|
state.byte_offset,
|
|
)
|
|
elif state.cursor_fingerprint is None:
|
|
if state.byte_offset == 0 and state.line_cursor == 0:
|
|
# State was written before the transcript file existed (fingerprint
|
|
# was None because the file was missing). The file now exists and
|
|
# the cursor is still at the start — adopt the computed fingerprint
|
|
# without resetting seen_source_ids.
|
|
return TranscriptForwardState(
|
|
transcript_path=state.transcript_path,
|
|
line_cursor=state.line_cursor,
|
|
byte_offset=state.byte_offset,
|
|
current_response_id=state.current_response_id,
|
|
seen_source_ids=state.seen_source_ids,
|
|
cursor_fingerprint=current_fingerprint,
|
|
)
|
|
_logger.warning(
|
|
"Claude transcript cursor missing fingerprint; skipping to end of transcript; "
|
|
"session=%s bridge_dir=%s transcript=%s byte_offset=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
state.transcript_path,
|
|
state.byte_offset,
|
|
)
|
|
elif current_fingerprint == state.cursor_fingerprint:
|
|
return state
|
|
else:
|
|
_logger.warning(
|
|
"Claude transcript cursor fingerprint changed; skipping to end of transcript; "
|
|
"session=%s bridge_dir=%s transcript=%s byte_offset=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
state.transcript_path,
|
|
state.byte_offset,
|
|
)
|
|
end_offset = _transcript_end_offset(state.transcript_path)
|
|
return TranscriptForwardState(
|
|
transcript_path=state.transcript_path,
|
|
line_cursor=0,
|
|
byte_offset=end_offset,
|
|
cursor_fingerprint=_jsonl_cursor_fingerprint(state.transcript_path, end_offset),
|
|
seen_source_ids=state.seen_source_ids,
|
|
)
|
|
|
|
|
|
async def _post_clear_supersession(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
old_session_id: str,
|
|
new_session_id: str,
|
|
agent_name: str,
|
|
) -> None:
|
|
"""
|
|
Notify the superseded session that a ``/clear`` rotated it away.
|
|
|
|
Posts three best-effort events to the OLD conversation, in order:
|
|
|
|
1. An ``external_session_status: idle`` so the old conversation's
|
|
"Working…" spinner stops — its terminal moved to the new session,
|
|
so it will never receive the turn-end edge that would normally
|
|
clear it.
|
|
2. A persisted assistant ``message`` item linking to the new
|
|
conversation, so a later reload of the cleared conversation
|
|
explains what happened and offers the continuation link. This is
|
|
the durable record — it survives reconnects.
|
|
3. A transient ``external_session_superseded`` event the server
|
|
republishes as ``session.superseded``, so a client *actively*
|
|
viewing the old conversation auto-redirects to the new one.
|
|
|
|
Each failure is logged and swallowed: the rotation has already
|
|
completed and reset forwarder state, and a notification error must
|
|
not disrupt the poll loop or stop the new session from forwarding.
|
|
|
|
:param client: Omnigent HTTP client (``base_url`` = AP server).
|
|
:param old_session_id: Superseded conversation id, e.g. ``"conv_old"``.
|
|
:param new_session_id: Rotated-to conversation id, e.g. ``"conv_new"``.
|
|
:param agent_name: Agent name to stamp on the notice message — an
|
|
assistant ``message`` item requires one.
|
|
:returns: None.
|
|
"""
|
|
if old_session_id == new_session_id:
|
|
# Defensive: never address the notice/redirect at the live session.
|
|
# The caller resolves the old id from the pre-rotation forwarder
|
|
# state, but if that ever collapses to the new id, posting here
|
|
# would dump the "you were cleared" banner onto the active chat.
|
|
return
|
|
try:
|
|
status_resp = await client.post(
|
|
f"/v1/sessions/{url_component(old_session_id)}/events",
|
|
json={
|
|
"type": "external_session_status",
|
|
"data": {"status": "idle"},
|
|
},
|
|
)
|
|
status_resp.raise_for_status()
|
|
except httpx.HTTPError:
|
|
_logger.warning(
|
|
"Failed to post /clear supersession idle status; old_session=%s new_session=%s",
|
|
old_session_id,
|
|
new_session_id,
|
|
exc_info=True,
|
|
)
|
|
notice = (
|
|
"This conversation was ended by `/clear`. "
|
|
f"Continue in [the new chat](/c/{new_session_id}). "
|
|
"You can also send a message here to resume this conversation."
|
|
)
|
|
try:
|
|
item_resp = await client.post(
|
|
f"/v1/sessions/{url_component(old_session_id)}/events",
|
|
json={
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": "message",
|
|
"item_data": {
|
|
"role": "assistant",
|
|
"agent": agent_name,
|
|
"content": [{"type": "output_text", "text": notice}],
|
|
},
|
|
},
|
|
},
|
|
)
|
|
item_resp.raise_for_status()
|
|
except httpx.HTTPError:
|
|
_logger.warning(
|
|
"Failed to post /clear supersession notice; old_session=%s new_session=%s",
|
|
old_session_id,
|
|
new_session_id,
|
|
exc_info=True,
|
|
)
|
|
try:
|
|
event_resp = await client.post(
|
|
f"/v1/sessions/{url_component(old_session_id)}/events",
|
|
json={
|
|
"type": "external_session_superseded",
|
|
"data": {"target_conversation_id": new_session_id},
|
|
},
|
|
)
|
|
event_resp.raise_for_status()
|
|
except httpx.HTTPError:
|
|
_logger.warning(
|
|
"Failed to post /clear supersession redirect event; old_session=%s new_session=%s",
|
|
old_session_id,
|
|
new_session_id,
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
async def _post_external_conversation_item(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
session_id: str,
|
|
item: ClaudeTranscriptItem,
|
|
) -> None:
|
|
"""
|
|
Post one mirrored transcript item to the Sessions API.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id.
|
|
:param item: Transcript-derived conversation item.
|
|
:returns: None.
|
|
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
|
"""
|
|
from omnigent.runtime import telemetry
|
|
|
|
# The forwarder is the decoupled response path (it tails Claude's
|
|
# transcript and re-POSTs items under its own trace, not the request's).
|
|
# session_scope binds the session generically (the processor stamps
|
|
# session.id on this span and any other span in the forward); response_id
|
|
# is per-item, so it's set explicitly.
|
|
with (
|
|
telemetry.session_scope(session_id),
|
|
telemetry.span(
|
|
"claude_native.forward",
|
|
attributes={"omnigent.response_id": item.response_id},
|
|
),
|
|
):
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": item.item_type,
|
|
"item_data": item.data,
|
|
"response_id": item.response_id,
|
|
},
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
|
|
async def _post_external_output_text_delta(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
session_id: str,
|
|
delta: ClaudeMessageDelta,
|
|
) -> None:
|
|
"""
|
|
Post one streamed assistant-text chunk to the Sessions API.
|
|
|
|
Published as a transient ``response.output_text.delta`` SSE event
|
|
(no persistence). ``message_id``/``index``/``final`` let the web UI
|
|
scope an in-flight buffer per message, order chunks, and know when
|
|
the live stream for a message ends; the authoritative final text
|
|
still arrives separately via ``external_conversation_item``.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id.
|
|
:param delta: Parsed streamed chunk.
|
|
:returns: None.
|
|
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
|
"""
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={
|
|
"type": "external_output_text_delta",
|
|
"data": {
|
|
"delta": delta.delta,
|
|
"message_id": delta.message_id,
|
|
"index": delta.index,
|
|
"final": delta.final,
|
|
},
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
|
|
async def _forward_available_deltas(
|
|
*,
|
|
client: httpx.AsyncClient,
|
|
session_id: str,
|
|
bridge_dir: Path,
|
|
state: DeltaForwardState,
|
|
seen_keys: dict[tuple[str, int], None],
|
|
ordering: _DeltaOrderingState | None = None,
|
|
) -> DeltaForwardState:
|
|
"""
|
|
Forward newly appended assistant-text deltas to the active session.
|
|
|
|
Reads complete records appended to ``message_deltas.jsonl`` after
|
|
the current byte offset and publishes each as a transient
|
|
``external_output_text_delta``. Deltas are best-effort live preview:
|
|
a per-chunk POST failure is logged and dropped (the authoritative
|
|
final message still arrives via ``external_conversation_item``)
|
|
rather than retried, so a transient blip can never wedge the tail.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id deltas are forwarded
|
|
to — the currently active session, so chunks streamed after a
|
|
``/clear`` land on the rotated session.
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param state: Current delta cursor state.
|
|
:param seen_keys: In-memory ``(message_id, index)`` dedupe ring,
|
|
mutated in place. Guards the rare file-truncation rewind where
|
|
the reader restarts from offset ``0``.
|
|
:param ordering: Delta-ordering state, mutated in place: each
|
|
forwarded chunk's text accumulates under its ``message_id`` for
|
|
:func:`_hold_assistant_item_for_deltas` to byte-match. Accumulated
|
|
on read, not POST success — a dropped chunk should let the item
|
|
post, not wait on text that never completes. ``None`` disables it.
|
|
:returns: The updated delta cursor state (offset advanced past the
|
|
records just read).
|
|
"""
|
|
# The deltas file only exists once the MessageDisplay hook has fired
|
|
# for this Claude process. Skip the worker-thread read until then so
|
|
# idle / non-streaming polls don't churn the thread pool (this loop
|
|
# polls every ~0.25s). A bare ``exists()`` is a cheap stat consistent
|
|
# with the other sync reads this loop already does each poll.
|
|
if not (bridge_dir / MESSAGE_DELTAS_FILE).exists():
|
|
return state
|
|
result = await asyncio.to_thread(
|
|
read_message_deltas_from_offset, bridge_dir, state.byte_offset
|
|
)
|
|
if result.byte_offset == state.byte_offset and not result.deltas:
|
|
return state
|
|
for delta in result.deltas:
|
|
key = (delta.message_id, delta.index)
|
|
if key in seen_keys:
|
|
continue
|
|
seen_keys[key] = None
|
|
# Bound the dedupe ring by evicting the oldest key (dicts are
|
|
# insertion-ordered) so a very long session can't grow it without
|
|
# limit.
|
|
while len(seen_keys) > _MAX_SEEN_DELTA_KEYS:
|
|
del seen_keys[next(iter(seen_keys))]
|
|
if ordering is not None:
|
|
entry = ordering.texts.setdefault(delta.message_id, _ForwardedDeltaText())
|
|
entry.parts.append(delta.delta)
|
|
if delta.final:
|
|
entry.final = True
|
|
while len(ordering.texts) > _MAX_DELTA_ORDERING_ENTRIES:
|
|
del ordering.texts[next(iter(ordering.texts))]
|
|
try:
|
|
await _post_external_output_text_delta(client, session_id=session_id, delta=delta)
|
|
except httpx.HTTPError as exc:
|
|
_logger.debug(
|
|
"Dropping Claude streamed delta after HTTP failure; session=%s "
|
|
"bridge_dir=%s message_id=%s index=%s http_status=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
delta.message_id,
|
|
delta.index,
|
|
_http_status_for_log(exc),
|
|
)
|
|
updated = DeltaForwardState(byte_offset=result.byte_offset)
|
|
await _write_delta_forward_state_async(bridge_dir, updated)
|
|
return updated
|
|
|
|
|
|
async def _post_external_session_usage(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
session_id: str,
|
|
usage: Mapping[str, float | str] | None,
|
|
context_window: int | None = None,
|
|
) -> None:
|
|
"""
|
|
Post one ``external_session_usage`` event to the Sessions API.
|
|
|
|
At least one of ``usage`` / ``context_window`` must be set; a
|
|
payload with neither is a no-op (the server would 400 it).
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id.
|
|
:param usage: ``message.usage`` snapshot, or ``None`` to skip. Values are
|
|
numeric counters/costs, plus an optional ``model`` string tagging the
|
|
cost with the active model for per-model attribution.
|
|
:param context_window: Resolved window in tokens, or ``None`` to
|
|
leave the server's persisted value untouched.
|
|
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
|
"""
|
|
payload: dict[str, Any] = {}
|
|
if usage is not None:
|
|
payload.update(usage)
|
|
if context_window is not None:
|
|
payload["context_window"] = context_window
|
|
if not payload:
|
|
return
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={"type": "external_session_usage", "data": payload},
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
|
|
def _model_alias_for(model: str | None) -> str | None:
|
|
"""
|
|
Collapse a concrete Claude model id to the picker's tier alias.
|
|
|
|
The web model picker speaks Claude Code's version-agnostic aliases
|
|
(``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"haiku"``), plus the one
|
|
extra concrete-id slot ``"sonnet_5"`` (see
|
|
:data:`omnigent.claude_native._UCODE_CLAUDE_CUSTOM_TIER`) for the newer
|
|
Sonnet generation offered alongside the default ``"sonnet"`` tier; the
|
|
transcript records the resolved concrete id (e.g.
|
|
``"claude-opus-4-8"`` or ``"databricks-claude-sonnet-5"``).
|
|
Mapping to the tier keeps the mirrored value in the picker's
|
|
vocabulary and makes a web→TUI round-trip a no-op. The older Sonnet
|
|
(``sonnet-4-6``) collapses to the generic ``"sonnet"`` alias — it is the
|
|
default that row is bound to.
|
|
|
|
:param model: Concrete model id from the transcript, e.g.
|
|
``"claude-opus-4-8"``; ``None`` when none observed yet.
|
|
:returns: ``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"sonnet_5"`` /
|
|
``"haiku"`` when the id carries a known tier token, else ``None``
|
|
(the caller skips the post rather than surface an id the picker
|
|
can't render).
|
|
"""
|
|
if not model:
|
|
return None
|
|
lowered = model.lower()
|
|
if "sonnet-5" in lowered or "sonnet_5" in lowered:
|
|
return "sonnet_5"
|
|
for tier in ("fable", "opus", "sonnet", "haiku"):
|
|
if tier in lowered:
|
|
return tier
|
|
return None
|
|
|
|
|
|
async def _post_external_model_change(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
session_id: str,
|
|
model: str,
|
|
) -> None:
|
|
"""
|
|
Post one ``external_model_change`` event to the Sessions API.
|
|
|
|
Lets the web model picker reflect a model switch made inside the
|
|
Claude Code terminal (a ``/model`` command or the in-TUI picker).
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id, e.g.
|
|
``"conv_abc123"``.
|
|
:param model: Tier alias the session is now on, e.g. ``"opus"``.
|
|
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
|
"""
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={"type": "external_model_change", "data": {"model": model}},
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
|
|
async def _post_model_change_if_new(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
session_id: str,
|
|
dedupe: _ForwardDedupeState,
|
|
alias: str | None,
|
|
) -> None:
|
|
"""
|
|
Mirror an observed model tier alias to ``model_override``, deduped.
|
|
|
|
Shared by the transcript-driven path (:func:`_forward_available_items`)
|
|
and the statusLine-driven per-poll path
|
|
(:func:`_forward_model_from_status`). The FIRST observation is the
|
|
session's spawn default, not a switch, so it seeds the dedupe baseline
|
|
WITHOUT posting (posting it could clobber a pending silent model
|
|
handoff). Every later change posts ``external_model_change``. Both
|
|
callers pass the same ``dedupe`` so whichever observes a switch first
|
|
posts it and the other no-ops. Best-effort: a failed POST leaves
|
|
``posted_model`` behind ``observed_model`` so the next poll retries.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id.
|
|
:param dedupe: Shared per-session dedupe state; mutated in place.
|
|
:param alias: Tier alias just observed (``"opus"`` / ``"sonnet"`` /
|
|
…), or ``None`` when this source carried no recognizable model on
|
|
this poll. ``observed_model`` is sticky across polls, so passing
|
|
``None`` does NOT clear it — it just means "no fresh observation,"
|
|
and a previously-observed-but-unposted model is still reconciled
|
|
(retried) here.
|
|
"""
|
|
if alias is not None:
|
|
dedupe.observed_model = alias
|
|
if dedupe.observed_model is None or dedupe.observed_model == dedupe.posted_model:
|
|
return
|
|
if dedupe.posted_model is None:
|
|
# First observation = the spawn default; seed the baseline without
|
|
# posting so it can't clobber a pending silent model handoff.
|
|
dedupe.posted_model = dedupe.observed_model
|
|
return
|
|
try:
|
|
await _post_external_model_change(
|
|
client,
|
|
session_id=session_id,
|
|
model=dedupe.observed_model,
|
|
)
|
|
dedupe.posted_model = dedupe.observed_model
|
|
except httpx.HTTPError:
|
|
# Leave posted_model behind observed_model so the next poll retries.
|
|
_logger.warning(
|
|
"Failed to mirror model change to Omnigent session=%s; model pill / "
|
|
"cost-budget gate may lag until the next poll",
|
|
session_id,
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
async def _forward_model_from_status(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
session_id: str,
|
|
bridge_dir: Path,
|
|
dedupe: _ForwardDedupeState,
|
|
) -> None:
|
|
"""
|
|
Mirror the statusLine-reported active model to ``model_override`` each poll.
|
|
|
|
Claude Code rewrites the statusLine stdin on every TUI render — including
|
|
right after an in-pane ``/model`` switch, BEFORE the next turn runs. The
|
|
wrapper (:mod:`omnigent.claude_native_status`) persists that model into
|
|
``context.json``. Reading it here, every poll and independently of new
|
|
transcript items, is what lets a policy that gates on the active model
|
|
(e.g. the session cost-budget hard cap, which only blocks expensive
|
|
tiers) see the new model on the user's NEXT message — instead of one
|
|
turn later, which is what happened when the model was derived solely
|
|
from the next turn's transcript ``message.model``.
|
|
|
|
Best-effort and idempotent: shares ``dedupe`` with the transcript path,
|
|
so a no-op when the model is unchanged.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id.
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param dedupe: Shared per-session model dedupe state.
|
|
"""
|
|
status_state = await asyncio.to_thread(read_claude_context_state, bridge_dir)
|
|
if status_state is None:
|
|
return
|
|
model = status_state.get("model")
|
|
alias = _model_alias_for(model if isinstance(model, str) else None)
|
|
await _post_model_change_if_new(
|
|
client,
|
|
session_id=session_id,
|
|
dedupe=dedupe,
|
|
alias=alias,
|
|
)
|
|
|
|
|
|
async def _post_external_compaction_status(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
session_id: str,
|
|
status: str,
|
|
) -> None:
|
|
"""
|
|
Post one ``external_compaction_status`` event to the Sessions API.
|
|
|
|
Brackets Claude Code's own compaction so the web UI can show its
|
|
"Compacting conversation…" spinner while Claude runs the real
|
|
compaction in the terminal. ``"in_progress"`` is sent from the
|
|
``PreCompact`` hook and ``"completed"`` from the post-compaction
|
|
``SessionStart`` (``source == "compact"``) hook. The Omnigent server maps
|
|
these to the ``response.compaction.in_progress`` /
|
|
``response.compaction.completed`` SSE events the web client already
|
|
renders.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id.
|
|
:param status: Compaction status value, ``"in_progress"`` or
|
|
``"completed"``.
|
|
:returns: None.
|
|
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
|
"""
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={
|
|
"type": "external_compaction_status",
|
|
"data": {"status": status},
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
|
|
async def _persist_native_compaction_item(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
session_id: str,
|
|
bridge_dir: Path,
|
|
) -> None:
|
|
"""
|
|
Persist a compaction boundary item to the conversation store.
|
|
|
|
Called when the forwarder observes a compaction-completed signal
|
|
(``SessionStart source=compact``). Queries the latest conversation
|
|
item to use as ``last_item_id`` so session resume knows the
|
|
compaction boundary — items before this marker are summarized
|
|
and don't need to be loaded.
|
|
|
|
After writing the boundary, it also reads the post-compaction
|
|
transcript from Claude's own session state via
|
|
``get_session_messages`` and includes them as ``compacted_messages``
|
|
so session resume in ephemeral environments can reconstruct context
|
|
without the CLI's local transcript files.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id.
|
|
:param bridge_dir: Bridge directory path used to look up the
|
|
Claude-native session id.
|
|
"""
|
|
# Find the last persisted item to use as the compaction boundary.
|
|
resp = await client.get(
|
|
f"/v1/sessions/{session_id}/items",
|
|
params={"limit": 1, "order": "desc"},
|
|
)
|
|
resp.raise_for_status()
|
|
items = resp.json().get("data", [])
|
|
last_item_id = items[0]["id"] if items else f"compact_boundary_{session_id}"
|
|
|
|
# Read the post-compaction session messages so session resume can
|
|
# reconstruct context in ephemeral environments.
|
|
compacted_messages: list[dict[str, Any]] | None = None
|
|
try:
|
|
from claude_agent_sdk import get_session_messages
|
|
|
|
claude_sid = read_claude_session_id(bridge_dir)
|
|
if claude_sid:
|
|
msgs = get_session_messages(claude_sid)
|
|
compacted_messages = [
|
|
{"type": "message", "role": m.type, "content": m.message.get("content", [])}
|
|
for m in msgs
|
|
if isinstance(m.message, dict)
|
|
]
|
|
except Exception: # noqa: BLE001
|
|
_logger.debug(
|
|
"Failed to read Claude session messages for compaction persist",
|
|
exc_info=True,
|
|
)
|
|
|
|
event_data: dict[str, Any] = {
|
|
"summary": "[Claude Code compaction — context was compacted in the terminal]",
|
|
"last_item_id": last_item_id,
|
|
"model": "unknown",
|
|
"token_count": 0,
|
|
}
|
|
if compacted_messages is not None:
|
|
event_data["compacted_messages"] = compacted_messages
|
|
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={
|
|
"type": "compaction",
|
|
"data": event_data,
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
|
|
async def _patch_external_session_id(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
session_id: str,
|
|
external_session_id: str,
|
|
) -> None:
|
|
"""
|
|
PATCH the Omnigent conversation row with the Claude-native session id.
|
|
|
|
The server's ``set_external_session_id`` store call is idempotent
|
|
on same-value writes and rejects overwrite of an already-set
|
|
different value with ``400 invalid_input``. Wrapper bridges should
|
|
PATCH the value once when they first observe it from Claude.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id, e.g.
|
|
``"conv_abc123"``.
|
|
:param external_session_id: Runtime-native session id captured
|
|
from a Claude hook event,
|
|
e.g. ``"a1b2c3d4-1234-5678-9abc-def012345678"``.
|
|
:returns: None.
|
|
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
|
"""
|
|
resp = await client.patch(
|
|
f"/v1/sessions/{session_id}",
|
|
json={"external_session_id": external_session_id},
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
|
|
async def _maybe_sync_effort_from_slash_command(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
session_id: str,
|
|
item: ClaudeTranscriptItem,
|
|
) -> None:
|
|
"""
|
|
Mirror an in-pane ``/effort`` change onto the Omnigent session row.
|
|
|
|
The pane changes the binary but doesn't touch AP; PATCH
|
|
``reasoning_effort`` (``silent=True`` to avoid re-injecting ``/effort``
|
|
into the pane) so the pill tracks it. Best-effort — logged, not raised.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id, e.g. ``"conv_abc123"``.
|
|
:param item: A just-forwarded item; only a ``slash_command`` named
|
|
``"effort"`` triggers a PATCH.
|
|
:returns: None.
|
|
"""
|
|
if item.item_type != "slash_command" or item.data.get("name") != "effort":
|
|
return
|
|
arguments = item.data.get("arguments")
|
|
if not isinstance(arguments, str):
|
|
return
|
|
# Bare level (set) or clear alias changes state; bare /effort is a show no-op.
|
|
level = arguments.strip().lower()
|
|
if level not in CLAUDE_EFFORTS and level not in EFFORT_CLEAR_VALUES:
|
|
return
|
|
try:
|
|
resp = await client.patch(
|
|
f"/v1/sessions/{session_id}",
|
|
json={"reasoning_effort": level, "silent": True},
|
|
)
|
|
resp.raise_for_status()
|
|
except httpx.HTTPError:
|
|
_logger.warning(
|
|
"Failed to mirror in-pane /effort=%s to Omnigent session=%s; "
|
|
"effort pill may lag until the next change",
|
|
level,
|
|
session_id,
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
async def _post_forwarder_failed_status(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
session_id: str,
|
|
bridge_dir: Path,
|
|
reason: str,
|
|
response_id: str | None = None,
|
|
) -> None:
|
|
"""
|
|
Best-effort publish a failed status after dropping a poison event.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id.
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param reason: Diagnostic reason for the failure event, e.g.
|
|
``"transcript item item-1 rejected"``.
|
|
:param response_id: Active turn's response id, so this ``failed``
|
|
edge closes the streaming ``activeResponse`` for the matching
|
|
turn rather than leaving its tool cards spinning. ``None`` when
|
|
no turn id is known.
|
|
:returns: None.
|
|
"""
|
|
try:
|
|
await post_external_session_status(
|
|
client,
|
|
session_id=session_id,
|
|
status="failed",
|
|
output=reason,
|
|
response_id=response_id,
|
|
)
|
|
except httpx.HTTPError:
|
|
_logger.warning(
|
|
"Failed to publish Claude forwarder failure status; "
|
|
"session=%s bridge_dir=%s reason=%s",
|
|
session_id,
|
|
bridge_dir,
|
|
reason,
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
async def _post_external_session_todos(
|
|
client: httpx.AsyncClient,
|
|
*,
|
|
session_id: str,
|
|
todos: list[dict[str, Any]],
|
|
) -> None:
|
|
"""
|
|
Post one ``external_session_todos`` event to the Sessions API.
|
|
|
|
:param client: Omnigent HTTP client.
|
|
:param session_id: Omnigent session/conversation id, e.g. ``"conv_abc123"``.
|
|
:param todos: Current Claude todo list, e.g.
|
|
``[{"content": "Write tests", "status": "in_progress",
|
|
"activeForm": "Writing tests"}]``.
|
|
:returns: None.
|
|
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
|
"""
|
|
resp = await client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={"type": "external_session_todos", "data": {"todos": todos}},
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
|
|
def _is_permanent_http_error(exc: httpx.HTTPError) -> bool:
|
|
"""
|
|
Return whether ``exc`` is a permanent Omnigent rejection.
|
|
|
|
:param exc: HTTP exception raised while posting an Omnigent event.
|
|
:returns: ``True`` for non-transient 4xx status responses,
|
|
otherwise ``False``.
|
|
"""
|
|
if not isinstance(exc, httpx.HTTPStatusError):
|
|
return False
|
|
status_code = exc.response.status_code
|
|
return 400 <= status_code < 500 and status_code not in _HTTP_TRANSIENT_STATUS_CODES
|
|
|
|
|
|
def _http_status_for_log(exc: httpx.HTTPError) -> int | None:
|
|
"""
|
|
Extract an HTTP status code from ``exc`` when present.
|
|
|
|
:param exc: HTTP exception raised while posting an Omnigent event.
|
|
:returns: Numeric HTTP status code, or ``None`` for transport
|
|
failures that did not receive a response.
|
|
"""
|
|
if isinstance(exc, httpx.HTTPStatusError):
|
|
return exc.response.status_code
|
|
return None
|
|
|
|
|
|
def _read_hook_state(bridge_dir: Path) -> HookForwardState | None:
|
|
"""
|
|
Read the durable hook forwarder cursor from the bridge directory.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:returns: Cursor state, or ``None`` if no usable state exists.
|
|
"""
|
|
try:
|
|
raw = json.loads((bridge_dir / _HOOK_STATE_FILE).read_text(encoding="utf-8"))
|
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
return None
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
event_cursor = raw.get("event_cursor")
|
|
byte_offset = raw.get("byte_offset")
|
|
cursor_fingerprint = raw.get("cursor_fingerprint")
|
|
if not isinstance(event_cursor, int) or event_cursor < 0:
|
|
return None
|
|
if byte_offset is not None and (not isinstance(byte_offset, int) or byte_offset < 0):
|
|
return None
|
|
if cursor_fingerprint is not None and not isinstance(cursor_fingerprint, str):
|
|
return None
|
|
return HookForwardState(
|
|
event_cursor=event_cursor,
|
|
byte_offset=byte_offset,
|
|
cursor_fingerprint=cursor_fingerprint,
|
|
)
|
|
|
|
|
|
def _write_hook_state(bridge_dir: Path, state: HookForwardState) -> None:
|
|
"""
|
|
Write the durable hook forwarder cursor to the bridge directory.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param state: Cursor state to persist.
|
|
:returns: None.
|
|
"""
|
|
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
payload: dict[str, Any] = {
|
|
"event_cursor": state.event_cursor,
|
|
"updated_at": time.time(),
|
|
}
|
|
if state.byte_offset is not None:
|
|
payload["byte_offset"] = state.byte_offset
|
|
if state.cursor_fingerprint is not None:
|
|
payload["cursor_fingerprint"] = state.cursor_fingerprint
|
|
_write_json_atomic(bridge_dir / _HOOK_STATE_FILE, payload)
|
|
|
|
|
|
async def _write_hook_state_async(bridge_dir: Path, state: HookForwardState) -> None:
|
|
"""
|
|
Persist hook state without blocking the asyncio event loop.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param state: Cursor state to persist.
|
|
:returns: None.
|
|
"""
|
|
await asyncio.to_thread(_write_hook_state, bridge_dir, state)
|
|
|
|
|
|
def _usage_from_status_state(state: dict[str, Any]) -> dict[str, float] | None:
|
|
"""
|
|
Convert statusLine ``current_usage`` (+ cost) into the Omnigent usage shape.
|
|
|
|
Sums input + cache_creation + cache_read for ``context_tokens``
|
|
(matches claude-hud's ``getTotalTokens``: only input-side tokens
|
|
occupy the next prompt's budget). When the statusLine also captured
|
|
Claude Code's cumulative ``total_cost_usd``, it's surfaced as
|
|
``cumulative_cost_usd`` so the server can persist native session cost
|
|
(SET semantics). Returns ``None`` when the state has no usable
|
|
``current_usage`` so the caller falls back to the JSONL-derived value.
|
|
|
|
:param state: Parsed ``context.json`` payload.
|
|
:returns: Usage dict (token counts plus optional
|
|
``cumulative_cost_usd``), or ``None``.
|
|
"""
|
|
usage = state.get("current_usage")
|
|
if not isinstance(usage, dict):
|
|
return None
|
|
input_tokens = usage.get("input_tokens")
|
|
if not isinstance(input_tokens, int):
|
|
return None
|
|
cc = usage.get("cache_creation_input_tokens")
|
|
cr = usage.get("cache_read_input_tokens")
|
|
output_tokens = usage.get("output_tokens")
|
|
cc_i = cc if isinstance(cc, int) else 0
|
|
cr_i = cr if isinstance(cr, int) else 0
|
|
out_i = output_tokens if isinstance(output_tokens, int) else 0
|
|
# Token counts stay ``int`` (the server validates context_tokens with
|
|
# ``isinstance(int)``); only ``cumulative_cost_usd`` is a float. ``float``
|
|
# annotation is fine — ``int`` is a subtype under the numeric tower.
|
|
result: dict[str, float] = {
|
|
"context_tokens": input_tokens + cc_i + cr_i,
|
|
"input_tokens": input_tokens,
|
|
"output_tokens": out_i,
|
|
}
|
|
total_cost = state.get("total_cost_usd")
|
|
if (
|
|
isinstance(total_cost, (int, float))
|
|
and not isinstance(total_cost, bool)
|
|
and total_cost >= 0
|
|
):
|
|
result["cumulative_cost_usd"] = float(total_cost)
|
|
return result
|
|
|
|
|
|
def _bounded_seen_source_ids(seen_source_ids: list[str]) -> tuple[str, ...]:
|
|
"""
|
|
Return a bounded tuple of recently forwarded source ids.
|
|
|
|
:param seen_source_ids: Source ids accumulated in observation
|
|
order.
|
|
:returns: Tuple capped to the most recent source ids. The cap
|
|
prevents the state file from growing without bound while
|
|
retaining enough idempotency history for retries.
|
|
"""
|
|
return tuple(seen_source_ids[-_MAX_SEEN_SOURCE_IDS:])
|
|
|
|
|
|
def _read_forward_state(bridge_dir: Path) -> TranscriptForwardState | None:
|
|
"""
|
|
Read the durable forwarder cursor from the bridge directory.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:returns: Cursor state, or ``None`` if no usable state exists.
|
|
"""
|
|
try:
|
|
raw = json.loads((bridge_dir / _FORWARDER_STATE_FILE).read_text(encoding="utf-8"))
|
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
return None
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
transcript_path = raw.get("transcript_path")
|
|
line_cursor = raw.get("line_cursor")
|
|
byte_offset = raw.get("byte_offset")
|
|
current_response_id = raw.get("current_response_id")
|
|
cursor_fingerprint = raw.get("cursor_fingerprint")
|
|
seen_source_ids = raw.get("seen_source_ids", [])
|
|
if not isinstance(transcript_path, str) or not isinstance(line_cursor, int):
|
|
return None
|
|
if line_cursor < 0:
|
|
return None
|
|
if byte_offset is not None and (not isinstance(byte_offset, int) or byte_offset < 0):
|
|
return None
|
|
if current_response_id is not None and not isinstance(current_response_id, str):
|
|
return None
|
|
if cursor_fingerprint is not None and not isinstance(cursor_fingerprint, str):
|
|
return None
|
|
if not isinstance(seen_source_ids, list) or not all(
|
|
isinstance(source_id, str) for source_id in seen_source_ids
|
|
):
|
|
seen_source_ids = []
|
|
return TranscriptForwardState(
|
|
transcript_path=Path(transcript_path),
|
|
line_cursor=line_cursor,
|
|
byte_offset=byte_offset,
|
|
current_response_id=current_response_id,
|
|
seen_source_ids=tuple(seen_source_ids),
|
|
cursor_fingerprint=cursor_fingerprint,
|
|
)
|
|
|
|
|
|
def _write_forward_state(bridge_dir: Path, state: TranscriptForwardState) -> None:
|
|
"""
|
|
Write the durable forwarder cursor to the bridge directory.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param state: Cursor state to persist.
|
|
:returns: None.
|
|
"""
|
|
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
payload: dict[str, Any] = {
|
|
"transcript_path": str(state.transcript_path),
|
|
"line_cursor": state.line_cursor,
|
|
"current_response_id": state.current_response_id,
|
|
"seen_source_ids": list(state.seen_source_ids),
|
|
"updated_at": time.time(),
|
|
}
|
|
if state.byte_offset is not None:
|
|
payload["byte_offset"] = state.byte_offset
|
|
if state.cursor_fingerprint is not None:
|
|
payload["cursor_fingerprint"] = state.cursor_fingerprint
|
|
_write_json_atomic(bridge_dir / _FORWARDER_STATE_FILE, payload)
|
|
|
|
|
|
async def _write_forward_state_async(
|
|
bridge_dir: Path,
|
|
state: TranscriptForwardState,
|
|
) -> None:
|
|
"""
|
|
Persist transcript state without blocking the asyncio event loop.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param state: Cursor state to persist.
|
|
:returns: None.
|
|
"""
|
|
await asyncio.to_thread(_write_forward_state, bridge_dir, state)
|
|
|
|
|
|
def _read_delta_forward_state(bridge_dir: Path) -> DeltaForwardState:
|
|
"""
|
|
Read the durable delta-forwarder cursor from the bridge directory.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:returns: Persisted cursor, or a fresh ``byte_offset=0`` state when
|
|
none exists or it is unusable. Starting from ``0`` re-reads the
|
|
deltas file; the ``(message_id, index)`` dedupe ring and the
|
|
frontend's own provisional buffer absorb any re-sent chunks.
|
|
"""
|
|
try:
|
|
raw = json.loads((bridge_dir / _DELTA_STATE_FILE).read_text(encoding="utf-8"))
|
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
return DeltaForwardState()
|
|
if not isinstance(raw, dict):
|
|
return DeltaForwardState()
|
|
byte_offset = raw.get("byte_offset")
|
|
if not isinstance(byte_offset, int) or byte_offset < 0:
|
|
return DeltaForwardState()
|
|
return DeltaForwardState(byte_offset=byte_offset)
|
|
|
|
|
|
def _write_delta_forward_state(bridge_dir: Path, state: DeltaForwardState) -> None:
|
|
"""
|
|
Write the durable delta-forwarder cursor to the bridge directory.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param state: Cursor state to persist.
|
|
:returns: None.
|
|
"""
|
|
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
_write_json_atomic(
|
|
bridge_dir / _DELTA_STATE_FILE,
|
|
{"byte_offset": state.byte_offset, "updated_at": time.time()},
|
|
)
|
|
|
|
|
|
async def _write_delta_forward_state_async(
|
|
bridge_dir: Path,
|
|
state: DeltaForwardState,
|
|
) -> None:
|
|
"""
|
|
Persist delta state without blocking the asyncio event loop.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:param state: Cursor state to persist.
|
|
:returns: None.
|
|
"""
|
|
await asyncio.to_thread(_write_delta_forward_state, bridge_dir, state)
|
|
|
|
|
|
def _transcript_end_offset(transcript_path: Path) -> int:
|
|
"""
|
|
Return the byte offset after the last complete transcript record.
|
|
|
|
:param transcript_path: Claude transcript path.
|
|
:returns: Offset after the last newline-terminated record, or
|
|
``0`` when the transcript does not exist or has only a
|
|
partial first record.
|
|
"""
|
|
return _complete_jsonl_end_offset(transcript_path)
|
|
|
|
|
|
def _hook_end_offset(bridge_dir: Path) -> int:
|
|
"""
|
|
Return the byte offset after the last complete hook JSONL record.
|
|
|
|
:param bridge_dir: Native Claude bridge directory.
|
|
:returns: Offset after the last newline-terminated hook record, or
|
|
``0`` when no complete hook record exists yet.
|
|
"""
|
|
return _complete_jsonl_end_offset(bridge_dir / _HOOKS_FILE)
|
|
|
|
|
|
def _complete_jsonl_end_offset(path: Path) -> int:
|
|
"""
|
|
Return the offset after the last newline-terminated JSONL record.
|
|
|
|
:param path: JSONL file path.
|
|
:returns: File size when it ends in ``"\\n"``, otherwise the byte
|
|
offset immediately after the previous newline. Returns ``0``
|
|
for missing files or a single partial first record.
|
|
"""
|
|
try:
|
|
with path.open("rb") as handle:
|
|
handle.seek(0, os.SEEK_END)
|
|
size = handle.tell()
|
|
if size == 0:
|
|
return 0
|
|
handle.seek(size - 1)
|
|
if handle.read(1) == b"\n":
|
|
return size
|
|
block_end = size
|
|
while block_end > 0:
|
|
block_start = max(0, block_end - 65_536)
|
|
handle.seek(block_start)
|
|
data = handle.read(block_end - block_start)
|
|
newline_index = data.rfind(b"\n")
|
|
if newline_index >= 0:
|
|
return block_start + newline_index + 1
|
|
block_end = block_start
|
|
except FileNotFoundError:
|
|
return 0
|
|
return 0
|
|
|
|
|
|
def _jsonl_cursor_fingerprint(path: Path, byte_offset: int) -> str | None:
|
|
"""
|
|
Hash bytes immediately before a JSONL cursor for stale-cursor checks.
|
|
|
|
:param path: JSONL file path.
|
|
:param byte_offset: Cursor byte offset, e.g. ``4096``.
|
|
:returns: SHA-256 digest for the bytes before the cursor, or
|
|
``None`` when the file does not exist or the offset is invalid.
|
|
"""
|
|
if byte_offset < 0:
|
|
return None
|
|
try:
|
|
with path.open("rb") as handle:
|
|
handle.seek(0, os.SEEK_END)
|
|
size = handle.tell()
|
|
if byte_offset > size:
|
|
return None
|
|
sample_start = max(0, byte_offset - _CURSOR_FINGERPRINT_BYTES)
|
|
handle.seek(sample_start)
|
|
sample = handle.read(byte_offset - sample_start)
|
|
except FileNotFoundError:
|
|
return None
|
|
payload = byte_offset.to_bytes(8, "big", signed=False) + sample
|
|
return hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
|
|
"""
|
|
Write JSON to *path* via a same-directory temporary file.
|
|
|
|
:param path: Destination JSON file.
|
|
:param payload: JSON-serializable payload.
|
|
:returns: None.
|
|
"""
|
|
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
tmp_path: Path | None = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
"w",
|
|
encoding="utf-8",
|
|
dir=path.parent,
|
|
prefix=f".{path.name}.",
|
|
suffix=".tmp",
|
|
delete=False,
|
|
) as handle:
|
|
tmp_path = Path(handle.name)
|
|
handle.write(json.dumps(payload, separators=(",", ":")))
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.chmod(tmp_path, 0o600)
|
|
os.replace(tmp_path, path)
|
|
finally:
|
|
if tmp_path is not None:
|
|
with contextlib.suppress(FileNotFoundError):
|
|
tmp_path.unlink()
|