5023 lines
196 KiB
Python
5023 lines
196 KiB
Python
"""Bridge utilities for the native Claude Code wrapper.
|
||
|
||
The native wrapper has two live processes that need to rendezvous:
|
||
|
||
- Claude Code, running in the user's terminal resource.
|
||
- The Omnigent harness turn, running when the web UI submits a
|
||
message to the session agent.
|
||
|
||
This module owns the small filesystem rendezvous directory plus two
|
||
helper surfaces:
|
||
|
||
- An MCP stdio server (``serve-mcp`` subcommand) that Claude Code
|
||
launches as a child process. It advertises Omnigent tools to
|
||
Claude (workspace ``sys_os_*`` tools outside an active turn,
|
||
active-turn Omnigent tools via a per-turn relay).
|
||
- A tmux send-keys path. Web UI messages are delivered to Claude by
|
||
typing them into the same tmux pane the user is attached to;
|
||
Claude treats them as ordinary user input. The runner advertises
|
||
the pane's socket + target in ``tmux.json`` after launching the
|
||
``claude/main`` terminal.
|
||
|
||
Claude's experimental Channels MCP capability was the original input
|
||
path but is blocked at the org policy layer, so this bridge does not
|
||
use it.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import contextlib
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import queue
|
||
import re
|
||
import secrets
|
||
import shlex
|
||
import stat
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import urllib.parse
|
||
from collections.abc import Awaitable, Callable
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from http import HTTPStatus
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING, Any
|
||
from urllib import error, request
|
||
|
||
from omnigent._platform import stable_user_id
|
||
from omnigent.claude_native_message_display_hook import MESSAGE_DELTAS_FILE
|
||
from omnigent.kiro_native_bridge import bridge_root as kiro_bridge_root
|
||
|
||
if TYPE_CHECKING:
|
||
from omnigent.llms.context_window import ModelPricing
|
||
|
||
from omnigent.inner.bundle_skills import claude_native_skill_args
|
||
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
||
from omnigent.inner.os_env import OSEnvironment, create_os_environment
|
||
from omnigent.reasoning_effort import CLAUDE_EFFORTS
|
||
from omnigent.tools.base import Tool, ToolContext
|
||
from omnigent.tools.builtins.os_env import build_os_env_tools
|
||
|
||
BRIDGE_DIR_ENV_VAR = "HARNESS_CLAUDE_NATIVE_BRIDGE_DIR"
|
||
REQUEST_SESSION_ID_ENV_VAR = "HARNESS_CLAUDE_NATIVE_REQUEST_SESSION_ID"
|
||
BRIDGE_ID_LABEL_KEY = "omnigent.claude_native.bridge_id"
|
||
|
||
# Root for the per-process Claude bridge tree. Namespaced by uid so
|
||
# other Unix users on the same host cannot read the bearer token or
|
||
# pre-create the parent as a symlink to redirect the bridge tree. The
|
||
# trusted parent (`/tmp`) is shared; everything under
|
||
# `_BRIDGE_ROOT_PARENT` must be owned by the current uid and not be a
|
||
# symlink — see :func:`_ensure_secure_dir`.
|
||
_TRUSTED_PARENT = Path(tempfile.gettempdir())
|
||
_BRIDGE_ROOT_PARENT = _TRUSTED_PARENT / f"omnigent-{stable_user_id()}"
|
||
_BRIDGE_ROOT = _BRIDGE_ROOT_PARENT / "claude-native"
|
||
_CONFIG_FILE = "bridge.json"
|
||
_SERVER_FILE = "server.json"
|
||
_STATE_FILE = "state.json"
|
||
_HOOKS_FILE = "hooks.jsonl"
|
||
_RECENT_LOCAL_COMMAND_LINE_LIMIT = 200
|
||
_RECENT_LOCAL_COMMAND_WINDOW_S = 10.0
|
||
_FORKED_FROM_LINE_LIMIT = 200
|
||
_TOOL_RELAY_FILE = "tool_relay.json"
|
||
_TMUX_FILE = "tmux.json"
|
||
_PERMISSION_HOOK_FILE = "permission_hook.json"
|
||
_CONTEXT_FILE = "context.json"
|
||
_USER_CLAUDE_SETTINGS_PATH = Path.home() / ".claude" / "settings.json"
|
||
_MCP_SERVER_NAME = "omnigent"
|
||
_MCP_PROTOCOL_VERSION = "2024-11-05"
|
||
# Tools-changed: harness POSTs to the bridge MCP server's localhost
|
||
# control endpoint, which emits ``notifications/tools/list_changed``
|
||
# on its MCP stdout. Standard MCP notification — unrelated to the
|
||
# experimental Claude Channels feature that this module no longer
|
||
# uses.
|
||
_TOOLS_CHANGED_READY_TIMEOUT_S = 30.0
|
||
_TOOLS_CHANGED_POST_TIMEOUT_S = 10.0
|
||
# Ceiling the relay HTTP handler (``_run_relay_tool``) waits for a single
|
||
# tool dispatch to complete on the harness event loop.
|
||
_TOOL_CALL_TIMEOUT_S = 300.0
|
||
# Timeout for the bridge's POST to the active-turn relay server
|
||
# (``_call_relay_tool``). This is the OUTER hop: it waits for the relay
|
||
# handler's entire ``_TOOL_CALL_TIMEOUT_S`` dispatch, which itself fans out
|
||
# to the Omnigent policy server and back. It MUST exceed ``_TOOL_CALL_TIMEOUT_S``
|
||
# so the inner handler times out first and returns a clean MCP error over
|
||
# HTTP 200 — rather than the outer ``urlopen`` raising and tearing down the
|
||
# stdio MCP server (see ``_stdio_jsonrpc_loop``). The previous flat 10s sat
|
||
# below the real round-trip latency under load, so slow-but-healthy calls
|
||
# (session history reads, shell) tripped it and crashed the bridge.
|
||
_TOOL_RELAY_POST_TIMEOUT_S = _TOOL_CALL_TIMEOUT_S + 30.0
|
||
# Web-UI → Claude input now flows through tmux send-keys, not
|
||
# Claude's experimental Channels MCP capability. The runner writes
|
||
# ``tmux.json`` after the Claude terminal launches; the harness
|
||
# tails it and shells out to tmux.
|
||
_TMUX_READY_TIMEOUT_S = 30.0
|
||
_TMUX_SEND_TIMEOUT_S = 5.0
|
||
# Claude Code renders this prompt glyph in its input box once the TUI
|
||
# is interactive. We poll ``capture-pane`` for it before injecting the
|
||
# first message so keystrokes typed during Claude's boot aren't dropped.
|
||
# The glyph persists while Claude is busy responding, so its presence
|
||
# means "input box mounted" (not "idle"), which is what injection needs.
|
||
_CLAUDE_PROMPT_GLYPH = "❯"
|
||
# Matches a selected numbered menu row (``❯ 2. No (recommended)``): the glyph
|
||
# followed by a numbered choice, which the chat input never renders. Used to
|
||
# exclude startup menus from the readiness scan (see ``_is_selected_menu_row``).
|
||
_SELECTED_MENU_ROW_RE = re.compile(rf"{_CLAUDE_PROMPT_GLYPH}\s*\d+\.\s")
|
||
# Box-drawing glyphs Claude Code's input-box frame is made of. A line of
|
||
# these below ``❯`` marks the live input box (see ``_is_box_rule``),
|
||
# distinguishing it from a bare prompt echoed into scrollback.
|
||
_BOX_RULE_CHARS = frozenset("─━╭╮╰╯│┃╌╍")
|
||
# How many trailing non-empty lines to scan for the prompt glyph. The
|
||
# input box sits near the bottom of the pane; scanning only the tail
|
||
# avoids false positives from the glyph appearing in scrollback output.
|
||
# The window has to clear the footer rendered below the box — some
|
||
# people's statuslines run ~3 lines — so the ``❯`` row isn't the last
|
||
# non-empty line.
|
||
_PROMPT_SCAN_TAIL_LINES = 5
|
||
_CLAUDE_READY_POLL_INTERVAL_S = 0.15
|
||
_PASTE_SETTLE_S = 0.1 # let the TUI commit a paste before the separate submit Enter
|
||
# How long to wait for the pasted draft to visibly land in Claude's
|
||
# input box before sending the submit Enter. Claude Code coalesces
|
||
# rapid stdin bursts into a paste, so an Enter sent while the TUI is
|
||
# still consuming the paste gets folded in as a newline instead of
|
||
# submitting — the draft then sits unsent. Polling for the draft makes
|
||
# the handoff deterministic where the old fixed sleep raced it.
|
||
_PASTE_COMMIT_TIMEOUT_S = 5.0
|
||
# After the submit Enter, how long to keep checking that the draft
|
||
# actually left the input box (re-sending Enter while it hasn't)
|
||
# before failing loud.
|
||
_SUBMIT_VERIFY_TIMEOUT_S = 10.0
|
||
# Minimum spacing between repeated submit Enters during verification.
|
||
# Long enough for the TUI to clear the box after a successful submit
|
||
# (so a slow-but-successful first Enter isn't double-tapped), short
|
||
# enough that a swallowed Enter is retried promptly.
|
||
_SUBMIT_RETRY_INTERVAL_S = 1.0
|
||
# Claude Code collapses large pastes into this placeholder in the
|
||
# input box instead of rendering the text itself.
|
||
_PASTED_PLACEHOLDER_PREFIX = "[Pasted text"
|
||
# How many characters of the draft's first line to use when checking
|
||
# whether the draft is rendered in the input box. Short enough to fit
|
||
# on the prompt row of a default 80-column detached pane.
|
||
_DRAFT_NEEDLE_MAX_CHARS = 24
|
||
# When Claude Code's input prompt never renders (it failed to boot), the
|
||
# readiness gate attaches the tail of the captured pane to its error so
|
||
# the real cause — often Claude Code's own startup crash, e.g. a
|
||
# ``JSON Parse error`` from an HTML page served to its API client —
|
||
# surfaces in the web UI error banner instead of only in the terminal.
|
||
_TERMINAL_FAILURE_TAIL_LINES = 12
|
||
_TERMINAL_FAILURE_TAIL_CHARS = 800
|
||
|
||
ToolExecutor = Callable[[str, dict[str, Any]], Awaitable[Any]]
|
||
|
||
|
||
def _absolute_syntactic_path(path: Path) -> Path:
|
||
"""
|
||
Return an absolute path without following symlinks.
|
||
|
||
Security validation needs to inspect symlinked ancestors with
|
||
``lstat``. ``Path.resolve`` would follow an existing symlink before
|
||
that inspection, so this helper only expands ``~`` and normalizes
|
||
``.`` / ``..`` components.
|
||
|
||
:param path: Path to normalize, e.g. ``Path("~/.omnigent/x")``.
|
||
:returns: Absolute path with syntactic normalization applied.
|
||
"""
|
||
return Path(os.path.abspath(os.fspath(path.expanduser())))
|
||
|
||
|
||
def _trusted_parent_for_bridge_dir(target: Path) -> Path:
|
||
"""
|
||
Return the trusted parent for an allowed bridge directory.
|
||
|
||
Claude-native files live below the uid-scoped temp bridge root.
|
||
Codex-, Cursor-, Qwen-, Hermes-, Antigravity-, and OpenCode-native reuse the
|
||
relay/MCP implementation but keep bridge files below their own bridge roots.
|
||
All roots use the same owner-only ancestor validation; only the trusted
|
||
anchor differs.
|
||
|
||
:param target: Normalized bridge directory path being created or validated,
|
||
e.g. ``Path("/tmp/omnigent-501/claude-native/abc")``.
|
||
:returns: Absolute parent at which ancestor validation stops, e.g.
|
||
``Path("/tmp")``.
|
||
:raises RuntimeError: If ``target`` is not below a known bridge root.
|
||
"""
|
||
claude_root = _absolute_syntactic_path(_BRIDGE_ROOT)
|
||
if target.is_relative_to(claude_root):
|
||
return _absolute_syntactic_path(_TRUSTED_PARENT)
|
||
|
||
from omnigent.codex_native_bridge import bridge_root
|
||
|
||
codex_root = _absolute_syntactic_path(bridge_root())
|
||
if target.is_relative_to(codex_root):
|
||
# In production, trust $HOME and validate/chmod the two bridge-owned
|
||
# directories below it: .omnigent and codex-native. In tests, the
|
||
# monkeypatched root may not use that shape, so trust the direct parent.
|
||
trusted_parent = codex_root.parent
|
||
if codex_root.name == "codex-native" and codex_root.parent.name == ".omnigent":
|
||
trusted_parent = codex_root.parent.parent
|
||
return _absolute_syntactic_path(trusted_parent)
|
||
|
||
from omnigent.cursor_native_bridge import bridge_root as cursor_bridge_root
|
||
|
||
cursor_root = _absolute_syntactic_path(cursor_bridge_root())
|
||
if target.is_relative_to(cursor_root):
|
||
return _absolute_syntactic_path(cursor_root.parent.parent)
|
||
|
||
from omnigent.antigravity_native_bridge import bridge_root as antigravity_bridge_root
|
||
|
||
# antigravity-native keeps its bridge files below ``~/.omnigent/antigravity-native``,
|
||
# the same ``$HOME/.omnigent/<harness>-native`` shape codex uses, so apply the
|
||
# identical anchor logic: in production trust ``$HOME`` and validate/chmod the
|
||
# two bridge-owned dirs below it (``.omnigent`` and ``antigravity-native``); in
|
||
# tests the monkeypatched root may differ, so trust the direct parent.
|
||
antigravity_root = _absolute_syntactic_path(antigravity_bridge_root())
|
||
if target.is_relative_to(antigravity_root):
|
||
trusted_parent = antigravity_root.parent
|
||
if (
|
||
antigravity_root.name == "antigravity-native"
|
||
and antigravity_root.parent.name == ".omnigent"
|
||
):
|
||
trusted_parent = antigravity_root.parent.parent
|
||
return _absolute_syntactic_path(trusted_parent)
|
||
|
||
from omnigent.qwen_native_bridge import bridge_root as qwen_bridge_root
|
||
|
||
qwen_root = _absolute_syntactic_path(qwen_bridge_root())
|
||
if target.is_relative_to(qwen_root):
|
||
# Same shape as cursor-native ($TMPDIR/omnigent-<uid>/qwen-native): trust
|
||
# the uid-scoped temp dir's parent and validate/chmod the two
|
||
# bridge-owned directories below it.
|
||
return _absolute_syntactic_path(qwen_root.parent.parent)
|
||
|
||
from omnigent.hermes_native_bridge import bridge_root as hermes_bridge_root
|
||
|
||
hermes_root = _absolute_syntactic_path(hermes_bridge_root())
|
||
if target.is_relative_to(hermes_root):
|
||
# Same shape as cursor-native ($TMPDIR/omnigent-<uid>/hermes-native): trust
|
||
# the uid-scoped temp dir's parent and validate/chmod the two
|
||
# bridge-owned directories below it.
|
||
return _absolute_syntactic_path(hermes_root.parent.parent)
|
||
|
||
from omnigent.opencode_native_bridge import bridge_root as opencode_bridge_root
|
||
|
||
# opencode-native keeps its bridge files below ``~/.omnigent/opencode-native``
|
||
# (the same ``$HOME/.omnigent/<harness>-native`` shape codex/antigravity use),
|
||
# so apply the identical anchor logic: in production trust ``$HOME`` and
|
||
# validate/chmod the two bridge-owned dirs below it (``.omnigent`` and
|
||
# ``opencode-native``); in tests the monkeypatched root may differ, so trust
|
||
# the direct parent.
|
||
opencode_root = _absolute_syntactic_path(opencode_bridge_root())
|
||
if target.is_relative_to(opencode_root):
|
||
trusted_parent = opencode_root.parent
|
||
if opencode_root.name == "opencode-native" and opencode_root.parent.name == ".omnigent":
|
||
trusted_parent = opencode_root.parent.parent
|
||
return _absolute_syntactic_path(trusted_parent)
|
||
|
||
kiro_root = _absolute_syntactic_path(kiro_bridge_root())
|
||
if target.is_relative_to(kiro_root):
|
||
# Same shape as cursor-native ($TMPDIR/omnigent-<uid>/kiro-native): trust
|
||
# the uid-scoped temp dir's parent and validate/chmod the two
|
||
# bridge-owned directories below it.
|
||
return _absolute_syntactic_path(kiro_root.parent.parent)
|
||
|
||
# Headless ACP harnesses (acp / goose / qwen) put their Omnigent-MCP relay
|
||
# bridge below ``$TMPDIR/omnigent-<uid>/acp-mcp`` (same uid-scoped shape as
|
||
# cursor/qwen/hermes-native), so trust the uid-scoped temp dir's parent.
|
||
acp_root = _absolute_syntactic_path(acp_mcp_bridge_root())
|
||
if target.is_relative_to(acp_root):
|
||
return _absolute_syntactic_path(acp_root.parent.parent)
|
||
|
||
raise RuntimeError(
|
||
f"bridge dir {target!s} is not under an allowed bridge root "
|
||
f"({claude_root!s}, {codex_root!s}, {cursor_root!s}, "
|
||
f"{antigravity_root!s}, {qwen_root!s}, {hermes_root!s}, {opencode_root!s}, "
|
||
f"{kiro_root!s}, {acp_root!s})"
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ClaudeTranscriptItem:
|
||
"""
|
||
One Omnigent conversation item parsed from Claude's JSONL log.
|
||
|
||
:param source_id: Stable idempotency key derived from the Claude
|
||
transcript record UUID and content block position, e.g.
|
||
``"747e:0:function_call"``.
|
||
:param item_type: Omnigent conversation item type, e.g.
|
||
``"message"`` or ``"function_call"``.
|
||
:param data: Item payload shaped like ``SessionEventInput.data``.
|
||
:param response_id: Synthetic response id used to group the
|
||
Claude turn in AP/web UI rendering.
|
||
"""
|
||
|
||
source_id: str
|
||
item_type: str
|
||
data: dict[str, Any]
|
||
response_id: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class TranscriptReadResult:
|
||
"""
|
||
Result of reading Claude transcript JSONL records.
|
||
|
||
:param line_cursor: Count of complete newline-terminated records
|
||
consumed from the transcript, e.g. ``12``.
|
||
:param byte_offset: Byte offset immediately after the last
|
||
complete record consumed, e.g. ``4096``. A partial trailing
|
||
line is not included.
|
||
:param current_response_id: Response id for a Claude assistant
|
||
turn that remains active across polls.
|
||
:param items: Parsed Omnigent conversation items from the
|
||
complete records after the caller's cursor.
|
||
:param latest_usage: Token-usage from the most recent assistant
|
||
entry with a ``message.usage`` block. Keys: ``context_tokens``,
|
||
``input_tokens``, ``output_tokens``. ``None`` when no such
|
||
entry was scanned.
|
||
:param latest_model: ``message.model`` from the most recent
|
||
assistant entry, or ``None``.
|
||
"""
|
||
|
||
line_cursor: int
|
||
byte_offset: int
|
||
current_response_id: str | None
|
||
items: list[ClaudeTranscriptItem]
|
||
latest_usage: dict[str, int] | None = None
|
||
latest_model: str | None = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ClaudeHookRecord:
|
||
"""
|
||
One complete hook JSONL record read from ``hooks.jsonl``.
|
||
|
||
:param event_cursor: Count of complete hook records consumed
|
||
through this record, e.g. ``3``.
|
||
:param byte_offset: Byte offset immediately after this complete
|
||
record, e.g. ``512``.
|
||
:param recorded_at: Unix timestamp for when the hook was recorded,
|
||
e.g. ``1779922393.222``. ``None`` when the envelope did not
|
||
carry a numeric timestamp.
|
||
:param event_name: Claude hook event name, e.g. ``"Stop"``.
|
||
``None`` means the line was complete but malformed or did not
|
||
contain a usable event name; the durable cursor may still
|
||
advance past it.
|
||
:param source: Claude ``SessionStart`` source, e.g. ``"clear"``,
|
||
or ``None`` for hook records without a source field.
|
||
:param claude_session_id: Claude-native session uuid from the hook
|
||
payload, e.g. ``"a1b2c3d4-1234-5678-9abc-def012345678"``,
|
||
or ``None`` when absent.
|
||
:param transcript_path: Claude transcript path from the hook
|
||
payload, e.g. ``"/home/user/.claude/projects/x/session.jsonl"``,
|
||
or ``None`` when absent.
|
||
:param previous_claude_session_id: Claude session id that was
|
||
active immediately before this hook, e.g.
|
||
``"a1b2c3d4-1234-5678-9abc-def012345678"``, or ``None``
|
||
when the hook did not capture one.
|
||
:param claude_session_was_seen: Whether the incoming Claude
|
||
session id had already been observed before this hook was
|
||
recorded. ``None`` means the hook did not capture that
|
||
context.
|
||
:param clear_rotated_to: Omnigent session id created synchronously by the
|
||
hook for ``SessionStart source=clear``, e.g. ``"conv_new"``,
|
||
or ``None`` when the background forwarder should rotate.
|
||
:param fork_detected: Whether the hook identified this record as a
|
||
Claude branch/fork transition before recording it. The
|
||
background forwarder uses this annotation because state.json
|
||
already points at the new Claude session by the time it reads
|
||
hooks.jsonl.
|
||
:param fork_rotated_to: Omnigent session id created synchronously by the
|
||
hook for a Claude branch/fork transition, e.g. ``"conv_fork"``,
|
||
or ``None`` when the background forwarder should fork.
|
||
:param todos: Updated todo list from a ``PostToolUse``/``TodoWrite``
|
||
hook event, e.g.
|
||
``[{"content": "Write tests", "status": "in_progress",
|
||
"activeForm": "Writing tests"}]``. ``None`` for all other events.
|
||
:param task_id: Native task id from a ``TaskCreated``,
|
||
``TaskCompleted``, or ``PostToolUse``/``TaskUpdate`` hook event,
|
||
e.g. ``"1"``. ``None`` for all other events.
|
||
:param task_subject: Human-readable task subject from a
|
||
``TaskCreated`` hook event, e.g. ``"Create folder 'abc'"``.
|
||
``None`` for all other events.
|
||
:param task_status: Task status from a ``TaskCreated`` (``"pending"``),
|
||
``TaskCompleted`` (``"completed"``), or
|
||
``PostToolUse``/``TaskUpdate`` event (``"in_progress"`` or
|
||
``"completed"``). ``None`` for all other events.
|
||
:param background_task_count: Number of background tasks still running
|
||
when a ``Stop`` hook fires — entries in the payload's
|
||
``background_tasks`` array whose per-task ``status`` is not terminal
|
||
(see :data:`_TERMINAL_BACKGROUND_TASK_STATUSES`). ``0`` for all other
|
||
events or when absent.
|
||
"""
|
||
|
||
event_cursor: int
|
||
byte_offset: int
|
||
event_name: str | None
|
||
recorded_at: float | None = None
|
||
source: str | None = None
|
||
claude_session_id: str | None = None
|
||
transcript_path: Path | None = None
|
||
previous_claude_session_id: str | None = None
|
||
claude_session_was_seen: bool | None = None
|
||
clear_rotated_to: str | None = None
|
||
fork_detected: bool = False
|
||
fork_rotated_to: str | None = None
|
||
todos: list[dict[str, Any]] | None = None
|
||
task_id: str | None = None
|
||
task_subject: str | None = None
|
||
task_status: str | None = None
|
||
background_task_count: int = 0
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class HookReadResult:
|
||
"""
|
||
Result of reading Claude hook JSONL records.
|
||
|
||
:param event_cursor: Count of complete hook records consumed.
|
||
:param byte_offset: Byte offset immediately after the last
|
||
complete hook record consumed. A partial trailing line is not
|
||
included.
|
||
:param records: Complete hook records after the caller's cursor.
|
||
"""
|
||
|
||
event_cursor: int
|
||
byte_offset: int
|
||
records: list[ClaudeHookRecord]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _JsonlRecord:
|
||
"""
|
||
One complete newline-terminated JSONL record.
|
||
|
||
:param line_number: One-based line number relative to the reader's
|
||
line cursor, e.g. ``5``.
|
||
:param byte_offset: Byte offset where the record starts.
|
||
:param next_byte_offset: Byte offset immediately after the
|
||
newline-terminated record.
|
||
:param text: UTF-8 decoded JSONL text including the trailing
|
||
newline, or ``None`` when the complete record was not valid
|
||
UTF-8 and should advance cursors without being parsed.
|
||
"""
|
||
|
||
line_number: int
|
||
byte_offset: int
|
||
next_byte_offset: int
|
||
text: str | None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _JsonlReadResult:
|
||
"""
|
||
Complete-record read result for an append-only JSONL file.
|
||
|
||
:param line_cursor: Count of complete records consumed.
|
||
:param byte_offset: Byte offset after the last complete record.
|
||
:param records: Complete records read after the requested byte
|
||
offset.
|
||
"""
|
||
|
||
line_cursor: int
|
||
byte_offset: int
|
||
records: list[_JsonlRecord]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ClaudeMessageDelta:
|
||
"""
|
||
One streamed assistant-text chunk recorded by the MessageDisplay hook.
|
||
|
||
Written to ``<bridge_dir>/message_deltas.jsonl`` by
|
||
:mod:`omnigent.claude_native_message_display_hook` and read back by
|
||
the transcript forwarder to publish ``response.output_text.delta``
|
||
events.
|
||
|
||
:param message_id: Claude's stable per-assistant-message id, e.g.
|
||
``"2ca51d97-2f0f-493a-aed7-85a5b56c5747"``. Used by the web UI
|
||
to scope the in-flight buffer for one message; it does NOT
|
||
appear in the transcript JSONL, so the final item is correlated
|
||
positionally rather than by this id.
|
||
:param index: 0-based chunk order within the message, e.g. ``3``.
|
||
:param final: ``True`` on the last chunk of the message.
|
||
:param delta: Incremental text for this chunk, e.g.
|
||
``"Pour in the wine"``. Disjoint from other chunks' text.
|
||
"""
|
||
|
||
message_id: str
|
||
index: int
|
||
final: bool
|
||
delta: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MessageDeltaReadResult:
|
||
"""
|
||
Complete-record read result for the message-deltas JSONL file.
|
||
|
||
:param byte_offset: Byte offset after the last complete record, to
|
||
be persisted and passed to the next read so tailing resumes
|
||
without re-reading.
|
||
:param deltas: Parsed deltas appended after the requested offset,
|
||
in file (append) order.
|
||
"""
|
||
|
||
byte_offset: int
|
||
deltas: list[ClaudeMessageDelta]
|
||
|
||
|
||
def read_message_deltas_from_offset(
|
||
bridge_dir: Path,
|
||
byte_offset: int,
|
||
) -> MessageDeltaReadResult:
|
||
"""
|
||
Read assistant-text deltas appended after a byte offset.
|
||
|
||
Only complete newline-terminated records are returned; a partial
|
||
trailing record leaves the byte offset unchanged so the next poll
|
||
retries it once the hook finishes the append. Records that fail to
|
||
parse into a well-formed :class:`ClaudeMessageDelta` are skipped (the
|
||
byte offset still advances past them) — a malformed line must not
|
||
wedge the tail.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:param byte_offset: Byte offset already consumed, e.g. ``2048``.
|
||
:returns: Parsed deltas plus the updated byte offset.
|
||
"""
|
||
read_result = _read_complete_jsonl_records(
|
||
bridge_dir / MESSAGE_DELTAS_FILE,
|
||
byte_offset=byte_offset,
|
||
start_line=0,
|
||
)
|
||
deltas: list[ClaudeMessageDelta] = []
|
||
for record in read_result.records:
|
||
delta = _message_delta_from_jsonl_text(record.text)
|
||
if delta is not None:
|
||
deltas.append(delta)
|
||
return MessageDeltaReadResult(
|
||
byte_offset=read_result.byte_offset,
|
||
deltas=deltas,
|
||
)
|
||
|
||
|
||
def _message_delta_from_jsonl_text(text: str | None) -> ClaudeMessageDelta | None:
|
||
"""
|
||
Parse one deltas-file line into a :class:`ClaudeMessageDelta`.
|
||
|
||
:param text: Raw JSONL line text, or ``None`` when the record bytes
|
||
were not valid UTF-8.
|
||
:returns: Parsed delta, or ``None`` when the line is malformed or
|
||
lacks the required ``message_id``/``delta`` fields.
|
||
"""
|
||
if not text:
|
||
return None
|
||
try:
|
||
payload = json.loads(text)
|
||
except json.JSONDecodeError:
|
||
return None
|
||
if not isinstance(payload, dict):
|
||
return None
|
||
message_id = payload.get("message_id")
|
||
delta = payload.get("delta")
|
||
index = payload.get("index")
|
||
if not isinstance(message_id, str) or not message_id:
|
||
return None
|
||
if not isinstance(delta, str):
|
||
return None
|
||
# ``bool`` is an ``int`` subclass — exclude it so a stray ``true``
|
||
# index is rejected rather than silently coerced to 0/1.
|
||
if not isinstance(index, int) or isinstance(index, bool):
|
||
return None
|
||
return ClaudeMessageDelta(
|
||
message_id=message_id,
|
||
index=index,
|
||
final=bool(payload.get("final")),
|
||
delta=delta,
|
||
)
|
||
|
||
|
||
class ClaudeNativeToolRelay:
|
||
"""
|
||
HTTP relay for Claude MCP tool calls, scoped to its caller's lifetime.
|
||
|
||
Claude's MCP helper process calls the relay synchronously when Claude
|
||
Code invokes a relayed Omnigent tool; the relay forwards the call
|
||
into the ``tool_executor`` callback supplied at start, which dispatches
|
||
it on the runner event loop (e.g. through the Omnigent REST API).
|
||
|
||
Callers choose the lifetime and call :meth:`close` when it ends. The
|
||
comment-tool relay (``list_comments`` / ``update_comment``) is
|
||
session-scoped — started when the Claude terminal launches and closed
|
||
on session delete — whereas a per-turn caller would start and close it
|
||
within one turn.
|
||
|
||
:param bridge_dir: Bridge directory containing
|
||
``tool_relay.json``, e.g. ``/tmp/omnigent/claude-native/x``.
|
||
:param httpd: Started localhost HTTP server for tool calls. Its bound
|
||
address identifies this relay's advertisement on close.
|
||
"""
|
||
|
||
def __init__(self, *, bridge_dir: Path, httpd: ThreadingHTTPServer) -> None:
|
||
"""
|
||
Initialize the relay handle.
|
||
|
||
:param bridge_dir: Bridge directory containing the relay
|
||
advertisement, e.g. ``Path("/tmp/omnigent/...")``.
|
||
:param httpd: Started localhost HTTP server for tool calls.
|
||
:returns: None.
|
||
"""
|
||
self._bridge_dir = bridge_dir
|
||
self._httpd = httpd
|
||
|
||
def close(self) -> None:
|
||
"""
|
||
Stop the relay's HTTP server and remove its advertisement file.
|
||
|
||
Only unlinks ``tool_relay.json`` when it still advertises *this*
|
||
relay (its ``url`` matches this server's bound address). Sessions
|
||
that fork/clear/resume keep the same ``bridge_id`` — hence the same
|
||
bridge dir and relay file — so a newer session's relay may have
|
||
overwritten the file with its own address. Unlinking unconditionally
|
||
would delete the still-active session's advertisement and make its
|
||
comment tools vanish. The HTTP server is always shut down (it is this
|
||
relay's own socket).
|
||
|
||
:returns: None.
|
||
"""
|
||
relay_file = self._bridge_dir / _TOOL_RELAY_FILE
|
||
host, port = self._httpd.server_address
|
||
# A newer relay that overwrote the file advertises a different url
|
||
# (this relay's socket is still bound, so its port is unique), so the
|
||
# file is left for that relay to own.
|
||
if _read_json_file(relay_file).get("url") == f"http://{host}:{port}":
|
||
with contextlib.suppress(FileNotFoundError):
|
||
relay_file.unlink()
|
||
self._httpd.shutdown()
|
||
self._httpd.server_close()
|
||
|
||
|
||
def _ensure_secure_dir(target: Path) -> None:
|
||
"""
|
||
Create or validate ``target`` as an owner-only directory chain.
|
||
|
||
``Path.mkdir(mode=0o700, parents=True, exist_ok=True)`` only applies
|
||
the mode to the leaf and silently trusts any pre-existing ancestor.
|
||
On a shared host, an attacker could pre-create
|
||
``/tmp/omnigent-<UID>`` (Claude-native), ``~/.omnigent``
|
||
(Codex-native), or a deeper ancestor as a symlink — or as a 0o777
|
||
directory — and redirect the bridge tree (which stores bearer
|
||
tokens in JSON files).
|
||
|
||
This helper resolves the trusted parent for ``target`` and walks
|
||
each ancestor from that trusted parent down to ``target``,
|
||
creating new ones with mode 0o700 and rejecting any existing
|
||
ancestor that is a symlink, not a directory, owned by a different
|
||
uid, or has group/other permission bits set. Wrong-but-repairable
|
||
modes on dirs we own are reset to 0o700.
|
||
|
||
:param target: Final bridge directory path to ensure, e.g.
|
||
``Path("/tmp/omnigent-501/claude-native/abc")``.
|
||
:raises RuntimeError: If validation fails for any ancestor.
|
||
"""
|
||
target = _absolute_syntactic_path(target)
|
||
trusted_parent = _trusted_parent_for_bridge_dir(target)
|
||
ancestors: list[Path] = []
|
||
cur = target
|
||
while cur != trusted_parent and cur != cur.parent:
|
||
ancestors.append(cur)
|
||
cur = cur.parent
|
||
if cur != trusted_parent:
|
||
raise RuntimeError(f"bridge dir {target!s} is not under trusted parent {trusted_parent!s}")
|
||
ancestors.reverse()
|
||
my_uid = getattr(os, "getuid", lambda: -1)()
|
||
for ancestor in ancestors:
|
||
try:
|
||
os.mkdir(ancestor, mode=0o700)
|
||
continue
|
||
except FileExistsError:
|
||
pass
|
||
st = os.lstat(ancestor)
|
||
if stat.S_ISLNK(st.st_mode):
|
||
raise RuntimeError(f"refusing to use bridge ancestor {ancestor!s}: is a symlink")
|
||
if not stat.S_ISDIR(st.st_mode):
|
||
raise RuntimeError(f"refusing to use bridge ancestor {ancestor!s}: not a directory")
|
||
if st.st_uid != my_uid:
|
||
raise RuntimeError(
|
||
f"refusing to use bridge ancestor {ancestor!s}: owned by uid "
|
||
f"{st.st_uid}, not current user ({my_uid})"
|
||
)
|
||
if (st.st_mode & 0o077) != 0:
|
||
os.chmod(ancestor, 0o700)
|
||
|
||
|
||
def acp_mcp_bridge_root() -> Path:
|
||
"""Bridge root for the headless ACP harnesses' Omnigent-MCP relay.
|
||
|
||
Shares the uid-scoped temp parent with claude-native
|
||
(``$TMPDIR/omnigent-<uid>/acp-mcp``). Used by the acp / goose / qwen
|
||
executors' ``OmnigentAcpMcp`` relay so ``serve-mcp``'s bridge dir passes the
|
||
:func:`_trusted_parent_for_bridge_dir` secure-root check.
|
||
|
||
:returns: The ACP-MCP bridge root directory (not created here).
|
||
"""
|
||
return _BRIDGE_ROOT_PARENT / "acp-mcp"
|
||
|
||
|
||
def prepare_acp_mcp_bridge_dir() -> Path:
|
||
"""Create a fresh, secure per-relay bridge dir for an ACP harness.
|
||
|
||
Returns a unique owner-only directory under :func:`acp_mcp_bridge_root` with
|
||
a minimal token-only ``bridge.json`` — so the shared ``serve-mcp`` serves
|
||
ONLY the relay tools (no raw ``sys_os_*`` filesystem tools; the ACP agent
|
||
owns those). The caller's relay writes ``tool_relay.json`` here and points
|
||
``serve-mcp`` at the directory.
|
||
|
||
:returns: The prepared bridge directory path.
|
||
"""
|
||
bridge_dir = acp_mcp_bridge_root() / secrets.token_hex(8)
|
||
_ensure_secure_dir(bridge_dir)
|
||
config_path = bridge_dir / _CONFIG_FILE
|
||
if not config_path.exists():
|
||
_write_json_file(config_path, {"token": secrets.token_urlsafe(32)})
|
||
return bridge_dir
|
||
|
||
|
||
def bridge_dir_for_bridge_id(bridge_id: str) -> Path:
|
||
"""
|
||
Return the deterministic bridge directory for a Claude-native bridge.
|
||
|
||
:param bridge_id: Opaque bridge id, e.g. ``"bridge_abc123"``.
|
||
:returns: Absolute bridge directory under
|
||
``/tmp/omnigent-<UID>/claude-native``.
|
||
"""
|
||
digest = hashlib.sha256(bridge_id.encode("utf-8")).hexdigest()[:32]
|
||
return _BRIDGE_ROOT / digest
|
||
|
||
|
||
def bridge_dir_for_conversation_id(conversation_id: str) -> Path:
|
||
"""
|
||
Return the bridge directory for a legacy session id.
|
||
|
||
:param conversation_id: Omnigent conversation id used as bridge id, e.g.
|
||
``"conv_abc123"``.
|
||
:returns: Absolute bridge directory under
|
||
``/tmp/omnigent-<UID>/claude-native``.
|
||
"""
|
||
return bridge_dir_for_bridge_id(conversation_id)
|
||
|
||
|
||
def build_claude_native_spawn_env(
|
||
conversation_id: str,
|
||
*,
|
||
bridge_id: str | None = None,
|
||
) -> dict[str, str]:
|
||
"""
|
||
Build spawn env for the ``claude-native`` harness process.
|
||
|
||
:param conversation_id: Omnigent conversation id, e.g.
|
||
``"conv_abc123"``.
|
||
:param bridge_id: Opaque bridge id from
|
||
:data:`BRIDGE_ID_LABEL_KEY`, e.g. ``"bridge_abc123"``. ``None``
|
||
normalizes old sessions by using *conversation_id*.
|
||
:returns: Environment variables needed by
|
||
:class:`ClaudeNativeExecutor`.
|
||
"""
|
||
resolved_bridge_id = bridge_id or conversation_id
|
||
return {
|
||
BRIDGE_DIR_ENV_VAR: str(bridge_dir_for_bridge_id(resolved_bridge_id)),
|
||
REQUEST_SESSION_ID_ENV_VAR: conversation_id,
|
||
}
|
||
|
||
|
||
def prepare_bridge_dir(
|
||
conversation_id: str,
|
||
*,
|
||
bridge_id: str | None = None,
|
||
workspace: Path,
|
||
launch_model: str | None = None,
|
||
) -> Path:
|
||
"""
|
||
Create or refresh the bridge directory for a native Claude session.
|
||
|
||
:param conversation_id: Omnigent conversation id, e.g.
|
||
``"conv_abc123"``.
|
||
:param bridge_id: Opaque bridge id, e.g. ``"bridge_abc123"``.
|
||
``None`` normalizes old sessions by using *conversation_id*.
|
||
:param workspace: Runner workspace/cwd used for local OS tools.
|
||
:param launch_model: Gateway model name that Claude was launched
|
||
with, e.g. ``"databricks-claude-opus-4-7"``. Persisted so the
|
||
forwarder can re-inject it when Claude Code's ``/model``
|
||
normalizes the name to one the gateway rejects. ``None`` when
|
||
no ucode profile is active.
|
||
:returns: Bridge directory path.
|
||
"""
|
||
resolved_bridge_id = bridge_id or conversation_id
|
||
bridge_dir = bridge_dir_for_bridge_id(resolved_bridge_id)
|
||
_ensure_secure_dir(bridge_dir)
|
||
config = _read_json_file(bridge_dir / _CONFIG_FILE)
|
||
token = config.get("token") if isinstance(config, dict) else None
|
||
if not isinstance(token, str) or not token:
|
||
token = secrets.token_urlsafe(32)
|
||
payload: dict[str, object] = {
|
||
"bridge_id": resolved_bridge_id,
|
||
"active_session_id": conversation_id,
|
||
"conversation_id": conversation_id,
|
||
"workspace": str(workspace),
|
||
"token": token,
|
||
"updated_at": time.time(),
|
||
}
|
||
if launch_model is not None:
|
||
payload["launch_model"] = launch_model
|
||
_write_json_file(bridge_dir / _CONFIG_FILE, payload)
|
||
# Keep ``_PERMISSION_HOOK_FILE`` — the PermissionRequest command hook
|
||
# reads the Omnigent server URL from it at runtime, so wiping it on re-prep
|
||
# breaks approval routing on reattach/rebind. ``build_hook_settings``
|
||
# rewrites it on cold launch.
|
||
for filename in (
|
||
_SERVER_FILE,
|
||
_STATE_FILE,
|
||
_HOOKS_FILE,
|
||
_TOOL_RELAY_FILE,
|
||
_TMUX_FILE,
|
||
):
|
||
with contextlib.suppress(FileNotFoundError):
|
||
(bridge_dir / filename).unlink()
|
||
return bridge_dir
|
||
|
||
|
||
def ensure_claude_workspace_trusted(workspace: Path) -> None:
|
||
"""
|
||
Pre-accept Claude Code's first-run trust + onboarding prompts.
|
||
|
||
Claude Code blocks on two TUI prompts the first time it launches in
|
||
a new context: a global onboarding flow (theme / login) gated by the
|
||
top-level ``hasCompletedOnboarding`` key in ``~/.claude.json``, and a
|
||
per-directory "Do you trust the files in this folder?" dialog gated
|
||
by ``projects["<abs cwd>"].hasTrustDialogAccepted``. Neither fires a
|
||
``PermissionRequest`` hook, so on a host-spawned (web-UI-driven)
|
||
session there is nobody at the terminal to answer them: Claude hangs
|
||
and the web UI shows nothing. This is acute with
|
||
per-session git worktrees, which hand Claude a brand-new —
|
||
therefore untrusted — directory on every session.
|
||
|
||
Seed both gating keys idempotently so the launch never blocks. Only
|
||
those two keys are written; all other ``~/.claude.json`` state (the
|
||
user's own onboarding choices, project history, MCP config, OAuth
|
||
account) is preserved, and the file is left untouched when both keys
|
||
are already set. This deliberately does NOT skip per-tool permission
|
||
prompts — those still route to the web UI via the ``PermissionRequest``
|
||
hook; only the unhookable startup gates are pre-accepted.
|
||
|
||
Concurrency: this is a read-modify-write of a file Claude itself also
|
||
rewrites. It runs once, before the terminal is launched (so Claude is
|
||
not yet writing for this session), and uses an atomic replace. Two
|
||
runners starting on the same host within the same instant could still
|
||
race on last-writer-wins; the only consequence is that one session may
|
||
re-show the trust prompt, which a relaunch clears. Matching this to
|
||
Claude's own lock-free writes keeps the helper simple.
|
||
|
||
:param workspace: The runner workspace Claude will launch in, e.g.
|
||
``Path("/home/user/repo-worktrees/feature-x")``. Resolved to an
|
||
absolute path to match Claude's ``projects`` key convention.
|
||
:returns: None.
|
||
:raises ValueError: If an existing ``~/.claude.json`` (or its
|
||
``projects`` map / target project entry) is not a JSON object.
|
||
Surfaced rather than silently overwritten so a corrupt or
|
||
unexpected user config is never clobbered (fail loud).
|
||
:raises json.JSONDecodeError: If an existing ``~/.claude.json`` is
|
||
not valid JSON, for the same reason.
|
||
"""
|
||
config_path = Path.home() / ".claude.json"
|
||
if config_path.exists():
|
||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||
if not isinstance(data, dict):
|
||
raise ValueError(f"{config_path} is not a JSON object; refusing to overwrite.")
|
||
else:
|
||
data = {}
|
||
|
||
changed = False
|
||
# Global onboarding gate (theme / login). Absent on a machine that
|
||
# has never run Claude Code interactively.
|
||
if data.get("hasCompletedOnboarding") is not True:
|
||
data["hasCompletedOnboarding"] = True
|
||
changed = True
|
||
|
||
# Per-directory trust gate. Claude keys its ``projects`` map by the
|
||
# resolved absolute path, so match that exactly.
|
||
project_key = str(workspace.resolve())
|
||
projects = data.setdefault("projects", {})
|
||
if not isinstance(projects, dict):
|
||
raise ValueError(f"{config_path} 'projects' is not a JSON object; refusing to overwrite.")
|
||
project = projects.setdefault(project_key, {})
|
||
if not isinstance(project, dict):
|
||
raise ValueError(
|
||
f"{config_path} projects[{project_key!r}] is not a JSON object; refusing to overwrite."
|
||
)
|
||
if project.get("hasTrustDialogAccepted") is not True:
|
||
project["hasTrustDialogAccepted"] = True
|
||
changed = True
|
||
|
||
if not changed:
|
||
return
|
||
_atomic_write_user_json(config_path, data)
|
||
|
||
|
||
def _atomic_write_user_json(path: Path, payload: dict[str, Any]) -> None:
|
||
"""
|
||
Atomically rewrite a user-owned JSON config file in place.
|
||
|
||
Unlike :func:`_write_json_file` (which targets the owner-only bridge
|
||
tree under ``/tmp`` and enforces secure-directory ownership on the
|
||
parent), this writes the user's own ``~/.claude.json`` in their home
|
||
directory: it must not re-permission the home directory, but it does
|
||
pin the result to owner-only ``0o600`` because the file holds the
|
||
Claude OAuth account block.
|
||
|
||
:param path: Destination file, e.g. ``Path("~/.claude.json")``.
|
||
:param payload: JSON-serializable config object to write. Rendered
|
||
with two-space indentation to match Claude's own formatting and
|
||
keep diffs readable.
|
||
:returns: None.
|
||
:raises OSError: If the temp file cannot be written, ``chmod``-ed,
|
||
or atomically replaced into place — e.g. the home directory is
|
||
read-only or the filesystem does not support ``os.replace``.
|
||
"""
|
||
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)
|
||
json.dump(payload, handle, indent=2)
|
||
handle.flush()
|
||
os.fsync(handle.fileno())
|
||
os.chmod(tmp_path, 0o600)
|
||
os.replace(tmp_path, path)
|
||
tmp_path = None
|
||
finally:
|
||
if tmp_path is not None:
|
||
with contextlib.suppress(FileNotFoundError):
|
||
tmp_path.unlink()
|
||
|
||
|
||
def read_active_session_id(bridge_dir: Path) -> str | None:
|
||
"""
|
||
Read the Omnigent session currently receiving bridge-originated events.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:returns: Active Omnigent session id, e.g. ``"conv_abc123"``, or
|
||
``None`` when the bridge config is absent or malformed.
|
||
"""
|
||
config = _read_json_file(bridge_dir / _CONFIG_FILE)
|
||
if not isinstance(config, dict):
|
||
return None
|
||
active = config.get("active_session_id")
|
||
if isinstance(active, str) and active:
|
||
return active
|
||
legacy = config.get("conversation_id")
|
||
return legacy if isinstance(legacy, str) and legacy else None
|
||
|
||
|
||
def read_launch_model(bridge_dir: Path) -> str | None:
|
||
"""
|
||
Read the gateway model name that Claude was launched with.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:returns: Gateway model name, e.g.
|
||
``"databricks-claude-opus-4-7"``, or ``None`` when no ucode
|
||
profile was active at launch time.
|
||
"""
|
||
config = _read_json_file(bridge_dir / _CONFIG_FILE)
|
||
if not isinstance(config, dict):
|
||
return None
|
||
model = config.get("launch_model")
|
||
return model if isinstance(model, str) and model else None
|
||
|
||
|
||
def read_bridge_id(bridge_dir: Path) -> str | None:
|
||
"""
|
||
Read the opaque bridge id from bridge config.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:returns: Opaque bridge id, e.g. ``"bridge_abc123"``, or
|
||
``None`` when the bridge config is absent or malformed.
|
||
"""
|
||
config = _read_json_file(bridge_dir / _CONFIG_FILE)
|
||
if not isinstance(config, dict):
|
||
return None
|
||
bridge_id = config.get("bridge_id")
|
||
return bridge_id if isinstance(bridge_id, str) and bridge_id else None
|
||
|
||
|
||
def write_active_session_id(bridge_dir: Path, session_id: str) -> None:
|
||
"""
|
||
Atomically update the bridge's active Omnigent session.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:param session_id: New active Omnigent session id, e.g.
|
||
``"conv_abc123"``.
|
||
:returns: None.
|
||
:raises RuntimeError: If the bridge config does not exist.
|
||
"""
|
||
config = _read_json_file(bridge_dir / _CONFIG_FILE)
|
||
if not config:
|
||
raise RuntimeError(f"bridge config missing: {bridge_dir / _CONFIG_FILE}")
|
||
config["active_session_id"] = session_id
|
||
config["conversation_id"] = session_id
|
||
config["updated_at"] = time.time()
|
||
_write_json_file(bridge_dir / _CONFIG_FILE, config)
|
||
|
||
|
||
def read_permission_hook_config(bridge_dir: Path) -> dict[str, Any]:
|
||
"""
|
||
Read Omnigent routing details for the permission command hook.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:returns: Permission hook config, e.g.
|
||
``{"ap_server_url": "http://127.0.0.1:8787",
|
||
"ap_auth_headers": {"Authorization": "Bearer token"}}``.
|
||
Empty dict when the file is absent or malformed.
|
||
"""
|
||
payload = _read_json_file(bridge_dir / _PERMISSION_HOOK_FILE)
|
||
return payload if isinstance(payload, dict) else {}
|
||
|
||
|
||
def build_mcp_config(bridge_dir: Path, *, python_executable: str | None = None) -> dict[str, Any]:
|
||
"""
|
||
Build the Claude Code MCP config for the Omnigent bridge server.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:param python_executable: Python executable to run, e.g.
|
||
``"/path/to/.venv/bin/python"``. ``None`` uses
|
||
:data:`sys.executable`.
|
||
:returns: JSON-serializable Claude MCP config.
|
||
"""
|
||
python = python_executable or sys.executable
|
||
return {
|
||
"mcpServers": {
|
||
_MCP_SERVER_NAME: {
|
||
"command": python,
|
||
"args": [
|
||
"-I",
|
||
"-m",
|
||
"omnigent.claude_native_bridge",
|
||
"serve-mcp",
|
||
"--bridge-dir",
|
||
str(bridge_dir),
|
||
],
|
||
"env": {
|
||
"PYTHONUNBUFFERED": "1",
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
def build_hook_settings(
|
||
bridge_dir: Path,
|
||
*,
|
||
python_executable: str | None = None,
|
||
ap_server_url: str | None = None,
|
||
ap_auth_headers: dict[str, str] | None = None,
|
||
api_key_helper: str | None = None,
|
||
launch_model: str | None = None,
|
||
launch_permission_mode: str | None = None,
|
||
launch_effort: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""
|
||
Build invocation-local Claude Code hook settings.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:param python_executable: Python executable to run, e.g.
|
||
``"/path/to/.venv/bin/python"``. ``None`` uses
|
||
:data:`sys.executable`.
|
||
:param ap_server_url: Omnigent server base URL the ``PermissionRequest``
|
||
command hook should POST to, e.g. ``"http://127.0.0.1:8787"``.
|
||
When ``None``, no ``PermissionRequest`` hook is registered and
|
||
Claude falls back to its built-in TUI permission prompt.
|
||
:param ap_auth_headers: Headers to send with the
|
||
``PermissionRequest`` command hook, e.g.
|
||
``{"Authorization": "Bearer <token>"}``. Stored in the
|
||
owner-only bridge directory instead of in hook argv.
|
||
:param api_key_helper: Optional Claude Code ``apiKeyHelper``
|
||
command from ucode state, e.g. ``"databricks auth token
|
||
--host https://example.databricks.com ..."``.
|
||
:param launch_model: Effective launch model from ``--model``. Mirrored
|
||
into the invocation-local settings sidecar so a wrapped Claude Code
|
||
re-exec that preserves ``--settings`` but rebuilds argv cannot fall
|
||
back to the user's global default model.
|
||
:param launch_permission_mode: Effective launch permission mode from
|
||
``--permission-mode``. Mirrored into ``permissions.defaultMode``
|
||
for the same re-exec hardening.
|
||
:param launch_effort: Effective launch effort from ``--effort``.
|
||
Mirrored into ``effortLevel`` for restart/re-exec parity.
|
||
:returns: JSON-serializable Claude settings fragment.
|
||
"""
|
||
python = python_executable or sys.executable
|
||
# -I (isolated mode) prevents Python from adding the session's
|
||
# working directory to sys.path, which would shadow the installed
|
||
# omnigent package with a local checkout in the cwd (e.g. a
|
||
# git worktree that has its own omnigent/ directory on a
|
||
# different branch).
|
||
command_parts = [
|
||
python,
|
||
"-I",
|
||
"-m",
|
||
"omnigent.claude_native_hook",
|
||
"--bridge-dir",
|
||
str(bridge_dir),
|
||
]
|
||
command = shlex.join(command_parts)
|
||
hook = {"type": "command", "command": command}
|
||
session_start_hook = {
|
||
"type": "command",
|
||
"command": command,
|
||
}
|
||
# ``MessageDisplay`` fires once per streamed assistant-text chunk and
|
||
# Claude blocks on the hook, so it gets a dedicated stdlib-only
|
||
# appender module instead of the heavier observer ``hook`` above —
|
||
# the per-chunk subprocess must stay cheap. It just appends the
|
||
# chunk to ``<bridge_dir>/message_deltas.jsonl``; the forwarder tails
|
||
# that file and publishes ``response.output_text.delta`` events.
|
||
message_display_command_parts = [
|
||
python,
|
||
"-I",
|
||
"-m",
|
||
"omnigent.claude_native_message_display_hook",
|
||
"--bridge-dir",
|
||
str(bridge_dir),
|
||
]
|
||
message_display_hook = {
|
||
"type": "command",
|
||
"command": shlex.join(message_display_command_parts),
|
||
}
|
||
hooks: dict[str, Any] = {
|
||
"SessionStart": [{"hooks": [session_start_hook]}],
|
||
"Stop": [{"hooks": [hook]}],
|
||
"StopFailure": [{"hooks": [hook]}],
|
||
# ``UserPromptSubmit`` is the symmetric counterpart to
|
||
# ``Stop`` — fires when a new user prompt reaches Claude
|
||
# (web-UI message via tmux send-keys, or direct keystrokes
|
||
# into the embedded terminal). The transcript forwarder
|
||
# translates it into ``session.status: running``.
|
||
"UserPromptSubmit": [{"hooks": [hook]}],
|
||
# ``TaskCreated`` fires when Claude creates a new native task
|
||
# (shown with ``□`` in the TUI). The payload carries ``task_id``
|
||
# and ``task_subject``; the forwarder converts all current tasks
|
||
# into a ``session.todos`` SSE event so the web UI can display
|
||
# the task checklist.
|
||
"TaskCreated": [{"hooks": [hook]}],
|
||
# ``TaskCompleted`` fires when Claude marks a native task done
|
||
# (``■`` in the TUI). The payload carries ``task_id`` so the
|
||
# forwarder can flip that task's status to ``"completed"``.
|
||
"TaskCompleted": [{"hooks": [hook]}],
|
||
# ``PostToolUse`` filtered to ``TodoWrite`` fires whenever Claude
|
||
# updates its simple todo list. The hook payload carries the new
|
||
# todos under ``tool_input.todos``.
|
||
# ``PostToolUse`` filtered to ``TaskUpdate`` fires when Claude
|
||
# calls ``TaskUpdate`` to change a native task's status (e.g.
|
||
# to ``"in_progress"``). The payload carries ``tool_input.taskId``
|
||
# and ``tool_input.status``.
|
||
"PostToolUse": [
|
||
{"matcher": "TodoWrite", "hooks": [hook]},
|
||
{"matcher": "TaskUpdate", "hooks": [hook]},
|
||
],
|
||
# ``PreCompact`` fires right before Claude compacts its own
|
||
# context — for both a manual ``/compact`` (web-UI button or
|
||
# typed) and an automatic context-overflow compaction. The
|
||
# forwarder translates it into a
|
||
# ``response.compaction.in_progress`` SSE so the web UI shows
|
||
# its "Compacting conversation…" spinner while Claude runs the
|
||
# real compaction in the terminal. The matching completion
|
||
# signal is ``SessionStart`` with ``source == "compact"`` (no
|
||
# dedicated PreCompact-done hook exists), already wired above.
|
||
"PreCompact": [{"hooks": [hook]}],
|
||
# ``MessageDisplay`` fires per streamed assistant-text chunk.
|
||
# Routed to the dedicated fast appender so the forwarder can
|
||
# publish live token deltas to the web UI.
|
||
"MessageDisplay": [{"hooks": [message_display_hook]}],
|
||
}
|
||
if ap_server_url:
|
||
_write_json_file(
|
||
bridge_dir / _PERMISSION_HOOK_FILE,
|
||
{
|
||
"ap_server_url": ap_server_url,
|
||
"ap_auth_headers": ap_auth_headers or {},
|
||
"updated_at": time.time(),
|
||
},
|
||
)
|
||
# ``PermissionRequest`` fires only when Claude is about to
|
||
# show its TUI permission prompt — that's exactly the
|
||
# interception point we want for routing to the web UI.
|
||
# Route through a command hook instead of baking a session id
|
||
# into an HTTP URL at Claude launch. The subprocess reads the
|
||
# current active session from bridge.json for every permission
|
||
# request, so approvals follow `/clear` rotations without
|
||
# restarting Claude.
|
||
permission_command_parts = [
|
||
python,
|
||
"-I",
|
||
"-m",
|
||
"omnigent.claude_native_hook",
|
||
"permission-request",
|
||
"--bridge-dir",
|
||
str(bridge_dir),
|
||
]
|
||
permission_hook: dict[str, Any] = {
|
||
"type": "command",
|
||
"command": shlex.join(permission_command_parts),
|
||
# Wait up to a day for the verdict. Claude Code's default
|
||
# command-hook timeout (~60s) would otherwise kill the hook
|
||
# subprocess long before the user answers, putting the
|
||
# prompt back in the TUI and flipping the web card to
|
||
# "Resolved elsewhere". A day is effectively wait-forever
|
||
# for an interactive permission prompt; it stays in lockstep
|
||
# with the subprocess/AP-side budgets so none caps first.
|
||
"timeout": 86400,
|
||
}
|
||
hooks["PermissionRequest"] = [{"hooks": [permission_hook]}]
|
||
|
||
# Policy-gate native Claude Code tools, not just relay/MCP tools.
|
||
evaluate_policy_command_parts = [
|
||
python,
|
||
"-I",
|
||
"-m",
|
||
"omnigent.claude_native_hook",
|
||
"evaluate-policy",
|
||
"--bridge-dir",
|
||
str(bridge_dir),
|
||
]
|
||
evaluate_policy_hook: dict[str, Any] = {
|
||
"type": "command",
|
||
"command": shlex.join(evaluate_policy_command_parts),
|
||
}
|
||
# In bypassPermissions mode PermissionRequest never fires, so
|
||
# AskUserQuestion needs its own PreToolUse hook to surface the
|
||
# form. It's a no-op in other modes to avoid double-surfacing.
|
||
ask_uq_command_parts = [
|
||
python,
|
||
"-I",
|
||
"-m",
|
||
"omnigent.claude_native_hook",
|
||
"ask-user-question",
|
||
"--bridge-dir",
|
||
str(bridge_dir),
|
||
]
|
||
ask_uq_hook: dict[str, Any] = {
|
||
"type": "command",
|
||
"command": shlex.join(ask_uq_command_parts),
|
||
# Short timeout: if the web-UI elicitation isn't answered
|
||
# within 10s, the hook returns empty output so Claude falls
|
||
# through to its TUI picker in bypassPermissions mode. In
|
||
# default mode this hook exits immediately (no-op), so the
|
||
# timeout is irrelevant there.
|
||
"timeout": 10,
|
||
}
|
||
# The ``AskUserQuestion`` matcher only fires if that tool is actually
|
||
# callable. A session launched with ``--disallowedTools AskUserQuestion``
|
||
# (e.g. the exit-plan-mode e2e fixture) can never trigger this hook, so
|
||
# the registration is dormant there — harmless, just never reached.
|
||
hooks["PreToolUse"] = [
|
||
{"matcher": "AskUserQuestion", "hooks": [ask_uq_hook]},
|
||
{"hooks": [evaluate_policy_hook]},
|
||
]
|
||
# PostToolUse already has TodoWrite and TaskUpdate matchers
|
||
# for the transcript forwarder (the observer ``hook``). Append
|
||
# a catch-all policy evaluation entry so TOOL_RESULT policies
|
||
# fire for all tools, not just the forwarder-specific ones.
|
||
hooks["PostToolUse"].append({"hooks": [evaluate_policy_hook]})
|
||
# UserPromptSubmit already carries the transcript forwarder's
|
||
# status hook (running). Append the policy hook so REQUEST-phase
|
||
# policies gate native prompts — for native sessions this is the
|
||
# sole request gate (the server-level ``_evaluate_input_policy``
|
||
# skips native message events). A DENY emits ``decision: "block"``,
|
||
# dropping the prompt before the model sees it; ASK is resolved
|
||
# server-side. Covers both web-UI-injected and direct-terminal
|
||
# prompts, since both fire UserPromptSubmit.
|
||
hooks["UserPromptSubmit"].append({"hooks": [evaluate_policy_hook]})
|
||
settings: dict[str, Any] = {"hooks": hooks}
|
||
if launch_model:
|
||
settings["model"] = launch_model
|
||
if launch_permission_mode:
|
||
settings["permissions"] = {"defaultMode": launch_permission_mode}
|
||
if launch_effort and launch_effort in CLAUDE_EFFORTS:
|
||
settings["effortLevel"] = launch_effort
|
||
if api_key_helper:
|
||
settings["apiKeyHelper"] = api_key_helper
|
||
# Override Claude Code's statusLine so we receive its stdin (the
|
||
# only place ``context_window`` surfaces). Chain to whatever the
|
||
# user had globally so claude-hud / their bar still renders.
|
||
status_parts = [
|
||
python,
|
||
"-I",
|
||
"-m",
|
||
"omnigent.claude_native_status",
|
||
"--bridge-dir",
|
||
str(bridge_dir),
|
||
]
|
||
chain_command = read_user_status_line_command()
|
||
if chain_command is not None:
|
||
status_parts.extend(["--chain", chain_command])
|
||
settings["statusLine"] = {"type": "command", "command": shlex.join(status_parts)}
|
||
return settings
|
||
|
||
|
||
def url_component(value: str) -> str:
|
||
"""
|
||
Percent-encode one URL path component.
|
||
|
||
:param value: Raw path component, e.g. ``"conv_abc123"``.
|
||
:returns: URL-safe component with slashes escaped.
|
||
"""
|
||
return urllib.parse.quote(value, safe="")
|
||
|
||
|
||
# Built-in Claude Code tools that need their own custom UI to be
|
||
# usable from the web chat. Until we ship that UI, disable them so
|
||
# Claude falls back to plain assistant text + a normal user reply,
|
||
# which already round-trips through the existing chat-input pipeline.
|
||
#
|
||
# Currently empty: ``AskUserQuestion`` routes through a dedicated
|
||
# ``PreToolUse`` hook (registered in ``build_hook_settings``) that
|
||
# surfaces the question + options to the web UI as an elicitation
|
||
# form and injects the user's answer via ``updatedInput``, and
|
||
# ``ExitPlanMode`` surfaces through the standard ``PermissionRequest``
|
||
# hook as an approve/reject elicitation card.
|
||
_OMNIGENT_DISALLOWED_TOOLS: tuple[str, ...] = ()
|
||
|
||
|
||
def augment_claude_args(
|
||
claude_args: tuple[str, ...],
|
||
*,
|
||
bridge_dir: Path,
|
||
python_executable: str | None = None,
|
||
ap_server_url: str | None = None,
|
||
ap_auth_headers: dict[str, str] | None = None,
|
||
api_key_helper: str | None = None,
|
||
bundle_dir: Path | None = None,
|
||
agent_name: str | None = None,
|
||
skills_filter: str | list[str] = "all",
|
||
) -> list[str]:
|
||
"""
|
||
Return Claude CLI args with Omnigent MCP/hook/skill injection.
|
||
|
||
:param claude_args: User-provided Claude Code args, e.g.
|
||
``("--resume", "abc")``.
|
||
:param bridge_dir: Bridge directory path.
|
||
:param python_executable: Python executable to run helper
|
||
modules. ``None`` uses :data:`sys.executable`.
|
||
:param ap_server_url: Omnigent server base URL passed through to
|
||
:func:`build_hook_settings` so the ``PermissionRequest``
|
||
command hook is registered. ``None`` omits the hook and
|
||
Claude falls back to its built-in TUI prompt.
|
||
:param ap_auth_headers: Auth headers for the
|
||
``PermissionRequest`` command hook. Passed through to
|
||
:func:`build_hook_settings`.
|
||
:param api_key_helper: Optional Claude Code ``apiKeyHelper``
|
||
command from ucode state, e.g. ``"databricks auth token
|
||
--host https://example.databricks.com ..."``.
|
||
:param bundle_dir: Materialized agent-bundle root, when the
|
||
session's agent ships a ``skills/`` directory. Triggers
|
||
``--plugin-dir <bundle>`` so Claude Code discovers bundled
|
||
skills natively — the CLI mirror of the SDK executor's plugin
|
||
wiring. ``None`` (e.g. the ``omnigent claude`` CLI's minimal
|
||
spec) adds no plugin args.
|
||
:param agent_name: Agent display name for the bundle's plugin
|
||
manifest, e.g. ``"researcher"``. ``None`` falls back to the
|
||
bundle directory's basename.
|
||
:param skills_filter: The agent spec's ``skills_filter`` (``"all"``
|
||
/ ``"none"`` / list of skill names), mapped to
|
||
``--setting-sources`` exactly as the SDK executor maps it onto
|
||
``setting_sources``. Defaults to ``"all"``.
|
||
:returns: Augmented argument list for the terminal resource.
|
||
"""
|
||
mcp_config = build_mcp_config(bridge_dir, python_executable=python_executable)
|
||
hook_settings = build_hook_settings(
|
||
bridge_dir,
|
||
python_executable=python_executable,
|
||
ap_server_url=ap_server_url,
|
||
ap_auth_headers=ap_auth_headers,
|
||
api_key_helper=api_key_helper,
|
||
launch_model=_arg_value(claude_args, "--model"),
|
||
launch_permission_mode=_arg_value(claude_args, "--permission-mode"),
|
||
launch_effort=_arg_value(claude_args, "--effort"),
|
||
)
|
||
args = _merge_disallowed_tools(list(claude_args), _OMNIGENT_DISALLOWED_TOOLS)
|
||
args.extend(
|
||
[
|
||
"--mcp-config",
|
||
json.dumps(mcp_config, separators=(",", ":")),
|
||
"--settings",
|
||
json.dumps(hook_settings, separators=(",", ":")),
|
||
]
|
||
)
|
||
args.extend(
|
||
claude_native_skill_args(
|
||
bundle_dir,
|
||
agent_name=agent_name,
|
||
skills_filter=skills_filter,
|
||
)
|
||
)
|
||
return args
|
||
|
||
|
||
def _arg_value(args: tuple[str, ...], flag: str) -> str | None:
|
||
"""Return the effective CLI flag value from ``args``.
|
||
|
||
Supports both ``--flag value`` and ``--flag=value`` spellings. When a
|
||
flag appears more than once, the last valid occurrence wins, matching the
|
||
usual CLI precedence for repeated long options.
|
||
|
||
:param args: Claude CLI args, e.g. ``("--model", "sonnet")``.
|
||
:param flag: Long flag to read, e.g. ``"--model"``.
|
||
:returns: The flag value, or ``None`` when absent/empty.
|
||
"""
|
||
joined_prefix = f"{flag}="
|
||
value: str | None = None
|
||
for idx, arg in enumerate(args):
|
||
if arg.startswith(joined_prefix):
|
||
candidate = arg[len(joined_prefix) :]
|
||
if candidate:
|
||
value = candidate
|
||
continue
|
||
if arg == flag and idx + 1 < len(args):
|
||
candidate = args[idx + 1]
|
||
if candidate and not candidate.startswith("--"):
|
||
value = candidate
|
||
return value
|
||
|
||
|
||
def _merge_disallowed_tools(args: list[str], extra: tuple[str, ...]) -> list[str]:
|
||
"""
|
||
Add ``extra`` tool names to a ``--disallowedTools`` flag in ``args``.
|
||
|
||
Merges into an existing flag if present (deduping while preserving
|
||
order) so a user-supplied ``--disallowedTools`` is not silently
|
||
overridden; otherwise appends a new flag.
|
||
|
||
:param args: Claude CLI argument list to mutate-and-return.
|
||
:param extra: Tool names Omnigent wants disabled.
|
||
:returns: ``args`` with the merged flag.
|
||
"""
|
||
if not extra:
|
||
return args
|
||
try:
|
||
idx = args.index("--disallowedTools")
|
||
except ValueError:
|
||
args.extend(["--disallowedTools", ",".join(extra)])
|
||
return args
|
||
value_idx = idx + 1
|
||
if value_idx >= len(args):
|
||
return args
|
||
existing = [t for t in args[value_idx].split(",") if t]
|
||
args[value_idx] = ",".join(dict.fromkeys([*existing, *extra]))
|
||
return args
|
||
|
||
|
||
def record_hook_event(bridge_dir: Path, payload: dict[str, Any]) -> None:
|
||
"""
|
||
Record one Claude Code hook payload in the bridge directory.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:param payload: Hook JSON object read from Claude Code stdin,
|
||
e.g. ``{"hook_event_name": "Stop", "transcript_path":
|
||
"/home/user/.claude/projects/x/session.jsonl"}``.
|
||
:returns: None.
|
||
"""
|
||
_ensure_secure_dir(bridge_dir)
|
||
envelope = {
|
||
"recorded_at": time.time(),
|
||
"payload": payload,
|
||
}
|
||
with (bridge_dir / _HOOKS_FILE).open("a", encoding="utf-8") as handle:
|
||
handle.write(json.dumps(envelope, separators=(",", ":")) + "\n")
|
||
|
||
state = _read_json_file(bridge_dir / _STATE_FILE)
|
||
if not isinstance(state, dict):
|
||
state = {}
|
||
event_name = payload.get("hook_event_name")
|
||
if isinstance(event_name, str) and event_name:
|
||
state["last_hook_event_name"] = event_name
|
||
transcript_path = payload.get("transcript_path")
|
||
if isinstance(transcript_path, str) and transcript_path:
|
||
state["transcript_path"] = transcript_path
|
||
claude_session_id = payload.get("session_id")
|
||
if isinstance(claude_session_id, str) and claude_session_id:
|
||
state["claude_session_id"] = claude_session_id
|
||
seen = read_seen_claude_session_ids(bridge_dir)
|
||
seen.add(claude_session_id)
|
||
state["seen_claude_session_ids"] = sorted(seen)
|
||
state["updated_at"] = time.time()
|
||
_write_json_file(bridge_dir / _STATE_FILE, state)
|
||
|
||
|
||
def read_transcript_path(bridge_dir: Path) -> Path | None:
|
||
"""
|
||
Return the transcript path last reported by Claude hooks.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:returns: Transcript path, or ``None`` when hooks have not
|
||
reported one yet.
|
||
"""
|
||
state = _read_json_file(bridge_dir / _STATE_FILE)
|
||
raw = state.get("transcript_path") if isinstance(state, dict) else None
|
||
if not isinstance(raw, str) or not raw:
|
||
return None
|
||
return Path(raw)
|
||
|
||
|
||
def read_claude_session_id(bridge_dir: Path) -> str | None:
|
||
"""
|
||
Return the Claude-native session id captured from hook events.
|
||
|
||
Set by :func:`record_hook_event` whenever a hook payload carries
|
||
a ``session_id`` field (every event Claude Code emits does).
|
||
Wrapper code reads it back to mirror the value into AP-side
|
||
conversation state (e.g. ``external_session_id`` on the
|
||
``conversations`` row) so ``--resume`` can recover the prior
|
||
Claude transcript on a fresh runner without the user having to
|
||
know Claude's own id.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:returns: Claude session uuid string,
|
||
e.g. ``"a1b2c3d4-1234-5678-9abc-def012345678"``, or ``None``
|
||
when no hook has yet reported one (the first poll after a
|
||
cold launch).
|
||
"""
|
||
state = _read_json_file(bridge_dir / _STATE_FILE)
|
||
raw = state.get("claude_session_id") if isinstance(state, dict) else None
|
||
if not isinstance(raw, str) or not raw:
|
||
return None
|
||
return raw
|
||
|
||
|
||
def read_seen_claude_session_ids(bridge_dir: Path) -> set[str]:
|
||
"""
|
||
Return Claude session ids already observed by this bridge.
|
||
|
||
The set is transient local bridge state. It lets the hook
|
||
distinguish Claude-created branch/fork session switches from
|
||
ordinary resumes into sessions the wrapper already saw.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:returns: Claude session uuid strings, e.g.
|
||
``{"a1b2c3d4-1234-5678-9abc-def012345678"}``.
|
||
"""
|
||
state = _read_json_file(bridge_dir / _STATE_FILE)
|
||
if not isinstance(state, dict):
|
||
return set()
|
||
seen: set[str] = set()
|
||
raw_seen = state.get("seen_claude_session_ids")
|
||
if isinstance(raw_seen, list):
|
||
seen.update(value for value in raw_seen if isinstance(value, str) and value)
|
||
raw_current = state.get("claude_session_id")
|
||
if isinstance(raw_current, str) and raw_current:
|
||
seen.add(raw_current)
|
||
return seen
|
||
|
||
|
||
def count_transcript_lines(transcript_path: Path) -> int:
|
||
"""
|
||
Count JSONL records currently present in a Claude transcript.
|
||
|
||
:param transcript_path: Claude transcript path, e.g.
|
||
``"/home/user/.claude/projects/x/session.jsonl"``.
|
||
:returns: Number of newline-delimited records. Missing files
|
||
count as zero.
|
||
"""
|
||
try:
|
||
with transcript_path.open("r", encoding="utf-8") as handle:
|
||
return sum(1 for _line in handle)
|
||
except FileNotFoundError:
|
||
return 0
|
||
|
||
|
||
def transcript_has_recent_local_command(
|
||
transcript_path: Path,
|
||
*,
|
||
claude_session_id: str,
|
||
recorded_at: float,
|
||
command_names: frozenset[str],
|
||
) -> bool:
|
||
"""
|
||
Return whether Claude recently recorded one local command.
|
||
|
||
:param transcript_path: Claude transcript JSONL path, e.g.
|
||
``"/home/user/.claude/projects/x/session.jsonl"``.
|
||
:param claude_session_id: Claude-native session uuid from the
|
||
hook payload, e.g. ``"a1b2c3d4-1234-5678-9abc-def012345678"``.
|
||
:param recorded_at: Unix timestamp for the hook record,
|
||
e.g. ``1779922393.222``.
|
||
:param command_names: Slash-command names to match, including
|
||
the leading slash, e.g. ``frozenset({"/fork", "/branch"})``.
|
||
:returns: ``True`` when a matching ``local_command`` transcript
|
||
record exists near ``recorded_at`` for ``claude_session_id``.
|
||
"""
|
||
try:
|
||
lines = transcript_path.read_text(encoding="utf-8").splitlines()
|
||
except FileNotFoundError:
|
||
return False
|
||
for line in lines[-_RECENT_LOCAL_COMMAND_LINE_LIMIT:]:
|
||
try:
|
||
record = json.loads(line)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
if not isinstance(record, dict):
|
||
continue
|
||
if record.get("sessionId") != claude_session_id:
|
||
continue
|
||
if record.get("subtype") != "local_command":
|
||
continue
|
||
timestamp = _transcript_timestamp(record.get("timestamp"))
|
||
if timestamp is None or abs(timestamp - recorded_at) > _RECENT_LOCAL_COMMAND_WINDOW_S:
|
||
continue
|
||
content = record.get("content")
|
||
if not isinstance(content, str):
|
||
continue
|
||
command_name = _local_command_name(content)
|
||
if command_name in command_names:
|
||
return True
|
||
return False
|
||
|
||
|
||
def transcript_has_forked_from_marker(
|
||
transcript_path: Path,
|
||
*,
|
||
claude_session_id: str,
|
||
source_claude_session_id: str | None,
|
||
) -> bool:
|
||
"""
|
||
Return whether Claude marked a transcript as a fork.
|
||
|
||
Claude branch/fork transcripts carry structured ``forkedFrom``
|
||
metadata on copied records. This is the stable non-title signal
|
||
that a ``SessionStart source=resume`` event represents a new
|
||
branch rather than an ordinary resume.
|
||
|
||
:param transcript_path: Claude transcript JSONL path, e.g.
|
||
``"/home/user/.claude/projects/x/session.jsonl"``.
|
||
:param claude_session_id: New Claude-native session uuid from the
|
||
hook payload, e.g. ``"a1b2c3d4-1234-5678-9abc-def012345678"``.
|
||
:param source_claude_session_id: Expected source Claude session
|
||
uuid, e.g. ``"9abc..."``. ``None`` accepts any different
|
||
non-empty source id.
|
||
:returns: ``True`` when the transcript records a fork from the
|
||
expected source session.
|
||
"""
|
||
try:
|
||
lines = transcript_path.read_text(encoding="utf-8").splitlines()
|
||
except FileNotFoundError:
|
||
return False
|
||
for line in _sample_transcript_edges(lines, _FORKED_FROM_LINE_LIMIT):
|
||
try:
|
||
record = json.loads(line)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
if not isinstance(record, dict):
|
||
continue
|
||
if record.get("sessionId") != claude_session_id:
|
||
continue
|
||
forked_from = record.get("forkedFrom")
|
||
if not isinstance(forked_from, dict):
|
||
continue
|
||
raw_source_session_id = forked_from.get("sessionId")
|
||
if not isinstance(raw_source_session_id, str) or not raw_source_session_id:
|
||
continue
|
||
if raw_source_session_id == claude_session_id:
|
||
continue
|
||
if (
|
||
source_claude_session_id is not None
|
||
and raw_source_session_id != source_claude_session_id
|
||
):
|
||
continue
|
||
return True
|
||
return False
|
||
|
||
|
||
def _sample_transcript_edges(lines: list[str], limit: int) -> list[str]:
|
||
"""
|
||
Return transcript lines from the start and end of a file.
|
||
|
||
:param lines: Transcript JSONL lines.
|
||
:param limit: Maximum number of lines to take from each edge,
|
||
e.g. ``200``.
|
||
:returns: Sampled lines, preserving file order.
|
||
"""
|
||
if limit <= 0 or len(lines) <= limit * 2:
|
||
return lines
|
||
return [*lines[:limit], *lines[-limit:]]
|
||
|
||
|
||
def _transcript_timestamp(value: object) -> float | None:
|
||
"""
|
||
Parse a Claude transcript timestamp.
|
||
|
||
:param value: Timestamp string, e.g.
|
||
``"2026-05-27T22:53:13.245Z"``.
|
||
:returns: Unix timestamp, e.g. ``1779922393.245``, or ``None``
|
||
when parsing fails.
|
||
"""
|
||
if not isinstance(value, str) or not value:
|
||
return None
|
||
try:
|
||
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _local_command_name(content: str) -> str | None:
|
||
"""
|
||
Extract a Claude local command name from transcript content.
|
||
|
||
:param content: Local-command transcript content, e.g.
|
||
``"<command-name>/fork</command-name>"``.
|
||
:returns: Command name including leading slash, e.g.
|
||
``"/fork"``, or ``None`` when no command tag exists.
|
||
"""
|
||
name_match = _COMMAND_NAME_RE.search(content)
|
||
if name_match is None:
|
||
return None
|
||
name = name_match.group(1).strip()
|
||
return name or None
|
||
|
||
|
||
def read_assistant_text_since(
|
||
transcript_path: Path,
|
||
start_line: int,
|
||
) -> tuple[int, list[str]]:
|
||
"""
|
||
Read assistant text blocks appended after a transcript cursor.
|
||
|
||
:param transcript_path: Claude transcript path, e.g.
|
||
``"/home/user/.claude/projects/x/session.jsonl"``.
|
||
:param start_line: Zero-based line cursor captured before a
|
||
message is injected into the Claude terminal.
|
||
:returns: ``(new_cursor, text_chunks)``.
|
||
"""
|
||
texts: list[str] = []
|
||
cursor = 0
|
||
try:
|
||
with transcript_path.open("r", encoding="utf-8") as handle:
|
||
for cursor, line in enumerate(handle, start=1):
|
||
if cursor <= start_line:
|
||
continue
|
||
text = _assistant_text_from_transcript_line(line)
|
||
if text:
|
||
texts.append(text)
|
||
except FileNotFoundError:
|
||
return start_line, []
|
||
return cursor, texts
|
||
|
||
|
||
def read_transcript_items_since(
|
||
transcript_path: Path,
|
||
start_line: int,
|
||
*,
|
||
agent_name: str,
|
||
current_response_id: str | None = None,
|
||
) -> tuple[int, str | None, list[ClaudeTranscriptItem]]:
|
||
"""
|
||
Read Claude transcript records as Omnigent conversation items.
|
||
|
||
Claude Code writes append-only JSONL records whose ``message``
|
||
payloads include user prompts, assistant text, native tool calls,
|
||
and native tool results. This parser intentionally ignores
|
||
metadata records (title, file-history, permission mode, system
|
||
bookkeeping) and raw ``thinking`` blocks, while translating the
|
||
user-visible semantic records into Omnigent item types the web UI
|
||
already understands.
|
||
|
||
:param transcript_path: Claude transcript path, e.g.
|
||
``"/home/user/.claude/projects/x/session.jsonl"``.
|
||
:param start_line: One-based line cursor. Lines at or before
|
||
this cursor are skipped.
|
||
:param agent_name: Agent/model name stamped on assistant and
|
||
tool-call items, e.g. ``"claude-native-ui"``.
|
||
:param current_response_id: Response id for an in-progress
|
||
Claude assistant turn from a previous poll.
|
||
:returns: ``(new_cursor, current_response_id, items)``.
|
||
"""
|
||
result = read_transcript_items_since_with_position(
|
||
transcript_path,
|
||
start_line,
|
||
agent_name=agent_name,
|
||
current_response_id=current_response_id,
|
||
)
|
||
return result.line_cursor, result.current_response_id, result.items
|
||
|
||
|
||
def read_transcript_items_since_with_position(
|
||
transcript_path: Path,
|
||
start_line: int,
|
||
*,
|
||
agent_name: str,
|
||
current_response_id: str | None = None,
|
||
) -> TranscriptReadResult:
|
||
"""
|
||
Read transcript items from a line cursor and return byte position.
|
||
|
||
This compatibility reader supports existing durable state that
|
||
only stored a line cursor. It scans the file once, parses only
|
||
complete newline-terminated records after ``start_line``, and
|
||
returns the byte offset so future polls can seek directly.
|
||
|
||
:param transcript_path: Claude transcript path, e.g.
|
||
``"/home/user/.claude/projects/x/session.jsonl"``.
|
||
:param start_line: One-based line cursor. Lines at or before
|
||
this cursor are skipped.
|
||
:param agent_name: Agent/model name stamped on assistant and
|
||
tool-call items, e.g. ``"claude-native-ui"``.
|
||
:param current_response_id: Response id for an in-progress
|
||
Claude assistant turn from a previous poll.
|
||
:returns: Parsed items plus line and byte cursors.
|
||
"""
|
||
read_result = _read_complete_jsonl_records(
|
||
transcript_path,
|
||
byte_offset=0,
|
||
start_line=0,
|
||
emit_after_line=start_line,
|
||
)
|
||
items: list[ClaudeTranscriptItem] = []
|
||
active_response_id = current_response_id
|
||
latest_usage: dict[str, int] | None = None
|
||
latest_model: str | None = None
|
||
for record in read_result.records:
|
||
if record.text is None:
|
||
continue
|
||
try:
|
||
entry = json.loads(record.text)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
active_response_id, parsed = _transcript_items_from_entry(
|
||
entry,
|
||
line_number=record.line_number,
|
||
record_offset=None,
|
||
agent_name=agent_name,
|
||
current_response_id=active_response_id,
|
||
)
|
||
items.extend(parsed)
|
||
usage = _usage_from_transcript_entry(entry)
|
||
if usage is not None:
|
||
latest_usage = usage
|
||
model = _model_from_transcript_entry(entry)
|
||
if model is not None:
|
||
latest_model = model
|
||
return TranscriptReadResult(
|
||
line_cursor=read_result.line_cursor,
|
||
byte_offset=read_result.byte_offset,
|
||
current_response_id=active_response_id,
|
||
items=items,
|
||
latest_usage=latest_usage,
|
||
latest_model=latest_model,
|
||
)
|
||
|
||
|
||
def read_transcript_items_from_offset(
|
||
transcript_path: Path,
|
||
byte_offset: int,
|
||
*,
|
||
start_line: int,
|
||
agent_name: str,
|
||
current_response_id: str | None = None,
|
||
include_sidechains: bool = False,
|
||
) -> TranscriptReadResult:
|
||
"""
|
||
Read transcript items appended after a byte offset.
|
||
|
||
Only complete newline-terminated JSONL records are parsed. If
|
||
Claude is midway through writing a trailing JSON record, the
|
||
returned byte offset remains before that partial line so the next
|
||
poll retries it after completion.
|
||
|
||
:param transcript_path: Claude transcript path, e.g.
|
||
``"/home/user/.claude/projects/x/session.jsonl"``.
|
||
:param byte_offset: Byte offset already consumed, e.g. ``4096``.
|
||
:param start_line: Count of complete records already consumed.
|
||
Used only to keep legacy line cursors and diagnostics
|
||
monotonic while byte offsets drive the actual seek.
|
||
:param agent_name: Agent/model name stamped on assistant and
|
||
tool-call items, e.g. ``"claude-native-ui"``.
|
||
:param current_response_id: Response id for an in-progress
|
||
Claude assistant turn from a previous poll.
|
||
:param include_sidechains: Pass ``True`` when reading a
|
||
sub-agent's own ``agent-<id>.jsonl`` — every record there is
|
||
a sidechain by Claude's definition, and dropping them would
|
||
leave the sub-agent's child Omnigent conversation empty. The
|
||
default ``False`` keeps the parent-transcript path
|
||
unchanged.
|
||
:returns: Parsed items plus updated line and byte cursors.
|
||
"""
|
||
read_result = _read_complete_jsonl_records(
|
||
transcript_path,
|
||
byte_offset=byte_offset,
|
||
start_line=start_line,
|
||
)
|
||
items: list[ClaudeTranscriptItem] = []
|
||
active_response_id = current_response_id
|
||
latest_usage: dict[str, int] | None = None
|
||
latest_model: str | None = None
|
||
for record in read_result.records:
|
||
if record.text is None:
|
||
continue
|
||
try:
|
||
entry = json.loads(record.text)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
active_response_id, parsed = _transcript_items_from_entry(
|
||
entry,
|
||
line_number=record.line_number,
|
||
record_offset=record.byte_offset,
|
||
agent_name=agent_name,
|
||
current_response_id=active_response_id,
|
||
include_sidechains=include_sidechains,
|
||
)
|
||
items.extend(parsed)
|
||
usage = _usage_from_transcript_entry(entry)
|
||
if usage is not None:
|
||
latest_usage = usage
|
||
model = _model_from_transcript_entry(entry)
|
||
if model is not None:
|
||
latest_model = model
|
||
return TranscriptReadResult(
|
||
line_cursor=read_result.line_cursor,
|
||
byte_offset=read_result.byte_offset,
|
||
current_response_id=active_response_id,
|
||
items=items,
|
||
latest_usage=latest_usage,
|
||
latest_model=latest_model,
|
||
)
|
||
|
||
|
||
# Per-model pricing memo for transcript cost computation. Deliberately
|
||
# NOT ``functools.lru_cache``: a transient ``fetch_model_pricing`` failure
|
||
# returns ``None``, and lru_cache would pin that ``None`` for the model's
|
||
# lifetime; this dict stores only successful lookups, so a later poll
|
||
# retries a model whose first lookup failed.
|
||
_TRANSCRIPT_PRICING_CACHE: dict[str, ModelPricing] = {}
|
||
|
||
|
||
def _transcript_model_pricing(model: str) -> ModelPricing | None:
|
||
"""
|
||
Look up per-token pricing for *model*, memoizing successful results.
|
||
|
||
:param model: API model id from a transcript ``message.model``,
|
||
e.g. ``"claude-opus-4-8"`` or ``"databricks-claude-sonnet-4-6"``.
|
||
:returns: The model's :class:`ModelPricing`, or ``None`` when pricing
|
||
is unavailable (network error / model absent from the catalog),
|
||
so the caller skips that message's cost.
|
||
"""
|
||
cached = _TRANSCRIPT_PRICING_CACHE.get(model)
|
||
if cached is not None:
|
||
return cached
|
||
from omnigent.llms.context_window import fetch_model_pricing
|
||
|
||
pricing = fetch_model_pricing(model)
|
||
if pricing is not None:
|
||
_TRANSCRIPT_PRICING_CACHE[model] = pricing
|
||
return pricing
|
||
|
||
|
||
def compute_transcript_cumulative_cost(
|
||
transcript_path: Path,
|
||
*,
|
||
include_sidechains: bool,
|
||
) -> float | None:
|
||
"""
|
||
Sum the USD cost of every assistant message in a Claude transcript.
|
||
|
||
Reads the whole transcript and prices each assistant record's
|
||
``message.usage`` by that record's ``message.model`` (so a
|
||
mid-session ``/model`` switch is billed at the right rate), summing
|
||
the per-message costs. This is the forwarder's *real-time* cost
|
||
estimate for a transcript whose authoritative cumulative cost lags —
|
||
specifically a Task sub-agent's own ``agent-<id>.jsonl``, which has
|
||
no statusLine of its own, so its spend is otherwise invisible to the
|
||
cost-budget policy until the sub-agent finishes.
|
||
|
||
Cost is linear in token counts, so summing per-message costs equals
|
||
pricing the token totals — but per-message pricing also stays correct
|
||
across a model switch within one transcript.
|
||
|
||
**Deduplicated by ``requestId``.** Claude writes more than one
|
||
transcript record for a single API response (a streamed partial plus
|
||
the final record, retries, etc.), and those records share one
|
||
``requestId`` while each carries that response's full ``message.usage``
|
||
(not an increment). Summing every record would bill the same response
|
||
two-plus times — observed ~2x inflation, with the parent badge and the
|
||
cost-budget gate both reading the doubled figure. So records are keyed
|
||
by ``requestId`` (last priceable record per id wins, as its usage is
|
||
the authoritative final figure) and each billed response is counted
|
||
exactly once. A record with no ``requestId`` (rare non-API assistant
|
||
entry) gets a per-record unique key so it is never collapsed with
|
||
another.
|
||
|
||
:param transcript_path: Path to a Claude transcript JSONL, e.g.
|
||
``".../<session>.jsonl"`` (parent) or
|
||
``".../subagents/agent-<id>.jsonl"`` (sub-agent).
|
||
:param include_sidechains: ``False`` for a parent transcript — its
|
||
sub-agent records are inlined as ``isSidechain: true`` and are
|
||
skipped here (they are counted via the sub-agent's own
|
||
transcript) to avoid double-billing. ``True`` for a sub-agent's
|
||
own ``agent-<id>.jsonl``, where every record is a sidechain.
|
||
:returns: Total USD cost across priced assistant messages, or
|
||
``None`` when the transcript has no assistant message that could
|
||
be priced (missing/empty file, no usage, or pricing unavailable
|
||
for every model present) — distinct from ``0.0``, which means
|
||
priced messages summed to zero.
|
||
"""
|
||
read_result = _read_complete_jsonl_records(
|
||
transcript_path,
|
||
byte_offset=0,
|
||
start_line=0,
|
||
)
|
||
from omnigent.llms.context_window import compute_llm_cost
|
||
|
||
# Per-``requestId`` cost (USD); last priceable record per id wins so a
|
||
# response written across multiple transcript records is counted once.
|
||
cost_by_request: dict[str, float] = {}
|
||
# Counter minting unique keys for records lacking a ``requestId`` so
|
||
# they each count once instead of collapsing onto a shared key.
|
||
no_request_id_index = 0
|
||
for record in read_result.records:
|
||
if record.text is None:
|
||
continue
|
||
try:
|
||
entry = json.loads(record.text)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
if not include_sidechains and entry.get("isSidechain") is True:
|
||
continue
|
||
usage = _usage_from_transcript_entry(entry)
|
||
if usage is None:
|
||
continue
|
||
model = _model_from_transcript_entry(entry)
|
||
if model is None:
|
||
continue
|
||
pricing = _transcript_model_pricing(model)
|
||
if pricing is None:
|
||
continue
|
||
request_id = entry.get("requestId")
|
||
if not isinstance(request_id, str) or not request_id:
|
||
request_id = f"__no_request_id_{no_request_id_index}"
|
||
no_request_id_index += 1
|
||
cost_by_request[request_id] = compute_llm_cost(usage, pricing)
|
||
if not cost_by_request:
|
||
return None
|
||
return sum(cost_by_request.values())
|
||
|
||
|
||
def count_hook_events(bridge_dir: Path) -> int:
|
||
"""
|
||
Count hook records currently written for a bridge.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:returns: Number of hook JSONL records.
|
||
"""
|
||
path = bridge_dir / _HOOKS_FILE
|
||
try:
|
||
with path.open("r", encoding="utf-8") as handle:
|
||
return sum(1 for _line in handle)
|
||
except FileNotFoundError:
|
||
return 0
|
||
|
||
|
||
def read_hook_events_since(
|
||
bridge_dir: Path,
|
||
start_event_count: int,
|
||
) -> tuple[int, list[str]]:
|
||
"""
|
||
Read hook event names appended after a hook cursor.
|
||
|
||
The transcript forwarder uses this to publish ``session.status``
|
||
events to Omnigent when Claude Code's ``Stop`` / ``StopFailure`` hooks
|
||
fire — those are the only edges the wrapper can observe between
|
||
Claude becoming idle and the JSONL transcript reflecting it.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:param start_event_count: One-based cursor; lines at or before
|
||
this count are skipped.
|
||
:returns: ``(new_cursor, hook_event_names)`` — ``new_cursor`` is
|
||
the line count after the read, suitable for the next call.
|
||
Malformed lines are skipped silently but still advance the
|
||
cursor so they are not retried indefinitely.
|
||
"""
|
||
result = read_hook_events_since_with_position(bridge_dir, start_event_count)
|
||
names = [record.event_name for record in result.records if record.event_name is not None]
|
||
return result.event_cursor, names
|
||
|
||
|
||
def read_hook_events_since_with_position(
|
||
bridge_dir: Path,
|
||
start_event_count: int,
|
||
) -> HookReadResult:
|
||
"""
|
||
Read hook records from a line cursor and return byte position.
|
||
|
||
This compatibility reader supports existing durable state that
|
||
only stored a hook line cursor. It scans once, returns complete
|
||
records after ``start_event_count``, and reports the byte offset
|
||
so future polls can seek directly.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:param start_event_count: One-based cursor; lines at or before
|
||
this count are skipped.
|
||
:returns: Complete hook records plus updated line and byte cursors.
|
||
"""
|
||
read_result = _read_complete_jsonl_records(
|
||
bridge_dir / _HOOKS_FILE,
|
||
byte_offset=0,
|
||
start_line=0,
|
||
emit_after_line=start_event_count,
|
||
)
|
||
records = [_hook_record_from_jsonl_record(record) for record in read_result.records]
|
||
return HookReadResult(
|
||
event_cursor=read_result.line_cursor,
|
||
byte_offset=read_result.byte_offset,
|
||
records=records,
|
||
)
|
||
|
||
|
||
def read_hook_events_from_offset(
|
||
bridge_dir: Path,
|
||
byte_offset: int,
|
||
*,
|
||
start_event_count: int,
|
||
) -> HookReadResult:
|
||
"""
|
||
Read hook records appended after a byte offset.
|
||
|
||
Only complete newline-terminated JSONL records are returned. A
|
||
partial trailing hook record leaves the byte offset unchanged so
|
||
the next poll retries it after Claude finishes the write.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:param byte_offset: Byte offset already consumed, e.g. ``1024``.
|
||
:param start_event_count: Count of complete hook records already
|
||
consumed. Used to keep the legacy cursor monotonic while byte
|
||
offsets drive the actual seek.
|
||
:returns: Complete hook records plus updated line and byte cursors.
|
||
"""
|
||
read_result = _read_complete_jsonl_records(
|
||
bridge_dir / _HOOKS_FILE,
|
||
byte_offset=byte_offset,
|
||
start_line=start_event_count,
|
||
)
|
||
records = [_hook_record_from_jsonl_record(record) for record in read_result.records]
|
||
return HookReadResult(
|
||
event_cursor=read_result.line_cursor,
|
||
byte_offset=read_result.byte_offset,
|
||
records=records,
|
||
)
|
||
|
||
|
||
def stop_hook_seen_since(bridge_dir: Path, start_event_count: int) -> bool:
|
||
"""
|
||
Return whether Claude reported a stop event after a hook cursor.
|
||
|
||
Only counts stop events from the parent Claude process — subagent
|
||
stop events (whose ``transcript_path`` contains a ``subagents/``
|
||
component) are ignored so a finishing subagent does not
|
||
prematurely signal the parent turn as complete.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:param start_event_count: Hook record count captured before a
|
||
message is injected into the Claude terminal.
|
||
:returns: ``True`` once a parent-process ``Stop`` or
|
||
``StopFailure`` hook has been recorded after the cursor.
|
||
"""
|
||
path = bridge_dir / _HOOKS_FILE
|
||
try:
|
||
with path.open("r", encoding="utf-8") as handle:
|
||
for index, line in enumerate(handle, start=1):
|
||
if index <= start_event_count:
|
||
continue
|
||
try:
|
||
envelope = json.loads(line)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
payload = envelope.get("payload") if isinstance(envelope, dict) else None
|
||
event_name = payload.get("hook_event_name") if isinstance(payload, dict) else None
|
||
if event_name in {"Stop", "StopFailure"}:
|
||
transcript_path = (
|
||
payload.get("transcript_path") if isinstance(payload, dict) else None
|
||
)
|
||
if isinstance(transcript_path, str) and "/subagents/" in transcript_path:
|
||
continue
|
||
return True
|
||
except FileNotFoundError:
|
||
return False
|
||
return False
|
||
|
||
|
||
# Terminal per-task ``status`` values in a ``Stop`` hook's ``background_tasks``
|
||
# array. Claude Code retains finished/stopped shells in that array rather than
|
||
# reaping them (claude-code issues #67895, #59456, #14049), so counting the raw
|
||
# length would over-count and leave the "N background tasks still running"
|
||
# indicator stuck after a shell exited. We exclude these known terminal states
|
||
# and count everything else as live — unknown/absent statuses count as running
|
||
# so a payload variant can never UNDER-count and re-hide a genuinely running
|
||
# shell (the bug this whole feature fixes). ``"running"`` / ``"completed"`` /
|
||
# ``"failed"`` are the documented values (CHANGELOG v2.1.145+); ``"stopped"`` /
|
||
# ``"killed"`` appear in the codebase/issues but are not formally documented.
|
||
_TERMINAL_BACKGROUND_TASK_STATUSES: frozenset[str] = frozenset(
|
||
{"completed", "failed", "stopped", "killed"}
|
||
)
|
||
|
||
|
||
def _hook_record_from_jsonl_record(record: _JsonlRecord) -> ClaudeHookRecord:
|
||
"""
|
||
Convert one complete hook JSONL line into a hook record.
|
||
|
||
:param record: Complete JSONL record read from ``hooks.jsonl``.
|
||
:returns: Hook record with an event name when present. Malformed
|
||
complete lines return ``event_name=None`` so callers can
|
||
still advance durable cursors past them.
|
||
"""
|
||
event_name: str | None = None
|
||
try:
|
||
envelope = json.loads(record.text) if record.text is not None else None
|
||
except json.JSONDecodeError:
|
||
envelope = None
|
||
payload = envelope.get("payload") if isinstance(envelope, dict) else None
|
||
raw_event_name = payload.get("hook_event_name") if isinstance(payload, dict) else None
|
||
if isinstance(raw_event_name, str) and raw_event_name:
|
||
event_name = raw_event_name
|
||
raw_source = payload.get("source") if isinstance(payload, dict) else None
|
||
raw_recorded_at = envelope.get("recorded_at") if isinstance(envelope, dict) else None
|
||
raw_claude_session_id = payload.get("session_id") if isinstance(payload, dict) else None
|
||
raw_transcript_path = payload.get("transcript_path") if isinstance(payload, dict) else None
|
||
raw_previous_claude_session_id = (
|
||
payload.get("omnigent_previous_claude_session_id") if isinstance(payload, dict) else None
|
||
)
|
||
raw_claude_session_was_seen = (
|
||
payload.get("omnigent_claude_session_was_seen") if isinstance(payload, dict) else None
|
||
)
|
||
raw_clear_rotated_to = (
|
||
payload.get("omnigent_clear_rotated_to") if isinstance(payload, dict) else None
|
||
)
|
||
raw_fork_detected = (
|
||
payload.get("omnigent_fork_detected") if isinstance(payload, dict) else None
|
||
)
|
||
raw_fork_rotated_to = (
|
||
payload.get("omnigent_fork_rotated_to") if isinstance(payload, dict) else None
|
||
)
|
||
# Extract todos from PostToolUse/TodoWrite hook payloads. Claude Code
|
||
# fires this hook after every TodoWrite call with ``tool_input.todos``
|
||
# containing the updated list. Other PostToolUse events have no todos.
|
||
todos: list[dict[str, Any]] | None = None
|
||
task_id: str | None = None
|
||
task_subject: str | None = None
|
||
task_status: str | None = None
|
||
if event_name == "PostToolUse" and isinstance(payload, dict):
|
||
raw_tool_name = payload.get("tool_name")
|
||
if raw_tool_name == "TodoWrite":
|
||
raw_tool_input = payload.get("tool_input")
|
||
if isinstance(raw_tool_input, dict):
|
||
raw_todos = raw_tool_input.get("todos")
|
||
if isinstance(raw_todos, list):
|
||
todos = [t for t in raw_todos if isinstance(t, dict)]
|
||
elif raw_tool_name == "TaskUpdate":
|
||
raw_tool_input = payload.get("tool_input")
|
||
if isinstance(raw_tool_input, dict):
|
||
raw_task_id = raw_tool_input.get("taskId")
|
||
raw_task_status = raw_tool_input.get("status")
|
||
if isinstance(raw_task_id, str) and raw_task_id:
|
||
task_id = raw_task_id
|
||
if isinstance(raw_task_status, str) and raw_task_status:
|
||
task_status = raw_task_status
|
||
elif event_name == "TaskCreated" and isinstance(payload, dict):
|
||
raw_task_id = payload.get("task_id")
|
||
raw_task_subject = payload.get("task_subject")
|
||
if isinstance(raw_task_id, str) and raw_task_id:
|
||
task_id = raw_task_id
|
||
if isinstance(raw_task_subject, str) and raw_task_subject:
|
||
task_subject = raw_task_subject
|
||
task_status = "pending"
|
||
elif event_name == "TaskCompleted" and isinstance(payload, dict):
|
||
raw_task_id = payload.get("task_id")
|
||
if isinstance(raw_task_id, str) and raw_task_id:
|
||
task_id = raw_task_id
|
||
task_status = "completed"
|
||
background_task_count = 0
|
||
if event_name == "Stop" and isinstance(payload, dict):
|
||
raw_bg = payload.get("background_tasks")
|
||
if isinstance(raw_bg, list):
|
||
# Count only shells still running: Claude Code leaves finished
|
||
# shells in the array (see _TERMINAL_BACKGROUND_TASK_STATUSES), so a
|
||
# raw len() over-counts and pins the indicator after they exit.
|
||
background_task_count = sum(
|
||
1
|
||
for task in raw_bg
|
||
if not (
|
||
isinstance(task, dict)
|
||
and task.get("status") in _TERMINAL_BACKGROUND_TASK_STATUSES
|
||
)
|
||
)
|
||
return ClaudeHookRecord(
|
||
event_cursor=record.line_number,
|
||
byte_offset=record.next_byte_offset,
|
||
event_name=event_name,
|
||
recorded_at=raw_recorded_at
|
||
if isinstance(raw_recorded_at, (int, float)) and not isinstance(raw_recorded_at, bool)
|
||
else None,
|
||
source=raw_source if isinstance(raw_source, str) and raw_source else None,
|
||
claude_session_id=(
|
||
raw_claude_session_id
|
||
if isinstance(raw_claude_session_id, str) and raw_claude_session_id
|
||
else None
|
||
),
|
||
transcript_path=(
|
||
Path(raw_transcript_path)
|
||
if isinstance(raw_transcript_path, str) and raw_transcript_path
|
||
else None
|
||
),
|
||
previous_claude_session_id=(
|
||
raw_previous_claude_session_id
|
||
if isinstance(raw_previous_claude_session_id, str) and raw_previous_claude_session_id
|
||
else None
|
||
),
|
||
claude_session_was_seen=(
|
||
raw_claude_session_was_seen if isinstance(raw_claude_session_was_seen, bool) else None
|
||
),
|
||
clear_rotated_to=(
|
||
raw_clear_rotated_to
|
||
if isinstance(raw_clear_rotated_to, str) and raw_clear_rotated_to
|
||
else None
|
||
),
|
||
fork_detected=raw_fork_detected is True,
|
||
fork_rotated_to=(
|
||
raw_fork_rotated_to
|
||
if isinstance(raw_fork_rotated_to, str) and raw_fork_rotated_to
|
||
else None
|
||
),
|
||
todos=todos,
|
||
task_id=task_id,
|
||
task_subject=task_subject,
|
||
task_status=task_status,
|
||
background_task_count=background_task_count,
|
||
)
|
||
|
||
|
||
def _read_complete_jsonl_records(
|
||
path: Path,
|
||
*,
|
||
byte_offset: int,
|
||
start_line: int,
|
||
emit_after_line: int | None = None,
|
||
) -> _JsonlReadResult:
|
||
"""
|
||
Read complete newline-terminated records from a JSONL file.
|
||
|
||
The reader seeks to ``byte_offset`` and stops before a trailing
|
||
partial line. That partial line's bytes are retried by the next
|
||
poll after the writer appends its newline.
|
||
|
||
:param path: JSONL file path.
|
||
:param byte_offset: Byte offset where reading should begin,
|
||
e.g. ``4096``.
|
||
:param start_line: Count of complete records before
|
||
``byte_offset``, e.g. ``12``.
|
||
:param emit_after_line: When provided, complete records at or
|
||
before this line number are counted for cursor migration but
|
||
not decoded or stored.
|
||
:returns: Complete records plus updated line and byte cursors.
|
||
"""
|
||
if byte_offset < 0:
|
||
raise ValueError(f"byte_offset must be non-negative, got {byte_offset}")
|
||
if start_line < 0:
|
||
raise ValueError(f"start_line must be non-negative, got {start_line}")
|
||
records: list[_JsonlRecord] = []
|
||
cursor = start_line
|
||
position = byte_offset
|
||
try:
|
||
with path.open("rb") as handle:
|
||
handle.seek(0, os.SEEK_END)
|
||
file_size = handle.tell()
|
||
if byte_offset > file_size:
|
||
handle.seek(0)
|
||
cursor = 0
|
||
position = 0
|
||
else:
|
||
handle.seek(byte_offset)
|
||
while True:
|
||
record_start = position
|
||
raw = handle.readline()
|
||
if not raw:
|
||
break
|
||
if not raw.endswith(b"\n"):
|
||
break
|
||
position = handle.tell()
|
||
cursor += 1
|
||
if emit_after_line is not None and cursor <= emit_after_line:
|
||
continue
|
||
try:
|
||
text = raw.decode("utf-8")
|
||
except UnicodeDecodeError:
|
||
text = None
|
||
records.append(
|
||
_JsonlRecord(
|
||
line_number=cursor,
|
||
byte_offset=record_start,
|
||
next_byte_offset=position,
|
||
text=text,
|
||
)
|
||
)
|
||
except FileNotFoundError:
|
||
return _JsonlReadResult(
|
||
line_cursor=start_line,
|
||
byte_offset=byte_offset,
|
||
records=[],
|
||
)
|
||
return _JsonlReadResult(
|
||
line_cursor=cursor,
|
||
byte_offset=position,
|
||
records=records,
|
||
)
|
||
|
||
|
||
def write_tmux_target(
|
||
bridge_dir: Path,
|
||
*,
|
||
socket_path: Path,
|
||
tmux_target: str,
|
||
pid: int | None = None,
|
||
) -> None:
|
||
"""
|
||
Advertise the tmux socket + target for the Claude terminal.
|
||
|
||
The runner calls this after launching the ``claude/main`` terminal
|
||
so the harness can shell out to ``tmux send-keys`` against the
|
||
same private socket the terminal was launched on.
|
||
|
||
:param bridge_dir: Bridge directory path, e.g.
|
||
``/tmp/omnigent/claude-native/<digest>``.
|
||
:param socket_path: Absolute path to the terminal's private tmux
|
||
socket, e.g. ``Path("/tmp/.../tmux.sock")``.
|
||
:param tmux_target: tmux pane target string, e.g. ``"claude:0.0"``.
|
||
:param pid: Optional Claude process pid, recorded for diagnostics.
|
||
:returns: None.
|
||
"""
|
||
_ensure_secure_dir(bridge_dir)
|
||
payload: dict[str, Any] = {
|
||
"socket_path": str(socket_path),
|
||
"tmux_target": tmux_target,
|
||
"updated_at": time.time(),
|
||
}
|
||
if pid is not None:
|
||
payload["pid"] = pid
|
||
_write_json_file(bridge_dir / _TMUX_FILE, payload)
|
||
|
||
|
||
def inject_user_message(
|
||
bridge_dir: Path,
|
||
*,
|
||
content: str,
|
||
timeout_s: float = _TMUX_READY_TIMEOUT_S,
|
||
) -> None:
|
||
r"""
|
||
Deliver a user message into the Claude terminal via tmux send-keys.
|
||
|
||
Before typing, this waits for two readiness conditions: the runner
|
||
advertising ``tmux.json``, then Claude Code's input box rendering
|
||
(see :func:`_wait_for_claude_prompt_ready`). The second gate closes
|
||
a race on freshly-created sessions where the first message would
|
||
otherwise be typed into a still-booting TUI and silently dropped.
|
||
|
||
Delivered as one bracketed paste via ``tmux load-buffer`` (from a
|
||
temp file) + ``paste-buffer -p`` so interior newlines ride as raw CR
|
||
inside the paste markers and Claude Code's TUI keeps multi-line
|
||
input as data rather than submitting on each newline
|
||
(anthropics/claude-code#52126). A trailing newline inside the paste
|
||
absorbs any trailing backslash — otherwise ``\`` + the submit
|
||
``Enter`` reads as a line-continuation and the message sits unsent.
|
||
``Enter`` is a separate tmux call. The file-based buffer
|
||
path (not ``send-keys`` argv) matters: tmux caps a single
|
||
client→server command at ~16KB, so a large message — e.g. a PR diff
|
||
in a sub-agent dispatch — failed with "command too long".
|
||
|
||
The submit is **verified, not fire-and-forget**: Claude Code
|
||
coalesces rapid stdin bursts into a paste, so an Enter that lands
|
||
while the TUI is still consuming the paste is folded in as a
|
||
newline and the draft sits unsent. This helper first polls
|
||
``capture-pane`` until the draft is visible in the input box (the
|
||
paste was committed), sends Enter, then polls that the draft left
|
||
the box — re-sending Enter while it hasn't — and raises if the
|
||
message never submits.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:param content: User text from the Omnigent web UI. Must be non-empty.
|
||
:param timeout_s: Seconds to wait for each readiness gate
|
||
(``tmux.json`` advertised, then prompt rendered), e.g. ``30.0``.
|
||
:returns: None.
|
||
:raises RuntimeError: If the tmux target is not advertised in time,
|
||
if Claude's input prompt never renders, if a ``tmux send-keys``
|
||
invocation fails, or if the draft never leaves the input box
|
||
after repeated submit Enters (message not delivered).
|
||
"""
|
||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||
# tmux.json only means the tmux session exists; Claude Code's input
|
||
# box mounts a few seconds later. Block until the prompt renders so
|
||
# the first message isn't typed into a still-booting TUI and dropped.
|
||
_wait_for_claude_prompt_ready(
|
||
info["socket_path"],
|
||
info["tmux_target"],
|
||
timeout_s=timeout_s,
|
||
)
|
||
# Clear any leftover text in Claude's input field before typing.
|
||
# After Escape-cancel, Claude Code re-populates the prompt area
|
||
# with the previous input for re-editing. Without this clear,
|
||
# the new message appends to the stale buffer (e.g.
|
||
# "old promptnew prompt" with no separator).
|
||
# Ctrl-A (Home) + Ctrl-K (kill-to-end) is the safest pair —
|
||
# Ctrl-U only clears backwards from cursor.
|
||
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "C-a")
|
||
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "C-k")
|
||
# Trailing newline absorbs a trailing "\" so it can't escape the submit Enter.
|
||
# Delivered through a tmux buffer, NOT ``send-keys`` argv: tmux caps one
|
||
# client→server command at ~16KB, so per-byte hex argv blew up with
|
||
# "command too long" on large payloads (a PR diff in a sub-agent
|
||
# dispatch). ``load-buffer`` streams the file without that cap, and
|
||
# ``paste-buffer -p`` wraps it in the same bracketed-paste markers so
|
||
# interior newlines (mapped to CR below) stay data instead of becoming
|
||
# per-line submits. See anthropics/claude-code#52126.
|
||
with tempfile.NamedTemporaryFile(
|
||
dir=bridge_dir, prefix="paste_", suffix=".bin", delete=False
|
||
) as paste_file:
|
||
paste_file.write(_paste_payload_bytes(content + "\n"))
|
||
paste_path = paste_file.name
|
||
try:
|
||
_run_tmux(info["socket_path"], "load-buffer", "-b", "omnigent-paste", paste_path)
|
||
_run_tmux(
|
||
info["socket_path"],
|
||
"paste-buffer",
|
||
"-p", # bracketed-paste markers — the TUI keeps newlines as data
|
||
"-d", # drop the buffer after pasting (no stale copies server-side)
|
||
"-b",
|
||
"omnigent-paste",
|
||
"-t",
|
||
info["tmux_target"],
|
||
)
|
||
finally:
|
||
with contextlib.suppress(OSError):
|
||
os.unlink(paste_path)
|
||
# Wait until the TUI has visibly committed the paste into its input
|
||
# box before submitting. Claude Code coalesces rapid stdin bursts
|
||
# into a paste; an Enter that arrives while it is still consuming
|
||
# the paste becomes a newline inside the draft instead of a submit,
|
||
# and the message sits unsent. A fixed sleep raced this (lost under
|
||
# load / large payloads); polling is deterministic. Best-effort:
|
||
# when the draft never becomes identifiable (e.g. whitespace-only
|
||
# first line, custom statusline containing the glyph), fall through
|
||
# after the timeout and submit blind, matching the old behavior.
|
||
needle = _submit_needle(content)
|
||
draft_seen = False
|
||
deadline = time.monotonic() + _PASTE_COMMIT_TIMEOUT_S
|
||
while time.monotonic() < deadline:
|
||
if _draft_in_input_box(_capture_pane(info["socket_path"], info["tmux_target"]), needle):
|
||
draft_seen = True
|
||
break
|
||
time.sleep(_CLAUDE_READY_POLL_INTERVAL_S)
|
||
time.sleep(_PASTE_SETTLE_S)
|
||
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Enter")
|
||
if not draft_seen:
|
||
# The draft was never observed, so its absence proves nothing —
|
||
# verification would trivially "pass". Submit blind as before.
|
||
return
|
||
# Verify the submit took: a successful Enter clears the input box.
|
||
# If the draft is still sitting there the Enter was swallowed into
|
||
# the paste burst as a newline — re-send it (the retry lands well
|
||
# after the burst, so it submits). Each Enter only fires while the
|
||
# draft is verifiably still present, so a retry can never hit an
|
||
# empty prompt or a permission dialog of the started turn.
|
||
deadline = time.monotonic() + _SUBMIT_VERIFY_TIMEOUT_S
|
||
last_enter = time.monotonic()
|
||
while time.monotonic() < deadline:
|
||
time.sleep(_CLAUDE_READY_POLL_INTERVAL_S)
|
||
pane = _capture_pane(info["socket_path"], info["tmux_target"])
|
||
if not _draft_in_input_box(pane, needle):
|
||
return
|
||
if time.monotonic() - last_enter >= _SUBMIT_RETRY_INTERVAL_S:
|
||
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Enter")
|
||
last_enter = time.monotonic()
|
||
raise RuntimeError(
|
||
f"Claude Code did not accept the submitted message within {_SUBMIT_VERIFY_TIMEOUT_S}s "
|
||
"(the draft is still in the input box). The message was not delivered."
|
||
)
|
||
|
||
|
||
def inject_interrupt(
|
||
bridge_dir: Path,
|
||
*,
|
||
timeout_s: float = _TMUX_READY_TIMEOUT_S,
|
||
) -> None:
|
||
"""
|
||
Send an Escape keystroke into the Claude terminal via tmux send-keys.
|
||
|
||
Claude Code's TUI cancels an in-flight response on a single
|
||
``Escape``. The harness's ``run_turn`` for ``claude-native``
|
||
returns immediately after the tmux paste (the long-running work
|
||
happens inside the ``claude`` binary in the pane, not the
|
||
harness), so the scaffold's interrupt path can't reach it — this
|
||
helper is the analog of :func:`inject_user_message` for the AP
|
||
web stop button / Escape keybind.
|
||
|
||
:param bridge_dir: Bridge directory path, e.g.
|
||
``/tmp/omnigent/claude-native/<digest>``.
|
||
:param timeout_s: Seconds to wait for ``tmux.json`` to be
|
||
advertised by the runner, e.g. ``30.0``.
|
||
:returns: None.
|
||
:raises RuntimeError: If the tmux target is not advertised in
|
||
time, or if the ``tmux send-keys`` invocation fails.
|
||
"""
|
||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||
# No ``-l``: tmux must interpret ``Escape`` as a key name.
|
||
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Escape")
|
||
|
||
|
||
def kill_session(
|
||
bridge_dir: Path,
|
||
*,
|
||
timeout_s: float = _TMUX_READY_TIMEOUT_S,
|
||
) -> None:
|
||
"""
|
||
Forcefully terminate the Claude tmux session via ``kill-session``.
|
||
|
||
Claude-native sessions run the ``claude`` binary inside a tmux
|
||
session on a per-session socket (see
|
||
:class:`omnigent.inner.terminal.TerminalInstance`). The only way
|
||
a user can end such a session today is to re-attach to the tmux in
|
||
their terminal and exit from inside it. This helper is the analog
|
||
of that manual exit for the Omnigent web UI's "Stop session" affordance:
|
||
it kills the tmux session outright, which terminates ``claude`` and
|
||
everything in the pane.
|
||
|
||
Unlike :func:`inject_interrupt` (which sends a single ``Escape`` to
|
||
cancel an in-flight response but leaves the session alive), this is
|
||
a hard stop. Once the pane is gone the wrapper's reconnect loop
|
||
observes the terminal resource disappear and tears the session
|
||
down through its normal end-of-session path, so no transcript items
|
||
are synthesized here.
|
||
|
||
:param bridge_dir: Bridge directory path, e.g.
|
||
``/tmp/omnigent/claude-native/<digest>``.
|
||
:param timeout_s: Seconds to wait for ``tmux.json`` to be
|
||
advertised by the runner, e.g. ``30.0``. A short value is
|
||
appropriate for the UI path — a missing ``tmux.json`` means
|
||
there is no live session to kill.
|
||
:returns: None.
|
||
:raises RuntimeError: If the tmux target is not advertised in
|
||
time, or if the ``tmux kill-session`` invocation fails.
|
||
"""
|
||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||
_run_tmux(info["socket_path"], "kill-session", "-t", info["tmux_target"])
|
||
|
||
|
||
def inject_slash_command(
|
||
bridge_dir: Path,
|
||
*,
|
||
command: str,
|
||
timeout_s: float = _TMUX_READY_TIMEOUT_S,
|
||
auto_confirm: bool = False,
|
||
) -> None:
|
||
"""
|
||
Type a Claude Code slash command into the tmux pane and submit it.
|
||
|
||
:param bridge_dir: Bridge directory path, e.g.
|
||
``/tmp/omnigent/claude-native/<digest>``.
|
||
:param command: Single-line slash command including the leading
|
||
``/``, e.g. ``"/effort high"``.
|
||
:param timeout_s: Seconds to wait for ``tmux.json``, e.g. ``30.0``.
|
||
:param auto_confirm: If ``True``, send an extra ``Enter`` after a
|
||
short delay to accept the default option of any TUI confirmation
|
||
dialog that the command may pop (e.g. ``/effort`` / ``/model``
|
||
prompt when switching invalidates the prompt cache). HACK —
|
||
the chat UI has no way to render the CLI's TUI dialog, so
|
||
without this the command silently stalls. Assumes the default
|
||
option is "accept" (true today for effort + model). When no
|
||
dialog appears, the extra Enter falls on an empty prompt and is
|
||
a no-op. Callers that don't trigger confirmations should leave
|
||
this ``False``.
|
||
:raises ValueError: If *command* is empty, does not start with
|
||
``/``, or contains a newline.
|
||
:raises RuntimeError: If the tmux target is not advertised in
|
||
time, or if a ``tmux send-keys`` invocation fails.
|
||
"""
|
||
if not command or not command.startswith("/"):
|
||
raise ValueError(f"slash command must start with '/'; got {command!r}")
|
||
if "\n" in command:
|
||
raise ValueError("slash command must be a single line")
|
||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||
# ``C-u`` clears any draft the user is mid-typing; otherwise the
|
||
# paste below concatenates with their text and Enter submits
|
||
# ``<their-draft>/effort high`` as a turn. Unlike Escape it does
|
||
# not interrupt an in-flight generation.
|
||
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "C-u")
|
||
# ``-l`` pastes ``/`` and spaces literally; trailing Enter submits.
|
||
_run_tmux(info["socket_path"], "send-keys", "-l", "-t", info["tmux_target"], command)
|
||
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Enter")
|
||
if auto_confirm:
|
||
# Give the TUI time to render its confirmation dialog before
|
||
# the auto-Enter arrives; otherwise the keystroke races the
|
||
# prompt and gets dropped.
|
||
time.sleep(0.3)
|
||
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Enter")
|
||
|
||
|
||
def display_cost_approval_popup(
|
||
bridge_dir: Path,
|
||
*,
|
||
session_id: str,
|
||
elicitation_id: str,
|
||
message: str,
|
||
policy_name: str | None = None,
|
||
python_executable: str | None = None,
|
||
timeout_s: float = _TMUX_READY_TIMEOUT_S,
|
||
config_file: Path | None = None,
|
||
) -> None:
|
||
"""
|
||
Overlay a cost-budget approval modal on the Claude Code tmux pane.
|
||
|
||
Launches :mod:`omnigent.native_cost_popup` inside a
|
||
``tmux display-popup``, so a user working in the native terminal —
|
||
not only the web ``ApprovalCard`` — can approve/decline a cost
|
||
checkpoint. The popup script resolves the **same** elicitation Future
|
||
(via the same resolve endpoint the web card uses), so whichever
|
||
surface answers first wins and the other clears. The popup reads AP
|
||
routing (base URL + auth headers) from *config_file* so no token lands
|
||
on the command line.
|
||
|
||
Fire-and-forget by design: ``tmux display-popup`` blocks its tmux
|
||
client until the popup closes, so it is spawned **detached**
|
||
(``Popen``, not awaited) — the caller returns immediately while the
|
||
modal lives on the attached client until the user answers.
|
||
|
||
Claude-native resolver for the harness-agnostic
|
||
:func:`omnigent.native_cost_popup.launch_cost_popup`: it reads the
|
||
pane's tmux socket/target from this bridge's ``tmux.json`` and points
|
||
the popup at *config_file* for Omnigent routing (base URL + auth
|
||
headers, so no token lands on the command line), then delegates. The
|
||
launcher pops the modal on every attached client and skips silently when
|
||
none is attached (e.g. the Terminal tab is closed) — the web
|
||
``ApprovalCard`` remains the answer surface.
|
||
|
||
:param bridge_dir: Bridge directory path, e.g.
|
||
``/tmp/omnigent/claude-native/<digest>``. Supplies the tmux target
|
||
(``tmux.json``); the AP-routing config comes from *config_file*.
|
||
:param session_id: Omnigent session id that owns the elicitation, e.g.
|
||
``"conv_abc123"``. Used in the resolve URL the popup POSTs to.
|
||
:param elicitation_id: Outstanding elicitation correlation id, e.g.
|
||
``"elicit_deadbeef"``.
|
||
:param message: Approval reason shown in the popup, e.g.
|
||
``"Session cost $0.12 crossed the $0.10 checkpoint. Continue?"``.
|
||
:param policy_name: Name of the deciding policy, rendered as the
|
||
modal header. ``None`` falls back to a generic header.
|
||
:param python_executable: Python used to run the popup module;
|
||
``None`` uses :data:`sys.executable` (the runner's interpreter,
|
||
valid on the host the tmux server runs on).
|
||
:param timeout_s: Seconds to wait for ``tmux.json`` to be advertised,
|
||
e.g. ``30.0``.
|
||
:param config_file: AP-routing config the popup reads (base URL + auth
|
||
headers). ``None`` falls back to this bridge's ``permission_hook.json``
|
||
— but that carries the one-shot launch token, which dies with the ~1h
|
||
Databricks OAuth lifetime, so callers should pass a freshly-minted
|
||
snapshot to keep a late-firing verdict POST from 401-ing.
|
||
:returns: None.
|
||
:raises RuntimeError: If the tmux target is not advertised within
|
||
*timeout_s* (the pane isn't up yet); the caller treats this as a
|
||
best-effort miss and the web card remains answerable.
|
||
"""
|
||
from omnigent.native_cost_popup import launch_cost_popup
|
||
|
||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||
launch_cost_popup(
|
||
info["socket_path"],
|
||
info["tmux_target"],
|
||
config_file if config_file is not None else bridge_dir / _PERMISSION_HOOK_FILE,
|
||
session_id=session_id,
|
||
elicitation_id=elicitation_id,
|
||
message=message,
|
||
policy_name=policy_name,
|
||
python_executable=python_executable,
|
||
)
|
||
|
||
|
||
def post_tools_changed(
|
||
bridge_dir: Path,
|
||
*,
|
||
timeout_s: float = _TOOLS_CHANGED_READY_TIMEOUT_S,
|
||
) -> None:
|
||
"""
|
||
Notify Claude Code that the MCP tool list changed.
|
||
|
||
Standard MCP ``notifications/tools/list_changed`` — the bridge's
|
||
localhost HTTP control endpoint trampolines the POST into the
|
||
MCP stdio writer. Unrelated to Claude's experimental Channels.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:param timeout_s: Seconds to wait for the bridge HTTP control
|
||
endpoint to publish itself, e.g. ``30.0``.
|
||
:returns: None.
|
||
:raises RuntimeError: If the bridge server is not ready or
|
||
rejects the notification.
|
||
"""
|
||
server = _wait_for_server_info(bridge_dir, timeout_s=timeout_s)
|
||
token = server.get("token")
|
||
url = server.get("url")
|
||
if not isinstance(token, str) or not isinstance(url, str):
|
||
raise RuntimeError("Claude native bridge server file is missing url/token")
|
||
req = request.Request(
|
||
f"{url}/tools-changed",
|
||
data=b"{}",
|
||
method="POST",
|
||
headers={
|
||
"Authorization": f"Bearer {token}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
)
|
||
try:
|
||
with request.urlopen(req, timeout=_TOOLS_CHANGED_POST_TIMEOUT_S) as resp:
|
||
if resp.status >= 400:
|
||
raise RuntimeError(f"tools-changed POST failed with HTTP {resp.status}")
|
||
except error.URLError as exc:
|
||
raise RuntimeError(f"failed to notify Claude tool list change: {exc}") from exc
|
||
|
||
|
||
def _run_tmux(socket_path: str, *args: str) -> None:
|
||
"""
|
||
Invoke ``tmux -S <socket_path> <args...>`` and raise on failure.
|
||
|
||
:param socket_path: Absolute path to the tmux socket the terminal
|
||
was launched on, e.g. ``"/tmp/.../tmux.sock"``.
|
||
:param args: Arguments after ``tmux -S <socket_path>``, e.g.
|
||
``("send-keys", "-l", "-t", "claude:0.0", "hello")``.
|
||
:returns: None.
|
||
:raises RuntimeError: If the subprocess exits non-zero or times
|
||
out.
|
||
"""
|
||
import subprocess
|
||
|
||
cmd = ["tmux", "-S", socket_path, *args]
|
||
try:
|
||
proc = subprocess.run(
|
||
cmd,
|
||
check=False,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=_TMUX_SEND_TIMEOUT_S,
|
||
)
|
||
except subprocess.TimeoutExpired as exc:
|
||
raise RuntimeError(f"tmux command timed out after {_TMUX_SEND_TIMEOUT_S}s") from exc
|
||
if proc.returncode != 0:
|
||
detail = proc.stderr.strip() or proc.stdout.strip() or "<no output>"
|
||
raise RuntimeError(f"tmux command failed (rc={proc.returncode}): {detail}")
|
||
|
||
|
||
def _capture_pane(socket_path: str, tmux_target: str) -> str:
|
||
"""
|
||
Capture the current visible contents of a tmux pane.
|
||
|
||
Unlike :func:`_run_tmux`, this returns stdout instead of raising on
|
||
output, and never raises — a transient capture failure during boot
|
||
should be treated as "not ready yet" by the caller, not an error.
|
||
|
||
:param socket_path: Absolute path to the tmux socket, e.g.
|
||
``"/tmp/.../tmux.sock"``.
|
||
:param tmux_target: tmux pane target string, e.g. ``"main"``.
|
||
:returns: The pane's visible text, or ``""`` if capture failed.
|
||
"""
|
||
import subprocess
|
||
|
||
try:
|
||
proc = subprocess.run(
|
||
["tmux", "-S", socket_path, "capture-pane", "-t", tmux_target, "-p"],
|
||
check=False,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=_TMUX_SEND_TIMEOUT_S,
|
||
)
|
||
except (subprocess.SubprocessError, OSError):
|
||
return ""
|
||
return proc.stdout if proc.returncode == 0 else ""
|
||
|
||
|
||
def _claude_prompt_rendered(pane: str) -> bool:
|
||
"""
|
||
Return whether Claude Code's input prompt is rendered in a pane.
|
||
|
||
Scans the last :data:`_PROMPT_SCAN_TAIL_LINES` non-empty lines for
|
||
:data:`_CLAUDE_PROMPT_GLYPH`. Restricting to the tail avoids false
|
||
positives from the glyph appearing in scrollback (e.g. echoed in a
|
||
prior response), since the live input box always sits at the bottom.
|
||
|
||
A mid-turn injection grows the footer with running-state rows (a
|
||
``○ Explore …`` subagent line, extra spinners) that can push ``❯``
|
||
past that window — arbitrarily far, since a subagent fan-out adds one
|
||
row per concurrent subagent. To reach it at any depth without also
|
||
matching a scrollback echo, a glyph above the window counts only when
|
||
it's framed by a box rule — the ``────`` closing line the live input
|
||
box always renders below ``❯`` but a bare echoed prompt never has.
|
||
|
||
A bare ``❯`` on a selected numbered menu row is not the chat input. A
|
||
numbered line with an input-box rule below it still counts, however: the
|
||
readiness gate runs before every injection, so a restored composer draft
|
||
may legitimately begin with text such as ``2. buy milk``.
|
||
|
||
:param pane: Captured pane text from :func:`_capture_pane`.
|
||
:returns: ``True`` when the input box appears mounted.
|
||
"""
|
||
non_empty = [line for line in pane.splitlines() if line.strip()]
|
||
tail_start = max(0, len(non_empty) - _PROMPT_SCAN_TAIL_LINES)
|
||
for idx in range(tail_start, len(non_empty)):
|
||
line = non_empty[idx]
|
||
if _CLAUDE_PROMPT_GLYPH not in line:
|
||
continue
|
||
if not _is_selected_menu_row(line) or any(
|
||
_is_box_rule(rule) for rule in non_empty[idx + 1 :]
|
||
):
|
||
return True
|
||
# Above that window, trust the glyph only when a box rule sits below
|
||
# it — the live input box's closing frame, absent from scrollback.
|
||
# The footer height scales with concurrent subagents (a fan-out of
|
||
# ``○ Explore …`` rows), so no fixed window can bound it; the box rule
|
||
# is a reliable structural signal at any depth, and `capture-pane -p`
|
||
# returns only the visible pane, so this stays within one screen.
|
||
for idx, line in enumerate(non_empty):
|
||
if _CLAUDE_PROMPT_GLYPH not in line:
|
||
continue
|
||
if any(_is_box_rule(rule) for rule in non_empty[idx + 1 :]):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _is_selected_menu_row(line: str) -> bool:
|
||
"""
|
||
Return whether a ``❯`` line is a selected numbered menu row.
|
||
|
||
Claude Code's startup menus (e.g. the "Detected a custom API key"
|
||
confirmation) mark the highlighted choice with the same ``❯`` glyph the
|
||
chat input uses (``❯ 2. No (recommended)``). The readiness scan must not
|
||
treat such a row as the chat composer, or the first message gets typed
|
||
into the menu. A chat prompt never renders a numbered choice after the
|
||
glyph, so the ``<glyph> <digit>.`` shape distinguishes them.
|
||
|
||
:param line: A single pane line, e.g. ``"❯ 2. No (recommended)"``.
|
||
:returns: ``True`` when the line is a selected numbered menu choice.
|
||
"""
|
||
return bool(_SELECTED_MENU_ROW_RE.match(line.strip()))
|
||
|
||
|
||
def _is_box_rule(line: str) -> bool:
|
||
"""
|
||
Return whether a line is a TUI box-drawing horizontal rule.
|
||
|
||
Claude Code frames its input box with rows of ``─`` (plus corner
|
||
glyphs). Such a rule below ``❯`` marks the live input box, letting
|
||
the readiness scan reach a prompt buried under a tall running-turn
|
||
footer without matching a bare ``❯`` echoed into scrollback.
|
||
|
||
:param line: A single pane line, e.g. ``"──────────"``.
|
||
:returns: ``True`` when the line is predominantly box-rule glyphs.
|
||
"""
|
||
stripped = line.strip()
|
||
return len(stripped) >= 3 and all(ch in _BOX_RULE_CHARS for ch in stripped)
|
||
|
||
|
||
def _submit_needle(content: str) -> str:
|
||
r"""
|
||
Derive a short marker string used to spot a draft in the input box.
|
||
|
||
Takes the first non-empty line of *content* (after the same
|
||
line-ending normalization the paste payload gets), truncated at the
|
||
first control character and to :data:`_DRAFT_NEEDLE_MAX_CHARS`, so
|
||
it matches what Claude Code renders verbatim on the prompt row.
|
||
|
||
:param content: Raw user text, possibly multi-line,
|
||
e.g. ``"fix the bug\nin foo.py"``.
|
||
:returns: The needle, e.g. ``"fix the bug"``. Empty string when no
|
||
usable line exists (whitespace-only content) — callers must
|
||
then skip draft-visibility checks.
|
||
"""
|
||
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
||
for line in normalized.split("\n"):
|
||
# Truncate at the first control char (e.g. an interior tab):
|
||
# the TUI renders those differently, so they can't be matched
|
||
# verbatim against the captured pane text.
|
||
for idx, ch in enumerate(line):
|
||
if ord(ch) < 0x20:
|
||
line = line[:idx]
|
||
break
|
||
line = line.strip()
|
||
if line:
|
||
return line[:_DRAFT_NEEDLE_MAX_CHARS]
|
||
return ""
|
||
|
||
|
||
def _draft_in_input_box(pane: str, needle: str) -> bool:
|
||
"""
|
||
Return whether the pasted draft is visible in Claude's input box.
|
||
|
||
Looks only at the **last** line containing
|
||
:data:`_CLAUDE_PROMPT_GLYPH` — the live input box always sits at
|
||
the bottom of the pane, below the transcript, so this never
|
||
matches the submitted message's transcript echo. The draft counts
|
||
as visible when the text after the glyph contains *needle* (small
|
||
pastes render verbatim) or the
|
||
:data:`_PASTED_PLACEHOLDER_PREFIX` placeholder (Claude Code
|
||
collapses large pastes).
|
||
|
||
:param pane: Captured pane text from :func:`_capture_pane`.
|
||
:param needle: Marker from :func:`_submit_needle`, e.g.
|
||
``"fix the bug"``. Empty means the draft can't be identified;
|
||
only the paste placeholder is then considered.
|
||
:returns: ``True`` when the draft is still sitting in the input box.
|
||
"""
|
||
glyph_lines = [line for line in pane.splitlines() if _CLAUDE_PROMPT_GLYPH in line]
|
||
if not glyph_lines:
|
||
return False
|
||
tail = glyph_lines[-1].rsplit(_CLAUDE_PROMPT_GLYPH, 1)[1]
|
||
if _PASTED_PLACEHOLDER_PREFIX in tail:
|
||
return True
|
||
return bool(needle) and needle in tail
|
||
|
||
|
||
def _format_terminal_failure_tail(pane: str) -> str:
|
||
r"""
|
||
Format the tail of a captured tmux pane for a failure message.
|
||
|
||
When Claude Code's input prompt never renders, its own on-screen
|
||
output — often a startup error or stack trace (e.g. a ``JSON Parse
|
||
error`` raised when its API client receives an HTML page instead of
|
||
JSON) — is the only signal of the real cause. The readiness gate
|
||
raises into the web UI's error banner, so attaching this tail
|
||
surfaces that cause without the user having to open the terminal.
|
||
|
||
:param pane: Captured pane text from :func:`_capture_pane`.
|
||
:returns: A ``" Last terminal output:\n<tail>"`` block — the last
|
||
:data:`_TERMINAL_FAILURE_TAIL_LINES` non-blank lines, capped at
|
||
:data:`_TERMINAL_FAILURE_TAIL_CHARS` characters — or ``""`` when
|
||
the pane has no visible text.
|
||
"""
|
||
lines = [line.rstrip() for line in pane.splitlines() if line.strip()]
|
||
if not lines:
|
||
return ""
|
||
tail = "\n".join(lines[-_TERMINAL_FAILURE_TAIL_LINES:])
|
||
if len(tail) > _TERMINAL_FAILURE_TAIL_CHARS:
|
||
tail = "…" + tail[-_TERMINAL_FAILURE_TAIL_CHARS:]
|
||
return f" Last terminal output:\n{tail}"
|
||
|
||
|
||
def _wait_for_claude_prompt_ready(
|
||
socket_path: str,
|
||
tmux_target: str,
|
||
*,
|
||
timeout_s: float,
|
||
) -> None:
|
||
"""
|
||
Block until Claude Code's TUI input box is ready for keystrokes.
|
||
|
||
The runner advertises ``tmux.json`` as soon as the tmux session
|
||
exists, but Claude Code's input box mounts a few seconds later
|
||
(longer on a cold first boot). Keystrokes sent into that gap are
|
||
dropped, so the first web-UI message silently vanishes. This gate
|
||
polls ``capture-pane`` for the input prompt before injection;
|
||
it returns immediately once mounted, so 2nd+ messages are
|
||
unaffected.
|
||
|
||
Claude-native only — this is called from :func:`inject_user_message`,
|
||
which exclusively serves the Claude Code terminal. It must never be
|
||
used for generic terminals, whose programs never render
|
||
:data:`_CLAUDE_PROMPT_GLYPH` and would always time out.
|
||
|
||
:param socket_path: Absolute path to the tmux socket, e.g.
|
||
``"/tmp/.../tmux.sock"``.
|
||
:param tmux_target: tmux pane target string, e.g. ``"main"``.
|
||
:param timeout_s: Seconds to wait for the prompt, e.g. ``30.0``.
|
||
:returns: None.
|
||
:raises RuntimeError: If the prompt never renders within
|
||
*timeout_s* (Claude failed to boot). The message carries a poll
|
||
count, how many of those polls saw an empty capture, and the tail
|
||
of the last non-empty capture the loop actually observed (see
|
||
:func:`_format_terminal_failure_tail`) so the true failure mode —
|
||
a startup crash, a torn/empty capture under a mid-turn repaint, or
|
||
a box that never appeared — is diagnosable from the error alone.
|
||
"""
|
||
deadline = time.monotonic() + timeout_s
|
||
polls = 0
|
||
empty_polls = 0
|
||
# Keep the last non-empty capture the loop actually saw, not a fresh
|
||
# capture taken after the deadline. A post-timeout re-capture can show
|
||
# a different (often healthier-looking) frame than any decision the
|
||
# loop made — e.g. the input box repainting just as the turn settles —
|
||
# which misrepresents why the gate failed. Attaching what was observed
|
||
# while it mattered keeps the error honest.
|
||
last_nonempty = ""
|
||
# Poll at least once even at timeout_s=0: a single readiness check is
|
||
# still meaningful, and it guarantees a capture to attach on failure.
|
||
while True:
|
||
pane = _capture_pane(socket_path, tmux_target)
|
||
polls += 1
|
||
if pane.strip():
|
||
last_nonempty = pane
|
||
else:
|
||
empty_polls += 1
|
||
if _claude_prompt_rendered(pane):
|
||
return
|
||
if time.monotonic() >= deadline:
|
||
break
|
||
time.sleep(_CLAUDE_READY_POLL_INTERVAL_S)
|
||
# Timed out. The poll/empty-capture counts separate the failure modes:
|
||
# mostly-empty captures point at a torn read under a busy repaint (the
|
||
# session is alive but capture-pane came back blank); non-empty captures
|
||
# with no box point at Claude never rendering the prompt (a boot crash,
|
||
# e.g. a ``JSON Parse error``, whose text the tail then surfaces).
|
||
raise RuntimeError(
|
||
f"Claude Code terminal did not become ready within {timeout_s}s "
|
||
f"(input prompt never rendered in {polls} polls, "
|
||
f"{empty_polls} empty captures). The message was not delivered."
|
||
+ _format_terminal_failure_tail(last_nonempty)
|
||
)
|
||
|
||
|
||
def _paste_payload_bytes(text: str) -> bytes:
|
||
r"""
|
||
Encode text as the paste-buffer byte payload for ``tmux load-buffer``.
|
||
|
||
Returns only the content bytes — ``paste-buffer -p`` adds the
|
||
``ESC [ 2 0 0 ~`` / ``ESC [ 2 0 1 ~`` bracketed-paste markers
|
||
itself when delivering the buffer to the pane.
|
||
|
||
Content bytes are mapped so Claude Code's TUI keeps the paste as
|
||
editable data rather than submitting on each line:
|
||
|
||
- ``\n`` and ``\r`` (and a ``\r\n`` pair coalesced) become a single
|
||
carriage return ``0x0d`` — the byte a real paste carries between
|
||
lines inside the markers.
|
||
- ``\t`` becomes ``0x09``.
|
||
- Any other control byte below ``0x20`` is dropped: a stray ``ESC``
|
||
(or BEL, etc.) in the content would otherwise prematurely close
|
||
the bracketed-paste sequence on the agent's side.
|
||
- All other characters pass through as their UTF-8 bytes.
|
||
|
||
:param text: Raw user text, possibly multi-line, e.g.
|
||
``"line one\nline two"`` or ``"a\r\nb"``.
|
||
:returns: The normalized content bytes, e.g. ``b"line one\rline two"``.
|
||
"""
|
||
# Normalize line endings to a single "\n" first so CRLF / lone CR
|
||
# pastes don't double up: every line break becomes exactly one CR.
|
||
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
||
body = bytearray()
|
||
for ch in normalized:
|
||
if ch == "\n":
|
||
body.append(0x0D)
|
||
continue
|
||
if ch == "\t":
|
||
body.append(0x09)
|
||
continue
|
||
if ord(ch) < 0x20:
|
||
continue
|
||
body.extend(ch.encode("utf-8"))
|
||
return bytes(body)
|
||
|
||
|
||
def _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]:
|
||
"""
|
||
Wait for the runner to write ``tmux.json``.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:param timeout_s: Seconds to wait, e.g. ``30.0``.
|
||
:returns: ``{"socket_path": ..., "tmux_target": ...}``.
|
||
:raises RuntimeError: If the file never appears with valid
|
||
``socket_path`` and ``tmux_target`` fields.
|
||
"""
|
||
deadline = time.monotonic() + timeout_s
|
||
path = bridge_dir / _TMUX_FILE
|
||
while time.monotonic() < deadline:
|
||
payload = _read_json_file(path)
|
||
socket_path = payload.get("socket_path") if isinstance(payload, dict) else None
|
||
tmux_target = payload.get("tmux_target") if isinstance(payload, dict) else None
|
||
if isinstance(socket_path, str) and isinstance(tmux_target, str):
|
||
return {"socket_path": socket_path, "tmux_target": tmux_target}
|
||
time.sleep(0.05)
|
||
raise RuntimeError(
|
||
"Claude terminal tmux target is not advertised yet. Wait for the "
|
||
"terminal to launch before sending messages from the web UI."
|
||
)
|
||
|
||
|
||
def start_tool_relay(
|
||
*,
|
||
bridge_dir: Path,
|
||
tools: list[dict[str, Any]],
|
||
tool_executor: ToolExecutor,
|
||
loop: asyncio.AbstractEventLoop,
|
||
) -> ClaudeNativeToolRelay:
|
||
"""
|
||
Start a relay for Omnigent tool calls from Claude.
|
||
|
||
Writes ``tool_relay.json`` and starts the localhost HTTP server that
|
||
backs it. The caller owns the relay's lifetime (a single turn or a
|
||
whole session) and must call :meth:`ClaudeNativeToolRelay.close` when
|
||
that scope ends.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:param tools: Omnigent tool schemas to advertise, e.g.
|
||
``[{"name": "sys_os_read", "parameters": {...}}]``.
|
||
:param tool_executor: Callback used to dispatch one tool call through
|
||
AP/runner.
|
||
:param loop: Event loop that owns ``tool_executor``.
|
||
:returns: Started relay handle. Call :meth:`close` when the relay's
|
||
scope ends (e.g. on session delete).
|
||
"""
|
||
token = secrets.token_urlsafe(32)
|
||
handler_cls = _tool_relay_handler_factory(token, tool_executor, loop)
|
||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler_cls)
|
||
host, port = httpd.server_address
|
||
relay_info = {
|
||
"url": f"http://{host}:{port}",
|
||
"token": token,
|
||
"tools": _normalize_relay_tool_specs(tools),
|
||
"pid": os.getpid(),
|
||
"updated_at": time.time(),
|
||
}
|
||
_write_json_file(bridge_dir / _TOOL_RELAY_FILE, relay_info)
|
||
thread = threading.Thread(
|
||
target=httpd.serve_forever,
|
||
name="claude-native-tool-relay",
|
||
daemon=True,
|
||
)
|
||
thread.start()
|
||
return ClaudeNativeToolRelay(bridge_dir=bridge_dir, httpd=httpd)
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> None:
|
||
"""
|
||
CLI entrypoint for bridge helper processes.
|
||
|
||
:param argv: Optional argv override excluding program name.
|
||
``None`` reads :data:`sys.argv`.
|
||
:returns: None.
|
||
"""
|
||
args = _parse_args(sys.argv[1:] if argv is None else argv)
|
||
if args.command == "serve-mcp":
|
||
_serve_mcp(Path(args.bridge_dir))
|
||
return
|
||
raise SystemExit(f"unknown command: {args.command}")
|
||
|
||
|
||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||
"""
|
||
Parse bridge helper CLI arguments.
|
||
|
||
:param argv: CLI argv excluding program name, e.g.
|
||
``["serve-mcp", "--bridge-dir", "/tmp/x"]``.
|
||
:returns: Parsed argparse namespace.
|
||
"""
|
||
parser = argparse.ArgumentParser(prog="python -m omnigent.claude_native_bridge")
|
||
sub = parser.add_subparsers(dest="command", required=True)
|
||
serve = sub.add_parser("serve-mcp")
|
||
serve.add_argument("--bridge-dir", required=True)
|
||
return parser.parse_args(argv)
|
||
|
||
|
||
def _serve_mcp(bridge_dir: Path) -> None:
|
||
"""
|
||
Run the MCP stdio server and the local control HTTP endpoint.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:returns: None when stdin closes.
|
||
"""
|
||
os.environ[BRIDGE_DIR_ENV_VAR] = str(bridge_dir)
|
||
config = _read_json_file(bridge_dir / _CONFIG_FILE)
|
||
if not isinstance(config, dict):
|
||
raise SystemExit(f"bridge config missing: {bridge_dir / _CONFIG_FILE}")
|
||
token = config.get("token")
|
||
if not isinstance(token, str) or not token:
|
||
raise SystemExit("bridge config missing token")
|
||
|
||
notification_queue: queue.Queue[dict[str, Any] | None] = queue.Queue()
|
||
stdout_lock = threading.Lock()
|
||
httpd = _start_http_ingress(bridge_dir, token, notification_queue)
|
||
tools, close_tools = _build_tools(config)
|
||
writer = threading.Thread(
|
||
target=_notification_writer,
|
||
args=(notification_queue, stdout_lock),
|
||
name="claude-native-mcp-writer",
|
||
daemon=True,
|
||
)
|
||
writer.start()
|
||
try:
|
||
_stdio_jsonrpc_loop(tools, stdout_lock, bridge_dir)
|
||
finally:
|
||
notification_queue.put(None)
|
||
httpd.shutdown()
|
||
httpd.server_close()
|
||
close_tools()
|
||
|
||
|
||
def _start_http_ingress(
|
||
bridge_dir: Path,
|
||
token: str,
|
||
notification_queue: queue.Queue[dict[str, Any] | None],
|
||
) -> ThreadingHTTPServer:
|
||
"""
|
||
Start the localhost control HTTP server.
|
||
|
||
Currently only serves ``POST /tools-changed``, which queues a
|
||
standard MCP ``notifications/tools/list_changed`` for the stdio
|
||
writer to emit.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:param token: Bearer token used for local requests.
|
||
:param notification_queue: Queue consumed by the MCP stdout
|
||
writer thread.
|
||
:returns: Started :class:`ThreadingHTTPServer`.
|
||
"""
|
||
handler_cls = _handler_factory(token, notification_queue)
|
||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler_cls)
|
||
host, port = httpd.server_address
|
||
server_info = {
|
||
"url": f"http://{host}:{port}",
|
||
"token": token,
|
||
"pid": os.getpid(),
|
||
"updated_at": time.time(),
|
||
}
|
||
_write_json_file(bridge_dir / _SERVER_FILE, server_info)
|
||
thread = threading.Thread(
|
||
target=httpd.serve_forever,
|
||
name="claude-native-mcp-http",
|
||
daemon=True,
|
||
)
|
||
thread.start()
|
||
return httpd
|
||
|
||
|
||
def _handler_factory(
|
||
token: str,
|
||
notification_queue: queue.Queue[dict[str, Any] | None],
|
||
) -> type[BaseHTTPRequestHandler]:
|
||
"""
|
||
Create an HTTP handler class bound to the MCP notification queue.
|
||
|
||
:param token: Bearer token expected in ``Authorization``.
|
||
:param notification_queue: Queue receiving MCP notification
|
||
payloads.
|
||
:returns: A concrete :class:`BaseHTTPRequestHandler` subclass.
|
||
"""
|
||
|
||
class _ControlHandler(BaseHTTPRequestHandler):
|
||
"""HTTP handler for the local MCP control endpoint."""
|
||
|
||
def log_message(self, format: str, *args: Any) -> None:
|
||
"""
|
||
Suppress default HTTP server logging.
|
||
|
||
:param format: Log format string from
|
||
:class:`BaseHTTPRequestHandler`.
|
||
:param args: Format arguments.
|
||
:returns: None.
|
||
"""
|
||
del format, args
|
||
|
||
def do_GET(self) -> None:
|
||
"""
|
||
Serve the local health endpoint.
|
||
|
||
:returns: None.
|
||
"""
|
||
if self.path != "/health":
|
||
self.send_error(HTTPStatus.NOT_FOUND)
|
||
return
|
||
self._send_json({"status": "ok"})
|
||
|
||
def do_POST(self) -> None:
|
||
"""
|
||
Accept the local MCP control POST for tools/list_changed.
|
||
|
||
:returns: None.
|
||
"""
|
||
if self.path != "/tools-changed":
|
||
self.send_error(HTTPStatus.NOT_FOUND)
|
||
return
|
||
if self.headers.get("Authorization") != f"Bearer {token}":
|
||
self.send_error(HTTPStatus.UNAUTHORIZED)
|
||
return
|
||
notification_queue.put(
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"method": "notifications/tools/list_changed",
|
||
"params": {},
|
||
}
|
||
)
|
||
self._send_json({"ok": True})
|
||
|
||
def _send_json(self, payload: dict[str, Any]) -> None:
|
||
"""
|
||
Send a JSON response body.
|
||
|
||
:param payload: JSON-compatible response object.
|
||
:returns: None.
|
||
"""
|
||
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||
self.send_response(HTTPStatus.OK)
|
||
self.send_header("Content-Type", "application/json")
|
||
self.send_header("Content-Length", str(len(raw)))
|
||
self.end_headers()
|
||
self.wfile.write(raw)
|
||
|
||
return _ControlHandler
|
||
|
||
|
||
def _tool_relay_handler_factory(
|
||
token: str,
|
||
tool_executor: ToolExecutor,
|
||
loop: asyncio.AbstractEventLoop,
|
||
) -> type[BaseHTTPRequestHandler]:
|
||
"""
|
||
Create an HTTP handler class for active-turn tool calls.
|
||
|
||
:param token: Bearer token expected in ``Authorization``.
|
||
:param tool_executor: Existing harness callback used to
|
||
dispatch one tool call.
|
||
:param loop: Event loop that owns ``tool_executor``.
|
||
:returns: A concrete :class:`BaseHTTPRequestHandler` subclass.
|
||
"""
|
||
|
||
class _ToolRelayHandler(BaseHTTPRequestHandler):
|
||
"""HTTP handler for active Omnigent tool relay calls."""
|
||
|
||
def log_message(self, format: str, *args: Any) -> None:
|
||
"""
|
||
Suppress default HTTP server logging.
|
||
|
||
:param format: Log format string from
|
||
:class:`BaseHTTPRequestHandler`.
|
||
:param args: Format arguments.
|
||
:returns: None.
|
||
"""
|
||
del format, args
|
||
|
||
def do_POST(self) -> None:
|
||
"""
|
||
Accept one MCP tool call from the Claude helper process.
|
||
|
||
:returns: None.
|
||
"""
|
||
if self.path != "/tool":
|
||
self.send_error(HTTPStatus.NOT_FOUND)
|
||
return
|
||
if self.headers.get("Authorization") != f"Bearer {token}":
|
||
self.send_error(HTTPStatus.UNAUTHORIZED)
|
||
return
|
||
payload = self._read_json_body()
|
||
if payload is None:
|
||
self.send_error(HTTPStatus.BAD_REQUEST)
|
||
return
|
||
name = payload.get("name")
|
||
arguments = payload.get("arguments")
|
||
if not isinstance(name, str) or not name:
|
||
self._send_json(_mcp_error("tool relay request missing name"))
|
||
return
|
||
if not isinstance(arguments, dict):
|
||
arguments = {}
|
||
self._send_json(_run_relay_tool(tool_executor, loop, name, arguments))
|
||
|
||
def _read_json_body(self) -> dict[str, Any] | None:
|
||
"""
|
||
Read and decode a JSON request body.
|
||
|
||
:returns: Parsed JSON object, or ``None`` when the body
|
||
is malformed.
|
||
"""
|
||
length_raw = self.headers.get("Content-Length", "0")
|
||
try:
|
||
length = int(length_raw)
|
||
except ValueError:
|
||
return None
|
||
try:
|
||
payload = json.loads(self.rfile.read(length) or b"{}")
|
||
except json.JSONDecodeError:
|
||
return None
|
||
return payload if isinstance(payload, dict) else None
|
||
|
||
def _send_json(self, payload: dict[str, Any]) -> None:
|
||
"""
|
||
Send a JSON response body.
|
||
|
||
:param payload: JSON-compatible response object.
|
||
:returns: None.
|
||
"""
|
||
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||
self.send_response(HTTPStatus.OK)
|
||
self.send_header("Content-Type", "application/json")
|
||
self.send_header("Content-Length", str(len(raw)))
|
||
self.end_headers()
|
||
self.wfile.write(raw)
|
||
|
||
return _ToolRelayHandler
|
||
|
||
|
||
def _run_relay_tool(
|
||
tool_executor: ToolExecutor,
|
||
loop: asyncio.AbstractEventLoop,
|
||
name: str,
|
||
arguments: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
"""
|
||
Execute one relay tool call on the harness event loop.
|
||
|
||
:param tool_executor: Existing harness callback used to
|
||
dispatch one tool call.
|
||
:param loop: Event loop that owns ``tool_executor``.
|
||
:param name: Tool name, e.g. ``"sys_os_shell"``.
|
||
:param arguments: Decoded tool arguments.
|
||
:returns: MCP tool-call response.
|
||
"""
|
||
future = asyncio.run_coroutine_threadsafe(tool_executor(name, arguments), loop)
|
||
try:
|
||
result = future.result(timeout=_TOOL_CALL_TIMEOUT_S)
|
||
except Exception as exc: # noqa: BLE001 - relay converts callback failures to MCP errors.
|
||
return _mcp_error(f"Omnigent tool dispatch failed: {exc}")
|
||
return _mcp_response_from_tool_result(result)
|
||
|
||
|
||
def _mcp_response_from_tool_result(result: Any) -> dict[str, Any]:
|
||
"""
|
||
Convert a harness tool result into MCP response shape.
|
||
|
||
:param result: Result returned by ``_tool_executor``. Existing
|
||
harnesses usually return a dict, e.g. ``{"result": "ok"}``.
|
||
:returns: MCP tool-call response.
|
||
"""
|
||
payload = result if isinstance(result, dict) else {"result": result}
|
||
response: dict[str, Any] = {
|
||
"content": [{"type": "text", "text": json.dumps(payload)}],
|
||
}
|
||
if payload.get("blocked") is True or ("error" in payload and payload.get("error")):
|
||
response["isError"] = True
|
||
return response
|
||
|
||
|
||
def _notification_writer(
|
||
notification_queue: queue.Queue[dict[str, Any] | None],
|
||
stdout_lock: threading.Lock,
|
||
) -> None:
|
||
"""
|
||
Copy queued MCP notifications to MCP stdout.
|
||
|
||
:param notification_queue: Queue populated by the control HTTP
|
||
endpoint.
|
||
:param stdout_lock: Lock protecting JSON-RPC writes to stdout.
|
||
:returns: None after a ``None`` sentinel.
|
||
"""
|
||
while True:
|
||
payload = notification_queue.get()
|
||
if payload is None:
|
||
return
|
||
_write_jsonrpc(payload, stdout_lock)
|
||
|
||
|
||
def _stdio_jsonrpc_loop(
|
||
tools: dict[str, Tool],
|
||
stdout_lock: threading.Lock,
|
||
bridge_dir: Path,
|
||
) -> None:
|
||
"""
|
||
Run the minimal MCP JSON-RPC stdio loop.
|
||
|
||
:param tools: Omnigent tools exposed over MCP.
|
||
:param stdout_lock: Lock protecting JSON-RPC writes to stdout.
|
||
:param bridge_dir: Bridge directory path used to read the
|
||
active tool relay.
|
||
:returns: None when stdin reaches EOF.
|
||
"""
|
||
use_content_length = False
|
||
while True:
|
||
raw_line = sys.stdin.buffer.readline()
|
||
if raw_line == b"":
|
||
return
|
||
if raw_line.lower().startswith(b"content-length:"):
|
||
use_content_length = True
|
||
try:
|
||
length = int(raw_line.decode("ascii", errors="ignore").split(":", 1)[1].strip())
|
||
except ValueError:
|
||
continue
|
||
while True:
|
||
header = sys.stdin.buffer.readline()
|
||
if header in {b"\r\n", b"\n", b""}:
|
||
break
|
||
if length <= 0:
|
||
continue
|
||
raw_payload = sys.stdin.buffer.read(length)
|
||
if len(raw_payload) != length:
|
||
return
|
||
line = raw_payload.decode("utf-8", errors="replace").strip()
|
||
else:
|
||
line = raw_line.decode("utf-8", errors="replace").strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
message = json.loads(line)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
if not isinstance(message, dict):
|
||
continue
|
||
request_id = message.get("id")
|
||
method = message.get("method")
|
||
if request_id is None or not isinstance(method, str):
|
||
continue
|
||
# Per-request guard: a failure handling ONE request must never tear
|
||
# down the long-lived MCP server (which would surface to Claude Code
|
||
# as ``-32000: Connection closed`` and drop every tool until respawn).
|
||
# Convert any handler exception into a JSON-RPC error response so the
|
||
# offending call fails cleanly and the stdio loop keeps serving. The
|
||
# individual handlers already return ``_mcp_error`` content for
|
||
# expected failures; this catches the unexpected (e.g. a bug in a
|
||
# tool, or an OSError that slipped a narrower except).
|
||
try:
|
||
result = _handle_mcp_request(method, message.get("params"), tools, bridge_dir)
|
||
response: dict[str, Any] = {
|
||
"jsonrpc": "2.0",
|
||
"id": request_id,
|
||
"result": result,
|
||
}
|
||
except Exception as exc: # noqa: BLE001 - top-level loop guard keeps the server alive.
|
||
response = {
|
||
"jsonrpc": "2.0",
|
||
"id": request_id,
|
||
# -32603 is the JSON-RPC 2.0 "Internal error" code.
|
||
"error": {"code": -32603, "message": f"internal error: {exc}"},
|
||
}
|
||
_write_jsonrpc(response, stdout_lock, framed=use_content_length)
|
||
|
||
|
||
def _handle_mcp_request(
|
||
method: str,
|
||
params: Any,
|
||
tools: dict[str, Tool],
|
||
bridge_dir: Path,
|
||
) -> dict[str, Any]:
|
||
"""
|
||
Handle one MCP request.
|
||
|
||
:param method: JSON-RPC method name, e.g. ``"initialize"``.
|
||
:param params: Request params object.
|
||
:param tools: Omnigent tools exposed over MCP.
|
||
:param bridge_dir: Bridge directory path used to read the
|
||
active tool relay.
|
||
:returns: MCP result object.
|
||
"""
|
||
if method == "initialize":
|
||
return {
|
||
"protocolVersion": _MCP_PROTOCOL_VERSION,
|
||
"capabilities": {
|
||
"tools": {"listChanged": True},
|
||
},
|
||
"serverInfo": {
|
||
"name": _MCP_SERVER_NAME,
|
||
"version": "0.1.0",
|
||
},
|
||
"instructions": (
|
||
"Omnigent tools are available as MCP tools when the "
|
||
"active Omnigent turn advertises them; local sys_os_* "
|
||
"tools are available outside an active turn for "
|
||
"workspace file and shell access."
|
||
),
|
||
}
|
||
if method == "tools/list":
|
||
return {"tools": _combined_mcp_tool_schemas(tools, bridge_dir)}
|
||
if method == "tools/call":
|
||
return _call_mcp_tool(params, tools, bridge_dir)
|
||
if method == "ping":
|
||
return {}
|
||
return {}
|
||
|
||
|
||
def _mcp_tool_schema(tool: Tool) -> dict[str, Any]:
|
||
"""
|
||
Convert an Omnigent tool schema into MCP tool-list shape.
|
||
|
||
:param tool: Tool instance, e.g. ``SysOsReadTool``.
|
||
:returns: MCP tool descriptor.
|
||
"""
|
||
schema = tool.get_schema()["function"]
|
||
return {
|
||
"name": schema["name"],
|
||
"description": schema.get("description", ""),
|
||
"inputSchema": schema.get("parameters", {"type": "object", "properties": {}}),
|
||
}
|
||
|
||
|
||
def _combined_mcp_tool_schemas(
|
||
local_tools: dict[str, Tool],
|
||
bridge_dir: Path,
|
||
) -> list[dict[str, Any]]:
|
||
"""
|
||
Return local and active-turn relay tools in MCP list shape.
|
||
|
||
:param local_tools: Tools the bridge can run directly, e.g.
|
||
``{"sys_os_read": SysOsReadTool(...)}``.
|
||
:param bridge_dir: Bridge directory path used to read
|
||
``tool_relay.json``.
|
||
:returns: MCP tool descriptors. Active relay tools override
|
||
local tools with the same name so calls flow through Omnigent and
|
||
appear in the Omnigent event stream during web turns.
|
||
"""
|
||
schemas = {name: _mcp_tool_schema(tool) for name, tool in local_tools.items()}
|
||
for tool_spec in _read_relay_tool_specs(bridge_dir):
|
||
name = tool_spec.get("name")
|
||
if not isinstance(name, str) or not name:
|
||
continue
|
||
schemas[name] = _mcp_tool_schema_from_spec(tool_spec)
|
||
return list(schemas.values())
|
||
|
||
|
||
def _mcp_tool_schema_from_spec(tool_spec: dict[str, Any]) -> dict[str, Any]:
|
||
"""
|
||
Convert an Omnigent tool schema dict into MCP tool-list shape.
|
||
|
||
:param tool_spec: Tool schema from an active harness turn, e.g.
|
||
``{"name": "sys_os_shell", "parameters": {...}}``.
|
||
:returns: MCP tool descriptor.
|
||
"""
|
||
name = tool_spec.get("name")
|
||
description = tool_spec.get("description")
|
||
parameters = tool_spec.get("parameters")
|
||
return {
|
||
"name": name if isinstance(name, str) else "",
|
||
"description": description if isinstance(description, str) else "",
|
||
"inputSchema": parameters if isinstance(parameters, dict) else _empty_object_schema(),
|
||
}
|
||
|
||
|
||
def _call_mcp_tool(
|
||
params: Any,
|
||
tools: dict[str, Tool],
|
||
bridge_dir: Path,
|
||
) -> dict[str, Any]:
|
||
"""
|
||
Execute one MCP tool call.
|
||
|
||
:param params: MCP tool-call params, e.g.
|
||
``{"name": "sys_os_read", "arguments": {"path": "README.md"}}``.
|
||
:param tools: Omnigent tools exposed over MCP.
|
||
:param bridge_dir: Bridge directory path used to read the
|
||
active tool relay.
|
||
:returns: MCP tool-call result.
|
||
"""
|
||
if not isinstance(params, dict):
|
||
return _mcp_error("tool call params must be an object")
|
||
name = params.get("name")
|
||
arguments = params.get("arguments")
|
||
if not isinstance(name, str):
|
||
return _mcp_error(f"unknown tool: {name!r}")
|
||
if not isinstance(arguments, dict):
|
||
arguments = {}
|
||
if name in _read_relay_tool_names(bridge_dir):
|
||
return _call_relay_tool(bridge_dir, name, arguments)
|
||
if name not in tools:
|
||
return _mcp_error(f"unknown tool: {name!r}")
|
||
bridge_config = _read_json_file(bridge_dir / _CONFIG_FILE)
|
||
workspace_raw = bridge_config.get("workspace") if isinstance(bridge_config, dict) else None
|
||
workspace = Path(workspace_raw) if isinstance(workspace_raw, str) and workspace_raw else None
|
||
ctx = ToolContext(
|
||
task_id="claude-native",
|
||
agent_id="claude-native-ui",
|
||
workspace=workspace,
|
||
conversation_id=read_active_session_id(bridge_dir),
|
||
)
|
||
result = tools[name].invoke(json.dumps(arguments), ctx)
|
||
return {"content": [{"type": "text", "text": result}]}
|
||
|
||
|
||
def _read_relay_tool_names(bridge_dir: Path) -> set[str]:
|
||
"""
|
||
Return active relay tool names.
|
||
|
||
:param bridge_dir: Bridge directory path used to read
|
||
``tool_relay.json``.
|
||
:returns: Set of tool names currently advertised by the
|
||
per-turn relay, e.g. ``{"sys_terminal_launch"}``.
|
||
"""
|
||
return {
|
||
name
|
||
for name in (tool_spec.get("name") for tool_spec in _read_relay_tool_specs(bridge_dir))
|
||
if isinstance(name, str) and name
|
||
}
|
||
|
||
|
||
def _read_relay_tool_specs(bridge_dir: Path) -> list[dict[str, Any]]:
|
||
"""
|
||
Return active relay tool schemas.
|
||
|
||
:param bridge_dir: Bridge directory path used to read
|
||
``tool_relay.json``.
|
||
:returns: Normalized tool schema dicts. Missing or malformed
|
||
relay files return an empty list.
|
||
"""
|
||
relay = _read_json_file(bridge_dir / _TOOL_RELAY_FILE)
|
||
raw_tools = relay.get("tools") if isinstance(relay, dict) else None
|
||
if not isinstance(raw_tools, list):
|
||
return []
|
||
return [tool for tool in raw_tools if isinstance(tool, dict)]
|
||
|
||
|
||
def _call_relay_tool(
|
||
bridge_dir: Path,
|
||
name: str,
|
||
arguments: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
"""
|
||
Call the active harness turn's tool relay.
|
||
|
||
:param bridge_dir: Bridge directory path used to read
|
||
``tool_relay.json``.
|
||
:param name: Tool name, e.g. ``"sys_terminal_launch"``.
|
||
:param arguments: Decoded tool arguments.
|
||
:returns: MCP tool-call result.
|
||
"""
|
||
relay = _read_json_file(bridge_dir / _TOOL_RELAY_FILE)
|
||
token = relay.get("token")
|
||
url = relay.get("url")
|
||
if not isinstance(token, str) or not isinstance(url, str):
|
||
return _mcp_error("active Omnigent tool relay is missing url/token")
|
||
payload = json.dumps({"name": name, "arguments": arguments}).encode("utf-8")
|
||
req = request.Request(
|
||
f"{url}/tool",
|
||
data=payload,
|
||
method="POST",
|
||
headers={
|
||
"Authorization": f"Bearer {token}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
)
|
||
try:
|
||
with request.urlopen(req, timeout=_TOOL_RELAY_POST_TIMEOUT_S) as resp:
|
||
raw = resp.read().decode("utf-8")
|
||
if resp.status >= 400:
|
||
return _mcp_error(f"tool relay POST failed with HTTP {resp.status}")
|
||
# ``OSError`` (the base of ``error.URLError``) also covers the bare
|
||
# timeout / reset errors that ``urlopen`` raises mid-read —
|
||
# ``TimeoutError``, ``socket.timeout``, ``ConnectionResetError`` — which
|
||
# are NOT ``URLError`` instances. Catching the base class returns a clean
|
||
# MCP error for all of them instead of letting the exception propagate up
|
||
# through ``_call_mcp_tool`` → ``_stdio_jsonrpc_loop`` and kill the MCP
|
||
# server.
|
||
except OSError as exc:
|
||
return _mcp_error(f"failed to call Omnigent tool relay: {exc}")
|
||
try:
|
||
decoded = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
return _mcp_error("Omnigent tool relay returned malformed JSON")
|
||
if not isinstance(decoded, dict):
|
||
return _mcp_error("Omnigent tool relay returned non-object JSON")
|
||
return decoded
|
||
|
||
|
||
def _mcp_error(message: str) -> dict[str, Any]:
|
||
"""
|
||
Build an MCP error-content tool result.
|
||
|
||
:param message: Human-readable error message.
|
||
:returns: MCP tool-call result marked as an error.
|
||
"""
|
||
return {"content": [{"type": "text", "text": json.dumps({"error": message})}], "isError": True}
|
||
|
||
|
||
def _normalize_relay_tool_specs(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
"""
|
||
Normalize active-turn tool schemas before advertising them.
|
||
|
||
:param tools: Tool schema dicts from the harness request, e.g.
|
||
``[{"name": "sys_os_read", "parameters": {...}}]``.
|
||
:returns: Schemas containing only fields the MCP bridge needs.
|
||
"""
|
||
normalized: list[dict[str, Any]] = []
|
||
for tool in tools:
|
||
name = tool.get("name")
|
||
if not isinstance(name, str) or not name:
|
||
continue
|
||
description = tool.get("description")
|
||
parameters = tool.get("parameters")
|
||
normalized.append(
|
||
{
|
||
"name": name,
|
||
"description": description if isinstance(description, str) else "",
|
||
"parameters": (
|
||
parameters if isinstance(parameters, dict) else _empty_object_schema()
|
||
),
|
||
}
|
||
)
|
||
return normalized
|
||
|
||
|
||
def _empty_object_schema() -> dict[str, Any]:
|
||
"""
|
||
Return a minimal JSON object schema.
|
||
|
||
:returns: ``{"type": "object", "properties": {}}``.
|
||
"""
|
||
return {"type": "object", "properties": {}}
|
||
|
||
|
||
def _build_tools(config: dict[str, Any]) -> tuple[dict[str, Tool], Callable[[], None]]:
|
||
"""
|
||
Build Omnigent MCP tools served by the bridge.
|
||
|
||
:param config: Bridge config JSON object.
|
||
:returns: ``(tools, close_tools)`` where ``close_tools``
|
||
releases any helper processes.
|
||
"""
|
||
workspace_raw = config.get("workspace")
|
||
workspace = Path(workspace_raw) if isinstance(workspace_raw, str) and workspace_raw else None
|
||
os_env: OSEnvironment | None = None
|
||
if workspace is not None:
|
||
spec = OSEnvSpec(
|
||
type="caller_process",
|
||
cwd=str(workspace),
|
||
sandbox=OSEnvSandboxSpec(type="none"),
|
||
fork=False,
|
||
)
|
||
os_env = create_os_environment(spec)
|
||
tools = {tool.name(): tool for tool in build_os_env_tools(os_env)} if os_env else {}
|
||
|
||
def _close_tools() -> None:
|
||
"""Close helper resources owned by this bridge server."""
|
||
if os_env is not None:
|
||
os_env.close()
|
||
|
||
return tools, _close_tools
|
||
|
||
|
||
def _write_jsonrpc(
|
||
payload: dict[str, Any],
|
||
stdout_lock: threading.Lock,
|
||
*,
|
||
framed: bool = False,
|
||
) -> None:
|
||
"""
|
||
Write one JSON-RPC message to stdout.
|
||
|
||
:param payload: JSON-RPC object to serialize.
|
||
:param stdout_lock: Lock protecting stdout.
|
||
:param framed: When ``True``, write MCP ``Content-Length`` framed output.
|
||
:returns: None.
|
||
"""
|
||
raw = json.dumps(payload, separators=(",", ":"))
|
||
with stdout_lock:
|
||
if framed:
|
||
encoded = raw.encode("utf-8")
|
||
sys.stdout.buffer.write(f"Content-Length: {len(encoded)}\r\n\r\n".encode("ascii"))
|
||
sys.stdout.buffer.write(encoded)
|
||
sys.stdout.buffer.flush()
|
||
else:
|
||
print(raw, flush=True)
|
||
|
||
|
||
def _model_from_transcript_entry(entry: dict[str, Any]) -> str | None:
|
||
"""
|
||
Return ``message.model`` from an assistant transcript record.
|
||
|
||
Surfaced on :class:`TranscriptReadResult.latest_model` for
|
||
diagnostics. The ring's denominator comes from the statusLine
|
||
stdin (see :func:`read_claude_context_state`); the JSONL model
|
||
name is no longer used to size the ring.
|
||
|
||
:param entry: One decoded transcript JSONL record.
|
||
:returns: API model name, or ``None`` for non-assistant entries
|
||
and entries missing the field.
|
||
"""
|
||
message = entry.get("message")
|
||
if not isinstance(message, dict) or message.get("role") != "assistant":
|
||
return None
|
||
model = message.get("model")
|
||
if isinstance(model, str) and model:
|
||
return model
|
||
return None
|
||
|
||
|
||
def read_claude_context_state(bridge_dir: Path) -> dict[str, Any] | None:
|
||
"""
|
||
Read the most recent statusLine snapshot from ``context.json``.
|
||
|
||
Written atomically by :mod:`omnigent.claude_native_status` each
|
||
time Claude Code invokes the wrapped statusLine command. The file
|
||
is the authoritative source for both the ring's denominator
|
||
(``context_window_size`` — Claude Code knows the real window for
|
||
the active model and beta tier) and an optional fresh
|
||
``current_usage`` block.
|
||
|
||
:param bridge_dir: Bridge directory shared with the forwarder.
|
||
:returns: Parsed dict with keys ``context_window_size`` (int) and
|
||
optionally ``current_usage`` (dict). ``None`` when the file
|
||
doesn't exist yet, is unreadable, or doesn't carry a usable
|
||
window — the forwarder treats that as "no update".
|
||
"""
|
||
path = bridge_dir / _CONTEXT_FILE
|
||
try:
|
||
raw = path.read_text(encoding="utf-8")
|
||
except OSError:
|
||
return None
|
||
try:
|
||
parsed = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
return None
|
||
if not isinstance(parsed, dict):
|
||
return None
|
||
size = parsed.get("context_window_size")
|
||
if not isinstance(size, int) or size <= 0:
|
||
return None
|
||
return parsed
|
||
|
||
|
||
def read_claude_status_model(bridge_dir: Path) -> str | None:
|
||
"""
|
||
Read the active model id from the statusLine snapshot ``context.json``.
|
||
|
||
Unlike :func:`read_claude_context_state` (which returns ``None`` unless a
|
||
usable ``context_window_size`` is present, since it backs the context
|
||
ring), this returns the model whenever the wrapper captured one — the
|
||
model and the window are written independently, and the cost-budget gate
|
||
needs the model even on a render where the window field was absent. This
|
||
is claude-native's race-free, gate-time source of the live ``/model``
|
||
selection (the analogue of the codex hook reading ``config.toml``).
|
||
|
||
:param bridge_dir: Bridge directory shared with the statusLine wrapper.
|
||
:returns: The model id, e.g. ``"claude-sonnet-4-6"``, or ``None`` when
|
||
the file is missing / unreadable / carries no model string.
|
||
"""
|
||
path = bridge_dir / _CONTEXT_FILE
|
||
try:
|
||
parsed = json.loads(path.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError):
|
||
return None
|
||
if not isinstance(parsed, dict):
|
||
return None
|
||
model = parsed.get("model")
|
||
return model if isinstance(model, str) and model else None
|
||
|
||
|
||
def read_user_status_line_command() -> str | None:
|
||
"""
|
||
Return the user's globally-configured statusLine shell command, if any.
|
||
|
||
We override Claude Code's statusLine in our per-session ``--settings``
|
||
to capture ``context_window`` stdin. To avoid breaking the user's
|
||
pre-existing status bar (typically claude-hud), the wrapper chains
|
||
to whatever they had configured globally.
|
||
|
||
:returns: The command string from
|
||
``~/.claude/settings.json``'s ``statusLine.command``, or
|
||
``None`` when no global statusLine is configured / readable.
|
||
"""
|
||
try:
|
||
raw = _USER_CLAUDE_SETTINGS_PATH.read_text(encoding="utf-8")
|
||
except OSError:
|
||
return None
|
||
try:
|
||
parsed = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
return None
|
||
if not isinstance(parsed, dict):
|
||
return None
|
||
status_line = parsed.get("statusLine")
|
||
if not isinstance(status_line, dict):
|
||
return None
|
||
command = status_line.get("command")
|
||
if isinstance(command, str) and command.strip():
|
||
return command
|
||
return None
|
||
|
||
|
||
def read_user_effort_level() -> str | None:
|
||
"""
|
||
Return the user's configured Claude Code effort level, if any.
|
||
|
||
Read client-side from ``effortLevel`` in ``~/.claude/settings.json`` —
|
||
the level the wrapped ``claude`` actually runs at (we pass no ``--effort``).
|
||
|
||
:returns: A recognized effort, e.g. ``"medium"``; ``None`` when unset,
|
||
unreadable, or not a valid Claude effort (fail-soft, never blocks launch).
|
||
"""
|
||
try:
|
||
raw = _USER_CLAUDE_SETTINGS_PATH.read_text(encoding="utf-8")
|
||
except OSError:
|
||
return None
|
||
try:
|
||
parsed = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
return None
|
||
if not isinstance(parsed, dict):
|
||
return None
|
||
effort = parsed.get("effortLevel")
|
||
if isinstance(effort, str) and effort in CLAUDE_EFFORTS:
|
||
return effort
|
||
return None
|
||
|
||
|
||
def _usage_from_transcript_entry(entry: dict[str, Any]) -> dict[str, int] | None:
|
||
"""
|
||
Extract token-usage from one Claude assistant transcript entry.
|
||
|
||
``context_tokens`` is ``input + cache_creation + cache_read`` — the
|
||
bytes that will reappear in the next call's prompt. Output tokens
|
||
are reported separately since they don't shift the prompt forward.
|
||
|
||
:param entry: One decoded transcript JSONL record.
|
||
:returns: ``{"context_tokens", "input_tokens", "output_tokens"}``
|
||
when the record is an assistant entry with usage; ``None``
|
||
otherwise.
|
||
"""
|
||
message = entry.get("message")
|
||
if not isinstance(message, dict) or message.get("role") != "assistant":
|
||
return None
|
||
usage = message.get("usage")
|
||
if not isinstance(usage, dict):
|
||
return None
|
||
input_tokens = usage.get("input_tokens")
|
||
output_tokens = usage.get("output_tokens")
|
||
cache_creation = usage.get("cache_creation_input_tokens")
|
||
cache_read = usage.get("cache_read_input_tokens")
|
||
if not isinstance(input_tokens, int):
|
||
return None
|
||
if not isinstance(output_tokens, int):
|
||
output_tokens = 0
|
||
cc = cache_creation if isinstance(cache_creation, int) else 0
|
||
cr = cache_read if isinstance(cache_read, int) else 0
|
||
result: dict[str, int] = {
|
||
"context_tokens": input_tokens + cc + cr,
|
||
"input_tokens": input_tokens,
|
||
"output_tokens": output_tokens,
|
||
}
|
||
if cc:
|
||
result["cache_creation_input_tokens"] = cc
|
||
if cr:
|
||
result["cache_read_input_tokens"] = cr
|
||
return result
|
||
|
||
|
||
def _assistant_text_from_transcript_line(line: str) -> str | None:
|
||
"""
|
||
Extract assistant text from one Claude transcript JSONL line.
|
||
|
||
:param line: Raw JSONL record.
|
||
:returns: Assistant text, or ``None`` when the record is not an
|
||
assistant text message.
|
||
"""
|
||
try:
|
||
entry = json.loads(line)
|
||
except json.JSONDecodeError:
|
||
return None
|
||
if not isinstance(entry, dict):
|
||
return None
|
||
message = entry.get("message")
|
||
if not isinstance(message, dict) or message.get("role") != "assistant":
|
||
return None
|
||
content = message.get("content")
|
||
if isinstance(content, str):
|
||
return content
|
||
if not isinstance(content, list):
|
||
return None
|
||
parts: list[str] = []
|
||
for block in content:
|
||
if isinstance(block, dict) and block.get("type") == "text":
|
||
text = block.get("text")
|
||
if isinstance(text, str) and text:
|
||
parts.append(text)
|
||
return "".join(parts) or None
|
||
|
||
|
||
def _transcript_items_from_entry(
|
||
entry: dict[str, Any],
|
||
*,
|
||
line_number: int,
|
||
record_offset: int | None = None,
|
||
agent_name: str,
|
||
current_response_id: str | None,
|
||
include_sidechains: bool = False,
|
||
) -> tuple[str | None, list[ClaudeTranscriptItem]]:
|
||
"""
|
||
Convert one Claude transcript entry into Omnigent conversation items.
|
||
|
||
:param entry: Decoded JSON object from one transcript line.
|
||
:param line_number: One-based transcript line number.
|
||
:param record_offset: Byte offset where the transcript record
|
||
starts. Used for stable fallback source ids when Claude omits
|
||
``uuid`` and ``requestId``.
|
||
:param agent_name: Agent/model name for assistant/tool items.
|
||
:param current_response_id: Response id for the active Claude
|
||
assistant turn, if a previous poll already started one.
|
||
:param include_sidechains: When ``False`` (the default) any record
|
||
with ``isSidechain: true`` is dropped — that's the right
|
||
behavior when reading the parent's main transcript, where
|
||
sub-agent records are inlined as sidechains and must not
|
||
appear in the parent's Omnigent conversation. When ``True`` the
|
||
flag is ignored — required when reading a sub-agent's own
|
||
``agent-<id>.jsonl`` (every record there is a sidechain by
|
||
definition) so the sub-agent's items reach the child AP
|
||
conversation. Caller is responsible for matching the flag to
|
||
the file shape.
|
||
:returns: Updated active response id and parsed items.
|
||
"""
|
||
if not include_sidechains and entry.get("isSidechain") is True:
|
||
return current_response_id, []
|
||
if entry.get("type") == "attachment":
|
||
return _attachment_transcript_items_from_entry(
|
||
entry,
|
||
line_number=line_number,
|
||
record_offset=record_offset,
|
||
current_response_id=current_response_id,
|
||
)
|
||
if entry.get("subtype") == "local_command":
|
||
return _local_command_transcript_items_from_entry(
|
||
entry,
|
||
line_number=line_number,
|
||
record_offset=record_offset,
|
||
current_response_id=current_response_id,
|
||
)
|
||
message = entry.get("message")
|
||
if not isinstance(message, dict):
|
||
return current_response_id, []
|
||
role = message.get("role")
|
||
if role == "user":
|
||
return _user_transcript_items_from_entry(
|
||
entry,
|
||
line_number=line_number,
|
||
record_offset=record_offset,
|
||
agent_name=agent_name,
|
||
current_response_id=current_response_id,
|
||
)
|
||
if role == "assistant":
|
||
return _assistant_transcript_items_from_entry(
|
||
entry,
|
||
line_number=line_number,
|
||
record_offset=record_offset,
|
||
agent_name=agent_name,
|
||
current_response_id=current_response_id,
|
||
)
|
||
return current_response_id, []
|
||
|
||
|
||
def _attachment_transcript_items_from_entry(
|
||
entry: dict[str, Any],
|
||
*,
|
||
line_number: int,
|
||
record_offset: int | None,
|
||
current_response_id: str | None,
|
||
) -> tuple[str | None, list[ClaudeTranscriptItem]]:
|
||
"""
|
||
Parse user-visible Claude attachment transcript entries.
|
||
|
||
Claude records prompts typed while an assistant turn is busy as
|
||
``attachment.type == "queued_command"`` rather than a normal
|
||
``role=user`` message. Treat prompt-mode queued commands as user
|
||
messages so interruption inputs such as ``"STOP"`` appear in the
|
||
Omnigent transcript and reset the active assistant response.
|
||
|
||
:param entry: Decoded Claude transcript record.
|
||
:param line_number: One-based transcript line number.
|
||
:param record_offset: Byte offset where the transcript record
|
||
starts, or ``None`` for legacy line-cursor reads.
|
||
:param current_response_id: Response id for the active assistant
|
||
turn. Ignored attachment metadata preserves this value.
|
||
:returns: Updated active response id and parsed items.
|
||
"""
|
||
attachment = entry.get("attachment")
|
||
if not isinstance(attachment, dict):
|
||
return current_response_id, []
|
||
if attachment.get("type") != "queued_command":
|
||
return current_response_id, []
|
||
if attachment.get("commandMode") != "prompt":
|
||
return current_response_id, []
|
||
prompt = attachment.get("prompt")
|
||
if not isinstance(prompt, str) or not prompt:
|
||
return current_response_id, []
|
||
source_key = _transcript_source_key(entry, line_number, record_offset)
|
||
item = ClaudeTranscriptItem(
|
||
source_id=_source_id(source_key, 0, "message"),
|
||
item_type="message",
|
||
data={
|
||
"role": "user",
|
||
"content": [{"type": "input_text", "text": prompt}],
|
||
},
|
||
response_id=_response_id_from_source(source_key),
|
||
)
|
||
return None, [item]
|
||
|
||
|
||
# Slash-command marker handling. Claude Code's embedded TUI emits
|
||
# multiple ``role=user`` records per operator action: a ``<command-
|
||
# name>`` echo, a sibling ``isMeta=true`` ``<local-command-caveat>``,
|
||
# a follow-up ``<local-command-stdout>`` (and friends), plus
|
||
# ``<bash-*>`` records when the operator types ``!cmd``. All are
|
||
# CLI scaffolding, not user-typed content — rendering any of them as
|
||
# a user bubble shows raw markup to a web viewer.
|
||
# Today: drop isMeta + every CLI-scaffolding-prefixed record; for
|
||
# ``<command-name>`` records also surface Skills as ``slash_command``
|
||
# items. The original blanket drop was reverted because it
|
||
# hid Skills; we keep the broad scaffolding filter and just
|
||
# selectively re-surface the Skill case.
|
||
_COMMAND_NAME_RE = re.compile(r"<command-name>(.*?)</command-name>", re.DOTALL)
|
||
_COMMAND_ARGS_RE = re.compile(r"<command-args>(.*?)</command-args>", re.DOTALL)
|
||
_COMMAND_STDOUT_RE = re.compile(r"<local-command-stdout>(.*?)</local-command-stdout>", re.DOTALL)
|
||
_BASH_INPUT_RE = re.compile(r"<bash-input>(.*?)</bash-input>", re.DOTALL)
|
||
_BASH_STDOUT_RE = re.compile(r"<bash-stdout>(.*?)</bash-stdout>", re.DOTALL)
|
||
_BASH_STDERR_RE = re.compile(r"<bash-stderr>(.*?)</bash-stderr>", re.DOTALL)
|
||
|
||
# Markers that prefix a ``role=user`` record produced by Claude
|
||
# Code's CLI scaffolding (not user-typed content). ``<command-
|
||
# name>`` is handled separately by the slash-command parser;
|
||
# ``<bash-*>`` records are handled separately as ``terminal_command``
|
||
# items; the rest must always drop.
|
||
_CLI_SCAFFOLDING_MARKERS: tuple[str, ...] = (
|
||
"<command-message>",
|
||
"<command-args>",
|
||
"<local-command-caveat>",
|
||
"<local-command-stdout>",
|
||
"<local-command-stderr>",
|
||
)
|
||
|
||
# Claude Code's CLI built-ins (no leading ``/``). Each name is
|
||
# classified as either:
|
||
#
|
||
# - DROPPED — pure UI affordances (``/help``, ``/login``) and local
|
||
# config (``/permissions``, ``/add-dir``). No conversation-visible
|
||
# effect; surfacing them in the web UI is noise.
|
||
# - SURFACED — commands that change the next turn's behavior or the
|
||
# conversation state (``/effort high``, ``/clear``, ``/compact``,
|
||
# ``/model``, ``/ultrareview``). A web observer needs to see these,
|
||
# otherwise the next assistant turn appears to shift unprompted.
|
||
#
|
||
# Unknown names fall through to the Skill branch — a safer default
|
||
# than silently hiding them.
|
||
_CLAUDE_CLI_DROPPED_COMMANDS: frozenset[str] = frozenset(
|
||
{
|
||
"add-dir",
|
||
"agents",
|
||
"bug",
|
||
"config",
|
||
"cost",
|
||
"doctor",
|
||
"exit",
|
||
"fast",
|
||
"feedback",
|
||
"help",
|
||
"hooks",
|
||
"ide",
|
||
"login",
|
||
"logout",
|
||
"mcp",
|
||
"memory",
|
||
"onboarding",
|
||
"permissions",
|
||
"plugin",
|
||
"quiet",
|
||
"quit",
|
||
"release-notes",
|
||
"resume",
|
||
"save",
|
||
"status",
|
||
"terminal-setup",
|
||
"upgrade",
|
||
"verbose",
|
||
}
|
||
)
|
||
_CLAUDE_CLI_SURFACED_COMMANDS: frozenset[str] = frozenset(
|
||
{
|
||
"clear",
|
||
"compact",
|
||
"effort",
|
||
"model",
|
||
"ultrareview",
|
||
}
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _SlashCommandPayload:
|
||
"""
|
||
Parsed content of a slash-command ``role=user`` transcript record.
|
||
|
||
:param name: Command name with leading ``/`` stripped, e.g.
|
||
``"dev-productivity:simplify"``.
|
||
:param arguments: Verbatim ``<command-args>`` text; empty when none.
|
||
:param output: Verbatim ``<local-command-stdout>`` text, or ``None``.
|
||
"""
|
||
|
||
name: str
|
||
arguments: str
|
||
output: str | None
|
||
|
||
|
||
def _parse_slash_command_record(content: str) -> _SlashCommandPayload | None:
|
||
"""
|
||
Parse a Claude Code slash-command marker blob.
|
||
|
||
Returns ``None`` on a missing/empty/unclosed ``<command-name>``
|
||
tag rather than raising — a single corrupt JSONL line must not
|
||
kill the transcript poll loop.
|
||
|
||
:param content: ``message.content`` string from a ``role=user``
|
||
Claude Code JSONL record.
|
||
:returns: Parsed payload, or ``None`` when no name could be
|
||
extracted.
|
||
"""
|
||
name_match = _COMMAND_NAME_RE.search(content)
|
||
if name_match is None:
|
||
return None
|
||
raw_name = name_match.group(1).strip()
|
||
if not raw_name:
|
||
return None
|
||
# Strip leading ``/`` so renderers can add their own prefix without double-rendering.
|
||
name = raw_name.lstrip("/")
|
||
if not name:
|
||
return None
|
||
args_match = _COMMAND_ARGS_RE.search(content)
|
||
arguments = args_match.group(1).strip() if args_match else ""
|
||
stdout_match = _COMMAND_STDOUT_RE.search(content)
|
||
output = stdout_match.group(1) if stdout_match else None
|
||
return _SlashCommandPayload(name=name, arguments=arguments, output=output)
|
||
|
||
|
||
def _local_command_transcript_items_from_entry(
|
||
entry: dict[str, Any],
|
||
*,
|
||
line_number: int,
|
||
record_offset: int | None,
|
||
current_response_id: str | None,
|
||
) -> tuple[str | None, list[ClaudeTranscriptItem]]:
|
||
"""
|
||
Parse a top-level Claude ``local_command`` transcript entry.
|
||
|
||
Newer Claude Code builds can record shell-mode ``!cmd`` activity
|
||
as top-level transcript records with ``subtype="local_command"``
|
||
and a string ``content`` field instead of wrapping the same markup
|
||
inside ``message.role=user``. Only ``<bash-*>`` records are
|
||
conversation-visible here; slash-command local records are still
|
||
handled by hook/fork detection and otherwise ignored.
|
||
|
||
:param entry: Decoded Claude transcript record.
|
||
:param line_number: One-based transcript line number.
|
||
:param record_offset: Byte offset where the transcript record
|
||
starts, or ``None`` for legacy line-cursor reads.
|
||
:param current_response_id: Response id for an in-progress shell
|
||
command group, if the input record was parsed in an earlier
|
||
line.
|
||
:returns: Updated active response id and parsed terminal-command
|
||
items.
|
||
"""
|
||
content = entry.get("content")
|
||
if not isinstance(content, str) or not content:
|
||
return current_response_id, []
|
||
source_key = _transcript_source_key(entry, line_number, record_offset)
|
||
fallback_response_id = _response_id_from_source(source_key)
|
||
response_id = (
|
||
fallback_response_id
|
||
if _BASH_INPUT_RE.search(content) is not None
|
||
else current_response_id or fallback_response_id
|
||
)
|
||
items = _terminal_command_items_from_content(
|
||
content,
|
||
source_key=source_key,
|
||
response_id=response_id,
|
||
)
|
||
if not items:
|
||
return current_response_id, []
|
||
return response_id, items
|
||
|
||
|
||
def _terminal_command_items_from_content(
|
||
content: str,
|
||
*,
|
||
source_key: str,
|
||
response_id: str,
|
||
) -> list[ClaudeTranscriptItem]:
|
||
"""
|
||
Parse Claude shell-mode markup into terminal-command items.
|
||
|
||
Claude may emit shell input and output as separate records or as
|
||
one record containing multiple ``<bash-*>`` tags. This helper
|
||
emits at most one input item and one output item, preserving their
|
||
order in the source record and giving both the same response id so
|
||
the server transcript groups an invocation with its result.
|
||
|
||
:param content: Transcript markup, e.g.
|
||
``"<bash-input>pwd</bash-input><bash-stdout>/tmp</bash-stdout>"``.
|
||
:param source_key: Base transcript record key used to construct
|
||
source ids, e.g. ``"rec_abc123"``.
|
||
:param response_id: Synthetic response id for this terminal
|
||
command group, e.g. ``"resp_claude_abc123"``.
|
||
:returns: Parsed ``terminal_command`` items. Empty when no shell
|
||
markers are present.
|
||
"""
|
||
if not any(marker in content for marker in ("<bash-input>", "<bash-stdout>", "<bash-stderr>")):
|
||
return []
|
||
input_match = _BASH_INPUT_RE.search(content)
|
||
stdout_match = _BASH_STDOUT_RE.search(content)
|
||
stderr_match = _BASH_STDERR_RE.search(content)
|
||
items: list[ClaudeTranscriptItem] = []
|
||
item_index = 0
|
||
if input_match is not None:
|
||
items.append(
|
||
ClaudeTranscriptItem(
|
||
source_id=_source_id(source_key, item_index, "terminal_command"),
|
||
item_type="terminal_command",
|
||
data={"kind": "input", "input": input_match.group(1)},
|
||
response_id=response_id,
|
||
)
|
||
)
|
||
item_index += 1
|
||
if stdout_match is not None or stderr_match is not None:
|
||
items.append(
|
||
ClaudeTranscriptItem(
|
||
source_id=_source_id(source_key, item_index, "terminal_command"),
|
||
item_type="terminal_command",
|
||
data={
|
||
"kind": "output",
|
||
"stdout": stdout_match.group(1) if stdout_match is not None else None,
|
||
"stderr": stderr_match.group(1) if stderr_match is not None else None,
|
||
},
|
||
response_id=response_id,
|
||
)
|
||
)
|
||
return items
|
||
|
||
|
||
def _user_transcript_items_from_entry(
|
||
entry: dict[str, Any],
|
||
*,
|
||
line_number: int,
|
||
record_offset: int | None,
|
||
agent_name: str,
|
||
current_response_id: str | None,
|
||
) -> tuple[str | None, list[ClaudeTranscriptItem]]:
|
||
"""
|
||
Parse a Claude ``role=user`` transcript entry.
|
||
|
||
:param entry: Decoded Claude transcript record.
|
||
:param line_number: One-based transcript line number.
|
||
:param record_offset: Byte offset where the transcript record
|
||
starts, or ``None`` for legacy line-cursor reads.
|
||
:param agent_name: Agent/model name attached to ``slash_command``
|
||
items so the web UI can attribute the invocation.
|
||
:param current_response_id: Response id for the active assistant
|
||
turn; tool results keep this id.
|
||
:returns: Updated active response id and parsed user/tool-result
|
||
items.
|
||
"""
|
||
# ``isMeta=true`` carries CLI scaffolding like
|
||
# ``<local-command-caveat>``; no user-visible content.
|
||
if entry.get("isMeta") is True:
|
||
return current_response_id, []
|
||
message = entry["message"]
|
||
content = message.get("content") if isinstance(message, dict) else None
|
||
source_key = _transcript_source_key(entry, line_number, record_offset)
|
||
fallback_response_id = _response_id_from_source(source_key)
|
||
items: list[ClaudeTranscriptItem] = []
|
||
|
||
if isinstance(content, str):
|
||
if not content:
|
||
return current_response_id, []
|
||
stripped = content.lstrip()
|
||
# Skill invocations with args ship the tag order
|
||
# ``<command-message>…<command-name>…<command-args>…`` — i.e.
|
||
# ``<command-name>`` is NOT the first tag. Detect it anywhere
|
||
# in the content, not just at the start.
|
||
if "<command-name>" in stripped:
|
||
payload = _parse_slash_command_record(content)
|
||
# Drop unparseable markup rather than letting it fall through
|
||
# to the user-bubble path — that rendered the markup verbatim
|
||
# in the original bug.
|
||
if payload is None or payload.name in _CLAUDE_CLI_DROPPED_COMMANDS:
|
||
return current_response_id, []
|
||
kind = "command" if payload.name in _CLAUDE_CLI_SURFACED_COMMANDS else "skill"
|
||
data: dict[str, Any] = {
|
||
"agent": agent_name,
|
||
"kind": kind,
|
||
"name": payload.name,
|
||
"arguments": payload.arguments,
|
||
}
|
||
if payload.output is not None:
|
||
data["output"] = payload.output
|
||
items.append(
|
||
ClaudeTranscriptItem(
|
||
source_id=_source_id(source_key, 0, "slash_command"),
|
||
item_type="slash_command",
|
||
data=data,
|
||
response_id=fallback_response_id,
|
||
)
|
||
)
|
||
# Slash command opens a new logical turn; subsequent
|
||
# assistant text must inherit this id so it clusters with
|
||
# the indicator, not the prior bubble.
|
||
return fallback_response_id, items
|
||
# ``!cmd`` terminal commands may arrive here in older Claude
|
||
# builds; newer builds use top-level ``local_command`` records.
|
||
# In both shapes, surface the command and result as their own
|
||
# transcript group instead of inheriting the previous assistant
|
||
# response id.
|
||
terminal_response_id = (
|
||
fallback_response_id
|
||
if _BASH_INPUT_RE.search(content) is not None
|
||
else current_response_id or fallback_response_id
|
||
)
|
||
terminal_items = _terminal_command_items_from_content(
|
||
content,
|
||
source_key=source_key,
|
||
response_id=terminal_response_id,
|
||
)
|
||
if terminal_items:
|
||
return terminal_response_id, terminal_items
|
||
# Other CLI-scaffolding records (stdout/stderr from /effort, etc.)
|
||
# arrive as standalone ``role=user`` records and must drop instead
|
||
# of leaking as user bubbles.
|
||
if any(stripped.startswith(m) for m in _CLI_SCAFFOLDING_MARKERS):
|
||
return current_response_id, []
|
||
items.append(
|
||
ClaudeTranscriptItem(
|
||
source_id=_source_id(source_key, 0, "message"),
|
||
item_type="message",
|
||
data={
|
||
"role": "user",
|
||
"content": [{"type": "input_text", "text": content}],
|
||
},
|
||
response_id=fallback_response_id,
|
||
)
|
||
)
|
||
return None, items
|
||
|
||
if not isinstance(content, list):
|
||
return current_response_id, []
|
||
|
||
user_blocks: list[dict[str, Any]] = []
|
||
saw_user_text = False
|
||
item_index = 0
|
||
for block in content:
|
||
if not isinstance(block, dict):
|
||
continue
|
||
block_type = block.get("type")
|
||
if block_type == "text":
|
||
text = block.get("text")
|
||
if not isinstance(text, str) or not text:
|
||
continue
|
||
# Defensively guard against slash-command markup or other
|
||
# CLI-scaffolding markers ever arriving in list-form
|
||
# content. Today these only ship in string content (the
|
||
# branch above), but Claude Code's JSONL format is not
|
||
# under our control — without this filter, a format
|
||
# change would regress to rendering ``<command-name>…``
|
||
# markup as a user bubble.
|
||
stripped = text.lstrip()
|
||
if "<command-name>" in stripped or any(
|
||
stripped.startswith(m) for m in _CLI_SCAFFOLDING_MARKERS
|
||
):
|
||
continue
|
||
user_blocks.append({"type": "input_text", "text": text})
|
||
saw_user_text = True
|
||
continue
|
||
if block_type != "tool_result":
|
||
continue
|
||
call_id = block.get("tool_use_id")
|
||
if not isinstance(call_id, str) or not call_id:
|
||
continue
|
||
response_id = current_response_id or _response_id_from_source(
|
||
_parent_or_record_source_key(entry, line_number, record_offset)
|
||
)
|
||
items.append(
|
||
ClaudeTranscriptItem(
|
||
source_id=_source_id(source_key, item_index, "function_call_output"),
|
||
item_type="function_call_output",
|
||
data={
|
||
"call_id": call_id,
|
||
"output": _tool_result_output(entry, block),
|
||
},
|
||
response_id=response_id,
|
||
)
|
||
)
|
||
item_index += 1
|
||
|
||
if user_blocks:
|
||
items.insert(
|
||
0,
|
||
ClaudeTranscriptItem(
|
||
source_id=_source_id(source_key, item_index, "message"),
|
||
item_type="message",
|
||
data={
|
||
"role": "user",
|
||
"content": user_blocks,
|
||
},
|
||
response_id=fallback_response_id,
|
||
),
|
||
)
|
||
return (None if saw_user_text else current_response_id), items
|
||
|
||
|
||
def _assistant_transcript_items_from_entry(
|
||
entry: dict[str, Any],
|
||
*,
|
||
line_number: int,
|
||
record_offset: int | None,
|
||
agent_name: str,
|
||
current_response_id: str | None,
|
||
) -> tuple[str | None, list[ClaudeTranscriptItem]]:
|
||
"""
|
||
Parse a Claude ``role=assistant`` transcript entry.
|
||
|
||
:param entry: Decoded Claude transcript record.
|
||
:param line_number: One-based transcript line number.
|
||
:param record_offset: Byte offset where the transcript record
|
||
starts, or ``None`` for legacy line-cursor reads.
|
||
:param agent_name: Agent/model name for assistant/tool items.
|
||
:param current_response_id: Response id for the active Claude
|
||
assistant turn.
|
||
:returns: Updated active response id and parsed assistant/tool
|
||
items.
|
||
"""
|
||
message = entry["message"]
|
||
content = message.get("content") if isinstance(message, dict) else None
|
||
source_key = _transcript_source_key(entry, line_number, record_offset)
|
||
response_id = current_response_id or _response_id_from_source(source_key)
|
||
items: list[ClaudeTranscriptItem] = []
|
||
|
||
if isinstance(content, str):
|
||
if content:
|
||
items.append(
|
||
_assistant_message_item(
|
||
source_key=source_key,
|
||
item_index=0,
|
||
agent_name=agent_name,
|
||
response_id=response_id,
|
||
text=content,
|
||
)
|
||
)
|
||
return response_id, items
|
||
|
||
if not isinstance(content, list):
|
||
return current_response_id, []
|
||
|
||
for item_index, block in enumerate(content):
|
||
if not isinstance(block, dict):
|
||
continue
|
||
block_type = block.get("type")
|
||
if block_type == "text":
|
||
text = block.get("text")
|
||
if isinstance(text, str) and text:
|
||
items.append(
|
||
_assistant_message_item(
|
||
source_key=source_key,
|
||
item_index=item_index,
|
||
agent_name=agent_name,
|
||
response_id=response_id,
|
||
text=text,
|
||
)
|
||
)
|
||
continue
|
||
if block_type == "tool_use":
|
||
tool_id = block.get("id")
|
||
name = block.get("name")
|
||
if not isinstance(tool_id, str) or not tool_id:
|
||
continue
|
||
if not isinstance(name, str) or not name:
|
||
continue
|
||
arguments = block.get("input")
|
||
if not isinstance(arguments, dict):
|
||
arguments = {}
|
||
items.append(
|
||
ClaudeTranscriptItem(
|
||
source_id=_source_id(source_key, item_index, "function_call"),
|
||
item_type="function_call",
|
||
data={
|
||
"agent": agent_name,
|
||
"name": name,
|
||
"arguments": json.dumps(arguments, separators=(",", ":")),
|
||
"call_id": tool_id,
|
||
},
|
||
response_id=response_id,
|
||
)
|
||
)
|
||
return response_id if items else current_response_id, items
|
||
|
||
|
||
_CONTEXT_OVERFLOW_RE = re.compile(
|
||
r"^prompt is too long",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
_CONTEXT_OVERFLOW_REPLACEMENT = (
|
||
"Context limit reached — the conversation has grown too long for "
|
||
"the model’s context window. Use /compact to summarize and free up "
|
||
"space, or /clear to start a new conversation."
|
||
)
|
||
|
||
|
||
def _assistant_message_item(
|
||
*,
|
||
source_key: str,
|
||
item_index: int,
|
||
agent_name: str,
|
||
response_id: str,
|
||
text: str,
|
||
) -> ClaudeTranscriptItem:
|
||
"""
|
||
Build an assistant message item from one Claude text block.
|
||
|
||
:param source_key: Base transcript record key.
|
||
:param item_index: Content block index inside the record.
|
||
:param agent_name: Agent/model name for the assistant message.
|
||
:param response_id: Response id grouping the Claude turn.
|
||
:param text: Assistant text block.
|
||
:returns: Parsed transcript item.
|
||
"""
|
||
display_text = text
|
||
if _CONTEXT_OVERFLOW_RE.match(text.strip()):
|
||
display_text = _CONTEXT_OVERFLOW_REPLACEMENT
|
||
return ClaudeTranscriptItem(
|
||
source_id=_source_id(source_key, item_index, "message"),
|
||
item_type="message",
|
||
data={
|
||
"role": "assistant",
|
||
"agent": agent_name,
|
||
"content": [{"type": "output_text", "text": display_text}],
|
||
},
|
||
response_id=response_id,
|
||
)
|
||
|
||
|
||
def _tool_result_output(entry: dict[str, Any], block: dict[str, Any]) -> str:
|
||
"""
|
||
Return the UI-facing output string for a Claude tool result.
|
||
|
||
:param entry: Decoded Claude transcript record containing
|
||
optional ``toolUseResult`` metadata.
|
||
:param block: ``tool_result`` content block from ``message``.
|
||
:returns: String output for a ``function_call_output`` item.
|
||
"""
|
||
content = block.get("content")
|
||
if isinstance(content, str):
|
||
return content
|
||
if content is not None:
|
||
return json.dumps(content, separators=(",", ":"))
|
||
tool_use_result = entry.get("toolUseResult")
|
||
if isinstance(tool_use_result, str):
|
||
return tool_use_result
|
||
if tool_use_result is not None:
|
||
return json.dumps(tool_use_result, separators=(",", ":"))
|
||
return ""
|
||
|
||
|
||
def _transcript_source_key(
|
||
entry: dict[str, Any],
|
||
line_number: int,
|
||
record_offset: int | None = None,
|
||
) -> str:
|
||
"""
|
||
Return the stable key for a Claude transcript record.
|
||
|
||
:param entry: Decoded Claude transcript record.
|
||
:param line_number: One-based transcript line number.
|
||
:param record_offset: Byte offset where the transcript record
|
||
starts, or ``None`` when unavailable.
|
||
:returns: Claude UUID/request id, byte-offset fallback, or a
|
||
legacy line-number fallback.
|
||
"""
|
||
for key in ("uuid", "requestId"):
|
||
value = entry.get(key)
|
||
if isinstance(value, str) and value:
|
||
return value
|
||
if record_offset is not None:
|
||
return f"byte-{record_offset}"
|
||
return f"line-{line_number}"
|
||
|
||
|
||
def _parent_or_record_source_key(
|
||
entry: dict[str, Any],
|
||
line_number: int,
|
||
record_offset: int | None = None,
|
||
) -> str:
|
||
"""
|
||
Return a parent key for tool results when Claude supplies one.
|
||
|
||
:param entry: Decoded Claude transcript record.
|
||
:param line_number: One-based transcript line number.
|
||
:param record_offset: Byte offset where the transcript record
|
||
starts, or ``None`` when unavailable.
|
||
:returns: Parent UUID when present, otherwise the record key.
|
||
"""
|
||
parent = entry.get("parentUuid")
|
||
if isinstance(parent, str) and parent:
|
||
return parent
|
||
return _transcript_source_key(entry, line_number, record_offset)
|
||
|
||
|
||
def _response_id_from_source(source: str) -> str:
|
||
"""
|
||
Derive a deterministic Omnigent response id from a Claude source key.
|
||
|
||
:param source: Claude UUID/request id/line key.
|
||
:returns: String id with the standard ``resp_`` prefix.
|
||
"""
|
||
digest = hashlib.sha256(source.encode("utf-8")).hexdigest()[:32]
|
||
return f"resp_claude_{digest}"
|
||
|
||
|
||
def _source_id(source_key: str, item_index: int, item_type: str) -> str:
|
||
"""
|
||
Build a per-item idempotency key for a transcript-derived item.
|
||
|
||
:param source_key: Base Claude record key.
|
||
:param item_index: Content block index inside the record.
|
||
:param item_type: Omnigent item type.
|
||
:returns: Stable source id string.
|
||
"""
|
||
return f"{source_key}:{item_index}:{item_type}"
|
||
|
||
|
||
def _wait_for_server_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, Any]:
|
||
"""
|
||
Wait for the bridge control HTTP endpoint file.
|
||
|
||
:param bridge_dir: Bridge directory path.
|
||
:param timeout_s: Seconds to wait, e.g. ``30.0``.
|
||
:returns: Parsed server-info JSON object.
|
||
:raises RuntimeError: If the server file never appears.
|
||
"""
|
||
deadline = time.monotonic() + timeout_s
|
||
path = bridge_dir / _SERVER_FILE
|
||
while time.monotonic() < deadline:
|
||
payload = _read_json_file(path)
|
||
if isinstance(payload, dict) and payload.get("url") and payload.get("token"):
|
||
return payload
|
||
time.sleep(0.05)
|
||
raise RuntimeError(
|
||
"Claude native bridge is not ready yet. Wait for Claude Code "
|
||
"startup to finish before notifying tool list changes."
|
||
)
|
||
|
||
|
||
def _read_json_file(path: Path) -> dict[str, Any]:
|
||
"""
|
||
Read a JSON object file.
|
||
|
||
:param path: JSON file path.
|
||
:returns: Parsed object, or ``{}`` when missing/malformed.
|
||
"""
|
||
try:
|
||
raw = path.read_text(encoding="utf-8")
|
||
except FileNotFoundError:
|
||
return {}
|
||
try:
|
||
payload = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
return {}
|
||
return payload if isinstance(payload, dict) else {}
|
||
|
||
|
||
def _write_json_file(path: Path, payload: dict[str, Any]) -> None:
|
||
"""
|
||
Atomically write a JSON object file with owner-only permissions.
|
||
|
||
:param path: JSON file path.
|
||
:param payload: JSON-compatible object.
|
||
:returns: None.
|
||
"""
|
||
_ensure_secure_dir(path.parent)
|
||
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()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|