Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ relay only carries JSON messages.
## Install

Requires **Python 3.10+** and a signed-in
[Claude Code](https://docs.claude.com/en/docs/claude-code) CLI. To use the
[Claude Code](https://docs.claude.com/en/docs/claude-code) CLI. The Claude Agent SDK
bundled CLI is detected too; a separate global installation is optional. To use the
**Codex** provider instead, install the
[Codex CLI](https://github.com/openai/codex) and run `codex login`.

Expand Down Expand Up @@ -147,9 +148,8 @@ That shape is the whole security model:
- **Execution is local.** Your repo, shell, and MCP servers only ever touch this
machine.
- **Outbound only.** No listening ports, so nothing to expose or firewall.
- **Per-tool approval — in the modes that ask.** In `default` mode every tool
the agent wants to run is relayed to you as a permission request; nothing runs
until you allow it, and a timeout denies by default. The looser modes are
- **Per-tool approval — in the modes that ask.** In `default` mode Claude follows the local approval rules; tools that need
approval are relayed to you. A timeout denies by default. The looser modes are
looser on purpose: `acceptEdits` auto-accepts edits, and `bypassPermissions`
runs everything unattended. Codex never asks interactively at all — `codex
exec` maps the permission mode to a sandbox policy instead (plan →
Expand Down Expand Up @@ -245,3 +245,18 @@ decision to a remote approver — was inspired by
[VibeBridge](https://github.com/Swayyyyy/VibeBridge) (GPL-3.0). This is an
independent implementation built on the public `claude-agent-sdk`; **no
VibeBridge source code is included.** See [NOTICE](https://github.com/AceDataCloud/CodingBridge/blob/main/NOTICE).

## Session reliability

- **Stop** cancels the active Codex process and its child commands and returns the
session to idle. You can continue the same conversation afterward. Claude uses
its native interrupt control, and pending Bridge approvals are denied on stop.
- Codex output is displayed as soon as a complete message arrives, without an
artificial typewriter delay. Individual JSON events up to 8 MiB are supported;
larger events fail clearly and terminate the subprocess instead of hanging.
- Claude model and permission changes must succeed before they are applied.
Changing reasoning effort resumes the same transcript with the new setting.
- History and capability discovery run independently so loading a large local
history cannot block stop or approval commands.
- New Homebrew service installations use a stable `opt` interpreter path when
available. Reinstall an existing service once to replace an old Cellar path.
47 changes: 24 additions & 23 deletions coding_bridge/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import asyncio
import contextlib
import importlib.util
import json
import logging
import os
Expand Down Expand Up @@ -58,8 +59,9 @@
# Codex host paths. `models_cache.json` is codex's own API-fetched, per-account
# model catalog (authoritative); config.toml carries the user's chosen default
# model / effort, used only as a fallback when the cache can't be read.
CODEX_CONFIG = Path.home() / ".codex" / "config.toml"
CODEX_MODELS_CACHE = Path.home() / ".codex" / "models_cache.json"
CODEX_HOME = Path(os.environ.get("CODEX_HOME") or str(Path.home() / ".codex"))
CODEX_CONFIG = CODEX_HOME / "config.toml"
CODEX_MODELS_CACHE = CODEX_HOME / "models_cache.json"
_CODEX_FALLBACK_MODELS: list[dict[str, str]] = [
{"value": "gpt-5.5", "label": "GPT-5.5"},
{"value": "gpt-5", "label": "GPT-5"},
Expand Down Expand Up @@ -170,9 +172,26 @@ def _candidate_cli_paths(cli: str) -> list[str]:
if cli == "claude":
fixed.insert(0, home / ".claude" / "local" / "claude") # native installer
out += [str(p) for p in fixed]
if cli == "claude":
bundled = _bundled_claude()
if bundled:
out.append(bundled)
return out


def _bundled_claude() -> str | None:
try:
spec = importlib.util.find_spec("claude_agent_sdk")
except (ImportError, ValueError):
return None
if spec and spec.origin:
name = "claude.exe" if os.name == "nt" else "claude"
path = Path(spec.origin).parent / "_bundled" / name
if path.is_file():
return str(path)
return None


def resolve_cli(cli: str, settings: Any | None = None) -> str | None:
"""Absolute path to a provider CLI, or None if genuinely absent.

Expand Down Expand Up @@ -365,6 +384,7 @@ async def _probe_claude_commands(settings: Any) -> list[dict[str, Any]]:
cwd=(getattr(settings, "default_cwd", "") or None),
system_prompt={"type": "preset", "preset": "claude_code"},
setting_sources=["user", "project", "local"],
cli_path=resolve_cli("claude", settings),
)
client = ClaudeSDKClient(options=options)
await asyncio.wait_for(client.connect(), timeout=45)
Expand All @@ -380,27 +400,8 @@ async def _probe_claude_commands(settings: Any) -> list[dict[str, Any]]:


def _codex_commands() -> list[dict[str, Any]]:
"""Codex custom prompts (`$CODEX_HOME/prompts/*.md`) surfaced as slash commands.

`codex exec` is non-interactive and has no built-in slash processor, so only
user-defined prompt files are advertised; the rest of Codex's interactive
slash commands cannot run remotely.
"""
home = os.environ.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")
prompts_dir = Path(home) / "prompts"
if not prompts_dir.is_dir():
return []
commands: list[dict[str, Any]] = []
try:
entries = sorted(prompts_dir.glob("*.md"))
except OSError:
return []
for path in entries:
name = path.stem
if not name:
continue
commands.append({"name": name, "description": "", "argument_hint": "", "aliases": []})
return commands
"""exec cannot execute slash commands; do not advertise unusable entries."""
return []


def _copilot_commands() -> list[dict[str, Any]]:
Expand Down
18 changes: 17 additions & 1 deletion coding_bridge/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def __init__(
self._truncated_sessions: set[str] = set()
# Dedup browser commands the relay may redeliver (a command queued across
# our reconnect, then flushed). Bounded ring of recently seen cmd_ids.
self._read_tasks: dict[str, asyncio.Task] = {}
self._seen_cmds: set[str] = set()
self._seen_cmd_order: deque[str] = deque()

Expand Down Expand Up @@ -259,7 +260,17 @@ async def _on_raw(self, raw: str | bytes) -> None:
# re-spawn a turn or re-run a prompt.
if self._is_duplicate_command(message.get("cmd_id")):
return
await self._dispatch(message.get("payload") or {})
payload = message.get("payload") or {}
action = payload.get("action")
if action in (Action.HISTORY_LIST, Action.CAPABILITIES_GET):
# Large local histories and CLI initialization must not delay
# stop/approval commands or acknowledgement processing.
existing = self._read_tasks.get(action)
if existing is None or existing.done():
task = asyncio.create_task(self._dispatch(payload))
self._read_tasks[action] = task
else:
await self._dispatch(payload)
elif msg_type == protocol.NODE_ACK:
up_to = (message.get("payload") or {}).get("up_to_node_seq")
if isinstance(up_to, int):
Expand Down Expand Up @@ -579,6 +590,11 @@ async def _send_capabilities(self) -> None:
await self.send_payload(event_payload(Event.CAPABILITIES, **descriptor))

async def aclose(self) -> None:
tasks = list(self._read_tasks.values())
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
self._read_tasks.clear()
for session in list(self.sessions.values()):
await session.close()
self.sessions.clear()
Expand Down
47 changes: 38 additions & 9 deletions coding_bridge/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@
from collections.abc import Callable, Iterator
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from functools import lru_cache
from pathlib import Path
from typing import Any

from .protocol import tool_result_content

_log = logging.getLogger("coding-bridge.history")

# Module-level roots so tests can point them at fixtures via monkeypatch.
Expand All @@ -30,8 +33,9 @@
CLAUDE_ROOT = (
Path(os.environ.get("CLAUDE_CONFIG_DIR") or str(Path.home() / ".claude")) / "projects"
)
CODEX_ROOT = Path.home() / ".codex" / "sessions"
CODEX_INDEX = Path.home() / ".codex" / "session_index.jsonl"
CODEX_HOME = Path(os.environ.get("CODEX_HOME") or str(Path.home() / ".codex"))
CODEX_ROOT = CODEX_HOME / "sessions"
CODEX_INDEX = CODEX_HOME / "session_index.jsonl"
COPILOT_ROOT = (
Path(os.environ.get("COPILOT_HOME") or str(Path.home() / ".copilot")) / "session-state"
)
Expand Down Expand Up @@ -179,12 +183,26 @@ def _one(path: Path) -> dict[str, Any] | None:
return [r for r in results if r is not None]


def _cached_summary(path: Path, provider: str) -> dict[str, Any]:
stat = path.stat()
# Only unchanged files reuse a summary. Copies keep caller annotations from
# leaking into the cache; the bounded cache holds metadata, never transcripts.
return dict(_summary_at_version(str(path), provider, stat.st_mtime_ns, stat.st_size))


@lru_cache(maxsize=1000)
def _summary_at_version(path: str, provider: str, mtime_ns: int, size: int) -> dict[str, Any]:
if provider == "claude":
return _claude_summary(Path(path))
return _codex_summary(Path(path), {})


# --- Claude Code -----------------------------------------------------------
def _list_claude(limit: int) -> list[dict[str, Any]]:
if not CLAUDE_ROOT.exists():
return []
files = sorted(CLAUDE_ROOT.glob("*/*.jsonl"), key=_safe_mtime, reverse=True)[:limit]
return _summaries(files, _claude_summary)
return _summaries(files, lambda path: _cached_summary(path, "claude"))


def _claude_summary(path: Path) -> dict[str, Any]:
Expand Down Expand Up @@ -280,7 +298,7 @@ def _claude_user_events(
{
"kind": "tool_result",
"tool_use_id": block.get("tool_use_id"),
"content": _stringify(block.get("content")),
"content": _stringify(tool_result_content(block.get("content"))),
"is_error": bool(block.get("is_error")),
"ts": ts,
}
Expand Down Expand Up @@ -313,7 +331,7 @@ def _claude_assistant_events(content: Any, ts: int | None, events: list[dict[str
{
"kind": "tool_result",
"tool_use_id": block.get("tool_use_id"),
"content": _stringify(block.get("content")),
"content": _stringify(tool_result_content(block.get("content"))),
"is_error": bool(block.get("is_error")),
"ts": ts,
}
Expand All @@ -337,7 +355,12 @@ def _list_codex(limit: int) -> list[dict[str, Any]]:
return []
files = sorted(CODEX_ROOT.glob("**/rollout-*.jsonl"), key=_safe_mtime, reverse=True)[:limit]
index = _codex_index()
return _summaries(files, lambda path: _codex_summary(path, index))
summaries = _summaries(files, lambda path: _cached_summary(path, "codex"))
for summary in summaries:
meta = index.get(summary.get("session_id") or "", {})
summary["title"] = _provider_title(meta.get("thread_name")) or summary["title"]
summary["updated_at"] = _iso_ms(meta.get("updated_at")) or summary["updated_at"]
return summaries


def _codex_summary(path: Path, index: dict[str, dict[str, Any]]) -> dict[str, Any]:
Expand Down Expand Up @@ -427,17 +450,21 @@ def _codex_response_event(
text = _codex_reasoning_text(payload.get("summary"))
if text:
events.append({"kind": "thinking", "text": text, "ts": ts})
elif ptype == "function_call":
elif ptype in ("function_call", "custom_tool_call"):
events.append(
{
"kind": "tool_use",
"tool": payload.get("name"),
"tool_use_id": payload.get("call_id"),
"input": _maybe_json(payload.get("arguments")),
"input": (
{"patch": payload.get("input")}
if ptype == "custom_tool_call" and isinstance(payload.get("input"), str)
else _maybe_json(payload.get("arguments"))
),
"ts": ts,
}
)
elif ptype == "function_call_output":
elif ptype in ("function_call_output", "custom_tool_call_output"):
events.append(
{
"kind": "tool_result",
Expand Down Expand Up @@ -725,6 +752,8 @@ def _provider_title(value: Any) -> str:
"# copilot instructions",
"# context from my ide setup",
"the following is the codex agent history",
"here is a list of plugins that are available but not installed.",
"[request interrupted by user",
"the user opened the file",
"the user selected the lines",
"the user interrupted the previous turn",
Expand Down
14 changes: 14 additions & 0 deletions coding_bridge/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,17 @@ def event_payload(
payload["trace_id"] = trace_id
payload.update(fields)
return payload


def tool_result_content(content: Any) -> Any:
"""Keep displayable tool output while excluding embedded binary payloads."""
if not isinstance(content, list):
return content
visible = []
for block in content:
if isinstance(block, dict) and block.get("type") in {"image", "audio", "document"}:
source = block.get("source") or {}
visible.append({"type": block["type"], "media_type": source.get("media_type")})
else:
visible.append(block)
return visible
Loading
Loading