coding-bridge 2026.7.2.2__tar.gz → 2026.7.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/PKG-INFO +1 -1
- coding_bridge-2026.7.3.0/coding_bridge/channels/__init__.py +29 -0
- coding_bridge-2026.7.3.0/coding_bridge/channels/base.py +107 -0
- coding_bridge-2026.7.3.0/coding_bridge/channels/dispatcher.py +203 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/connection.py +4 -12
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/history.py +5 -247
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/pyproject.toml +1 -1
- coding_bridge-2026.7.3.0/tests/test_channels_base.py +91 -0
- coding_bridge-2026.7.3.0/tests/test_channels_dispatcher.py +242 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_history.py +0 -165
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/.env.example +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/.github/workflows/ci.yaml +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/.github/workflows/publish.yml +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/.gitignore +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/LICENSE +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/NOTICE +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/README.md +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/__init__.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/__main__.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/attachments.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/capabilities.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/cli.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/config.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/fs.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/images.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/locking.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/logs.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/pairing.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/permissions.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/protocol.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/providers/__init__.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/providers/base.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/providers/claude.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/providers/codex.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/providers/copilot.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/session.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/session_meta.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/coding_bridge/store.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/docs/design/reliable-event-delivery.md +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/conftest.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_ask_user_question.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_attachments.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_capabilities.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_codex.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_config.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_connection.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_copilot.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_edit_fork.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_fs.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_images.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_locking.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_outbox.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_pairing.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_permissions.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_protocol.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_resume_guard.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_session.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_session_meta.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_slash_commands.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_store.py +0 -0
- {coding_bridge-2026.7.2.2 → coding_bridge-2026.7.3.0}/tests/test_streaming.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coding-bridge
|
|
3
|
-
Version: 2026.7.
|
|
3
|
+
Version: 2026.7.3.0
|
|
4
4
|
Summary: Node daemon for AceDataCloud Coding Bridge — run Claude Code on your own machine and drive it from the web.
|
|
5
5
|
Project-URL: Homepage, https://github.com/AceDataCloud/CodingBridge
|
|
6
6
|
Project-URL: Issues, https://github.com/AceDataCloud/CodingBridge/issues
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Messaging channels: run a coding-agent turn per inbound external message.
|
|
2
|
+
|
|
3
|
+
Existing coding-bridge shape: browser ⇄ relay WSS ⇄ ``BridgeConnection`` node.
|
|
4
|
+
Channels shape: external messenger (WeChat / Telegram / …) ⇄ ``ChannelAdapter``
|
|
5
|
+
⇄ ``SessionDispatcher`` ⇄ existing ``Session`` + ``Provider``. The relay path is
|
|
6
|
+
untouched; channels are an additive second entry-point built on the same
|
|
7
|
+
``Provider`` layer.
|
|
8
|
+
|
|
9
|
+
See ``plans/coding-bridge-channels/01-production-plan.md`` in AceDataCloud/Index
|
|
10
|
+
for the P1..P11 roadmap this module implements.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .base import (
|
|
14
|
+
ChannelAdapter,
|
|
15
|
+
ChannelTarget,
|
|
16
|
+
IncomingMessage,
|
|
17
|
+
MessageHandler,
|
|
18
|
+
SendResult,
|
|
19
|
+
)
|
|
20
|
+
from .dispatcher import SessionDispatcher
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ChannelAdapter",
|
|
24
|
+
"ChannelTarget",
|
|
25
|
+
"IncomingMessage",
|
|
26
|
+
"MessageHandler",
|
|
27
|
+
"SendResult",
|
|
28
|
+
"SessionDispatcher",
|
|
29
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Channel-adapter Protocol and value objects shared by every messenger.
|
|
2
|
+
|
|
3
|
+
An adapter is the thin glue between one external messaging system (WeChat via
|
|
4
|
+
Wisdom, later Telegram / Discord …) and the dispatcher. It never spawns provider
|
|
5
|
+
processes, tracks sessions, or evaluates prompts — those live in
|
|
6
|
+
``SessionDispatcher`` and reuse the existing ``Session`` / ``Provider`` machinery
|
|
7
|
+
so the browser and WeChat paths share one coding-agent implementation.
|
|
8
|
+
|
|
9
|
+
Design invariants:
|
|
10
|
+
|
|
11
|
+
* ``instance_id`` is opaque to the dispatcher but MUST be stable per adapter
|
|
12
|
+
instance (per configured Wisdom endpoint, per Bot token, …) so multi-instance
|
|
13
|
+
deployments can namespace dedup keys, log fields, and outbox rows.
|
|
14
|
+
* ``send()`` is expected to return a ``SendResult`` — never raise on a delivery
|
|
15
|
+
failure — because delivery is best-effort and the dispatcher records the
|
|
16
|
+
outcome for observability + retry decisions.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Awaitable, Callable
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Protocol, runtime_checkable
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ChannelTarget:
|
|
28
|
+
"""Where a reply for an incoming message should be delivered.
|
|
29
|
+
|
|
30
|
+
``conversation_id`` is the ID of the "room" — a WeChat private chat wxid, a
|
|
31
|
+
group room id, a Telegram chat_id, etc. ``reply_to_id`` is set only when the
|
|
32
|
+
external protocol supports a reply-to primitive worth carrying (WeChat
|
|
33
|
+
Wisdom's ``msg_id``, Telegram's ``reply_to_message_id``); adapters that lack
|
|
34
|
+
one leave it ``None``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
conversation_id: str
|
|
38
|
+
conversation_type: str = "private"
|
|
39
|
+
reply_to_id: str | None = None
|
|
40
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class IncomingMessage:
|
|
45
|
+
"""A single external message routed to the dispatcher.
|
|
46
|
+
|
|
47
|
+
``sender_id`` MUST be stable per remote identity (WeChat wxid, Telegram
|
|
48
|
+
user_id, …) — the dispatcher uses it for allowlists, rate limits, and per-
|
|
49
|
+
sender session keying.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
sender_id: str
|
|
53
|
+
sender_name: str | None
|
|
54
|
+
target: ChannelTarget
|
|
55
|
+
text: str
|
|
56
|
+
msg_type: str
|
|
57
|
+
direction: str # ``inbound`` / ``outbound`` (per Wisdom's schema)
|
|
58
|
+
upstream_id: str | None = None
|
|
59
|
+
received_at_ms: int | None = None
|
|
60
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class SendResult:
|
|
65
|
+
"""Outcome of an adapter ``send()`` call. Non-raising by contract."""
|
|
66
|
+
|
|
67
|
+
ok: bool
|
|
68
|
+
upstream_id: str | None = None
|
|
69
|
+
error: str | None = None
|
|
70
|
+
latency_ms: int | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ``handler(msg, adapter)`` — the dispatcher installs one before ``run()``.
|
|
74
|
+
MessageHandler = Callable[["IncomingMessage", "ChannelAdapter"], Awaitable[None]]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@runtime_checkable
|
|
78
|
+
class ChannelAdapter(Protocol):
|
|
79
|
+
"""One long-lived connection to an external messaging system.
|
|
80
|
+
|
|
81
|
+
Lifecycle:
|
|
82
|
+
|
|
83
|
+
1. Construct — parse settings, do NOT open sockets yet.
|
|
84
|
+
2. ``set_handler(h)`` — dispatcher registers the callback.
|
|
85
|
+
3. ``run()`` — subscribe loop; must call ``handler(msg, self)`` per inbound
|
|
86
|
+
message. Should reconnect internally on transient failures. Returns only
|
|
87
|
+
on ``aclose()`` or an unrecoverable auth failure.
|
|
88
|
+
4. ``send(target, text)`` — outbound reply.
|
|
89
|
+
5. ``aclose()`` — signal ``run()`` to stop; idempotent.
|
|
90
|
+
|
|
91
|
+
Adapters MUST NOT retain references to the dispatcher itself — only the
|
|
92
|
+
handler callback — so the dispatcher can outlive individual adapters (and
|
|
93
|
+
vice versa) without leaks.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
name: str
|
|
97
|
+
instance_id: str
|
|
98
|
+
|
|
99
|
+
def set_handler(self, handler: MessageHandler) -> None: ...
|
|
100
|
+
|
|
101
|
+
async def run(self) -> None: ...
|
|
102
|
+
|
|
103
|
+
async def send(
|
|
104
|
+
self, target: ChannelTarget, text: str, *, reply_to: str | None = None
|
|
105
|
+
) -> SendResult: ...
|
|
106
|
+
|
|
107
|
+
async def aclose(self) -> None: ...
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Route external messages to per-session provider turns and post replies back.
|
|
2
|
+
|
|
3
|
+
The dispatcher is the channel-side counterpart of ``BridgeConnection`` — it owns
|
|
4
|
+
the map from ``(instance_id, sender_id, conversation_id)`` to a live ``Session``,
|
|
5
|
+
converts each session's event stream into a single text reply, and hands the
|
|
6
|
+
reply to the originating adapter's ``send()``.
|
|
7
|
+
|
|
8
|
+
Session lifecycle (v1 = one-shot per message):
|
|
9
|
+
|
|
10
|
+
* Every inbound message spawns a fresh ``Session`` and immediately closes it
|
|
11
|
+
when the provider emits ``session.result`` or ``session.error``. Persisting
|
|
12
|
+
context across messages ("resume") is deferred so v1 stays simple and each
|
|
13
|
+
send/receive pair is auditable in isolation.
|
|
14
|
+
|
|
15
|
+
* Concurrency guard: only one in-flight turn per session key. A second message
|
|
16
|
+
arriving before the current turn completes is deferred to the caller (the
|
|
17
|
+
adapter decides whether to buffer, drop, or warn) via a ``BusyError``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import contextlib
|
|
24
|
+
import logging
|
|
25
|
+
import uuid
|
|
26
|
+
from collections.abc import Awaitable, Callable
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from ..config import Settings
|
|
30
|
+
from ..protocol import Event
|
|
31
|
+
from ..providers.base import ProviderFactory
|
|
32
|
+
from ..session import Session
|
|
33
|
+
from .base import ChannelAdapter, ChannelTarget, IncomingMessage, SendResult
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("coding-bridge.channels")
|
|
36
|
+
|
|
37
|
+
# ``(adapter_name, instance_id, sender_id, conversation_id)`` — the key namespace
|
|
38
|
+
# the dispatcher uses to route replies. ``adapter_name`` guards against future
|
|
39
|
+
# collisions when a user runs both a WeChat and a Telegram adapter at once.
|
|
40
|
+
SessionKey = tuple[str, str, str, str]
|
|
41
|
+
|
|
42
|
+
# Emitted after a session finishes; carries the concatenated reply text so the
|
|
43
|
+
# adapter can post it. Never raises — the dispatcher swallows adapter errors and
|
|
44
|
+
# logs them so a broken adapter can't kill sibling sessions.
|
|
45
|
+
ReplySink = Callable[[ChannelAdapter, ChannelTarget, str], Awaitable[SendResult]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BusyError(RuntimeError):
|
|
49
|
+
"""Raised by ``handle_message`` when a session key already has an in-flight turn."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Turn:
|
|
53
|
+
"""Accumulates text_delta / text / result events for one provider turn."""
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
self._parts: list[str] = []
|
|
57
|
+
self._done = asyncio.Event()
|
|
58
|
+
self._error: str | None = None
|
|
59
|
+
|
|
60
|
+
def on_event(self, payload: dict[str, Any]) -> None:
|
|
61
|
+
event = payload.get("event")
|
|
62
|
+
if event in (Event.SESSION_TEXT, Event.SESSION_TEXT_DELTA):
|
|
63
|
+
text = payload.get("text")
|
|
64
|
+
if isinstance(text, str) and text:
|
|
65
|
+
self._parts.append(text)
|
|
66
|
+
elif event == Event.SESSION_RESULT:
|
|
67
|
+
# Some providers emit their final text only in ``result.text`` and
|
|
68
|
+
# never stream a session.text — pick that up so we don't reply blank.
|
|
69
|
+
result_text = payload.get("text") or payload.get("result")
|
|
70
|
+
if isinstance(result_text, str) and result_text and not self._parts:
|
|
71
|
+
self._parts.append(result_text)
|
|
72
|
+
self._done.set()
|
|
73
|
+
elif event == Event.SESSION_ERROR:
|
|
74
|
+
err = payload.get("message") or payload.get("code") or "provider_error"
|
|
75
|
+
self._error = str(err)
|
|
76
|
+
self._done.set()
|
|
77
|
+
elif event == Event.SESSION_CLOSED:
|
|
78
|
+
self._done.set()
|
|
79
|
+
|
|
80
|
+
def text(self) -> str:
|
|
81
|
+
return "".join(self._parts).strip()
|
|
82
|
+
|
|
83
|
+
def error(self) -> str | None:
|
|
84
|
+
return self._error
|
|
85
|
+
|
|
86
|
+
async def wait(self, timeout: float | None) -> bool:
|
|
87
|
+
try:
|
|
88
|
+
await asyncio.wait_for(self._done.wait(), timeout=timeout)
|
|
89
|
+
except asyncio.TimeoutError:
|
|
90
|
+
return False
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class SessionDispatcher:
|
|
95
|
+
"""Fan external messages into ``Session`` turns and fan replies back out."""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
settings: Settings,
|
|
100
|
+
provider_factory: ProviderFactory,
|
|
101
|
+
*,
|
|
102
|
+
reply_sink: ReplySink | None = None,
|
|
103
|
+
turn_timeout: float = 300.0,
|
|
104
|
+
default_provider: str = "claude",
|
|
105
|
+
) -> None:
|
|
106
|
+
self.settings = settings
|
|
107
|
+
self._factory = provider_factory
|
|
108
|
+
self._reply_sink = reply_sink or _default_reply_sink
|
|
109
|
+
self._turn_timeout = turn_timeout
|
|
110
|
+
self._default_provider = default_provider
|
|
111
|
+
self._inflight: dict[SessionKey, asyncio.Task[Any]] = {}
|
|
112
|
+
self._lock = asyncio.Lock()
|
|
113
|
+
|
|
114
|
+
def key_for(self, msg: IncomingMessage, adapter: ChannelAdapter) -> SessionKey:
|
|
115
|
+
return (adapter.name, adapter.instance_id, msg.sender_id, msg.target.conversation_id)
|
|
116
|
+
|
|
117
|
+
async def handle_message(self, msg: IncomingMessage, adapter: ChannelAdapter) -> None:
|
|
118
|
+
"""Spawn one turn for ``msg``. Raises ``BusyError`` if a turn is in flight.
|
|
119
|
+
|
|
120
|
+
Non-raising for anything else — a provider crash or send failure is
|
|
121
|
+
logged and reported to the caller only via the returned coroutine
|
|
122
|
+
completing (there is no exception to re-raise on the adapter thread).
|
|
123
|
+
"""
|
|
124
|
+
key = self.key_for(msg, adapter)
|
|
125
|
+
async with self._lock:
|
|
126
|
+
if key in self._inflight and not self._inflight[key].done():
|
|
127
|
+
raise BusyError(f"turn already running for {key!r}")
|
|
128
|
+
task = asyncio.create_task(self._run_turn(key, msg, adapter))
|
|
129
|
+
self._inflight[key] = task
|
|
130
|
+
# Fire-and-forget: the adapter loop must not block on this turn.
|
|
131
|
+
|
|
132
|
+
async def aclose(self) -> None:
|
|
133
|
+
async with self._lock:
|
|
134
|
+
tasks = [t for t in self._inflight.values() if not t.done()]
|
|
135
|
+
self._inflight.clear()
|
|
136
|
+
for t in tasks:
|
|
137
|
+
t.cancel()
|
|
138
|
+
for t in tasks:
|
|
139
|
+
with contextlib.suppress(BaseException):
|
|
140
|
+
await t
|
|
141
|
+
|
|
142
|
+
async def _run_turn(
|
|
143
|
+
self, key: SessionKey, msg: IncomingMessage, adapter: ChannelAdapter
|
|
144
|
+
) -> None:
|
|
145
|
+
session_id = uuid.uuid4().hex
|
|
146
|
+
turn = _Turn()
|
|
147
|
+
|
|
148
|
+
async def emit(payload: dict[str, Any]) -> None:
|
|
149
|
+
turn.on_event(payload)
|
|
150
|
+
|
|
151
|
+
session = Session(
|
|
152
|
+
session_id=session_id,
|
|
153
|
+
provider_factory=self._factory,
|
|
154
|
+
emit=emit,
|
|
155
|
+
settings=self.settings,
|
|
156
|
+
cwd=self.settings.default_cwd,
|
|
157
|
+
model=self.settings.default_model,
|
|
158
|
+
permission_mode="default",
|
|
159
|
+
provider=self._default_provider,
|
|
160
|
+
)
|
|
161
|
+
try:
|
|
162
|
+
await session.start(msg.text)
|
|
163
|
+
ok = await turn.wait(self._turn_timeout)
|
|
164
|
+
reply = turn.text()
|
|
165
|
+
# Error takes precedence over any text already streamed — if the
|
|
166
|
+
# provider emits SESSION_RESULT and then SESSION_ERROR (buggy
|
|
167
|
+
# provider), we surface the error instead of silently returning the
|
|
168
|
+
# stale text.
|
|
169
|
+
if turn.error():
|
|
170
|
+
reply = f"(provider error: {turn.error()})"
|
|
171
|
+
elif not ok:
|
|
172
|
+
reply = reply or "(provider timed out; no reply)"
|
|
173
|
+
if not reply:
|
|
174
|
+
reply = "(no reply)"
|
|
175
|
+
try:
|
|
176
|
+
result = await self._reply_sink(adapter, msg.target, reply)
|
|
177
|
+
except Exception as exc: # defensive: adapter send should not kill dispatcher
|
|
178
|
+
logger.exception("channel send failed: %s", exc)
|
|
179
|
+
return
|
|
180
|
+
if not result.ok:
|
|
181
|
+
logger.warning(
|
|
182
|
+
"channel send returned failure: adapter=%s instance=%s error=%s",
|
|
183
|
+
adapter.name,
|
|
184
|
+
adapter.instance_id,
|
|
185
|
+
result.error,
|
|
186
|
+
)
|
|
187
|
+
finally:
|
|
188
|
+
with contextlib.suppress(BaseException):
|
|
189
|
+
await session.close()
|
|
190
|
+
async with self._lock:
|
|
191
|
+
if self._inflight.get(key) is asyncio.current_task():
|
|
192
|
+
self._inflight.pop(key, None)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def _default_reply_sink(
|
|
196
|
+
adapter: ChannelAdapter, target: ChannelTarget, text: str
|
|
197
|
+
) -> SendResult:
|
|
198
|
+
"""Fallback sink used when the caller didn't supply one — just calls send()."""
|
|
199
|
+
|
|
200
|
+
return await adapter.send(target, text)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
__all__ = ["SessionDispatcher", "BusyError", "ReplySink"]
|
|
@@ -334,16 +334,6 @@ class BridgeConnection:
|
|
|
334
334
|
**_session_overrides(payload),
|
|
335
335
|
)
|
|
336
336
|
return
|
|
337
|
-
resume = payload.get("resume_session_id") or None
|
|
338
|
-
prompt = payload.get("prompt", "")
|
|
339
|
-
# Continue a Copilot session the CLI can't natively resume (e.g. a VS Code
|
|
340
|
-
# Copilot Chat session opened from history): seed its transcript into a
|
|
341
|
-
# fresh session instead of a doomed session/resume.
|
|
342
|
-
if provider == "copilot" and resume and not history.copilot_native(resume):
|
|
343
|
-
seed = await asyncio.to_thread(history.build_seed, resume)
|
|
344
|
-
if seed:
|
|
345
|
-
prompt = f"{seed}\n\n{prompt}".strip() if prompt else seed
|
|
346
|
-
resume = None
|
|
347
337
|
session = Session(
|
|
348
338
|
session_id,
|
|
349
339
|
self.provider_factory,
|
|
@@ -354,12 +344,14 @@ class BridgeConnection:
|
|
|
354
344
|
permission_mode=payload.get("permission_mode") or "default",
|
|
355
345
|
provider=provider,
|
|
356
346
|
effort=payload.get("effort") or None,
|
|
357
|
-
resume=
|
|
347
|
+
resume=payload.get("resume_session_id") or None,
|
|
358
348
|
trace_id=payload.get("trace_id"),
|
|
359
349
|
on_rekey=self._rekey_session,
|
|
360
350
|
)
|
|
361
351
|
self.sessions[session_id] = session
|
|
362
|
-
await session.start(
|
|
352
|
+
await session.start(
|
|
353
|
+
payload.get("prompt", ""), payload.get("images"), payload.get("attachments")
|
|
354
|
+
)
|
|
363
355
|
|
|
364
356
|
def _session(self, session_id: str | None) -> Session | None:
|
|
365
357
|
"""Resolve a session by its canonical id or a still-live provisional alias."""
|
|
@@ -20,7 +20,6 @@ from concurrent.futures import ThreadPoolExecutor
|
|
|
20
20
|
from datetime import datetime
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
from typing import Any
|
|
23
|
-
from urllib.parse import unquote, urlparse
|
|
24
23
|
|
|
25
24
|
# Module-level roots so tests can point them at fixtures via monkeypatch.
|
|
26
25
|
CLAUDE_ROOT = Path.home() / ".claude" / "projects"
|
|
@@ -30,33 +29,6 @@ COPILOT_ROOT = (
|
|
|
30
29
|
Path(os.environ.get("COPILOT_HOME") or str(Path.home() / ".copilot")) / "session-state"
|
|
31
30
|
)
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
def _vscode_chat_roots() -> list[Path]:
|
|
35
|
-
"""Candidate ``workspaceStorage`` dirs for VS Code Copilot Chat transcripts.
|
|
36
|
-
|
|
37
|
-
Chat sessions live at ``<storage>/<workspace-hash>/chatSessions/<id>.jsonl``.
|
|
38
|
-
``VSCODE_CHAT_HOME`` overrides for tests / non-standard installs.
|
|
39
|
-
"""
|
|
40
|
-
override = os.environ.get("VSCODE_CHAT_HOME")
|
|
41
|
-
if override:
|
|
42
|
-
return [Path(override)]
|
|
43
|
-
home = Path.home()
|
|
44
|
-
bases: list[Path | None] = [
|
|
45
|
-
home / "Library" / "Application Support", # macOS
|
|
46
|
-
Path(os.environ["APPDATA"]) if os.environ.get("APPDATA") else None, # Windows
|
|
47
|
-
home / ".config", # Linux
|
|
48
|
-
]
|
|
49
|
-
apps = ("Code", "Code - Insiders", "VSCodium", "Cursor")
|
|
50
|
-
roots: list[Path] = []
|
|
51
|
-
for base in bases:
|
|
52
|
-
if base is None:
|
|
53
|
-
continue
|
|
54
|
-
roots.extend(base / app / "User" / "workspaceStorage" for app in apps)
|
|
55
|
-
return roots
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
VSCODE_CHAT_ROOTS = _vscode_chat_roots()
|
|
59
|
-
|
|
60
32
|
_TITLE_MAX = 80
|
|
61
33
|
_DETAIL_MAX_EVENTS = 4000
|
|
62
34
|
|
|
@@ -70,14 +42,9 @@ _UUID_RE = re.compile(
|
|
|
70
42
|
|
|
71
43
|
# --- public API ------------------------------------------------------------
|
|
72
44
|
def list_sessions(limit: int = 200) -> list[dict[str, Any]]:
|
|
73
|
-
"""Return session summaries from
|
|
45
|
+
"""Return session summaries from both providers, newest first."""
|
|
74
46
|
limit = max(1, min(int(limit or 200), 1000))
|
|
75
|
-
sessions = (
|
|
76
|
-
_list_claude(limit)
|
|
77
|
-
+ _list_codex(limit)
|
|
78
|
-
+ _list_copilot(limit)
|
|
79
|
-
+ _list_vscode_chat(limit)
|
|
80
|
-
)
|
|
47
|
+
sessions = _list_claude(limit) + _list_codex(limit) + _list_copilot(limit)
|
|
81
48
|
sessions.sort(key=lambda s: s.get("updated_at") or 0, reverse=True)
|
|
82
49
|
return sessions[:limit]
|
|
83
50
|
|
|
@@ -131,54 +98,6 @@ def claude_user_uuid_after(session_id: str, cut_uuid: str | None) -> str | None:
|
|
|
131
98
|
return None
|
|
132
99
|
|
|
133
100
|
|
|
134
|
-
# Cap the seeded transcript so continuing a huge conversation doesn't blow the
|
|
135
|
-
# first prompt; keep the most recent turns (the live context).
|
|
136
|
-
_SEED_MAX_CHARS = 40000
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def copilot_native(session_id: str) -> bool:
|
|
140
|
-
"""True if a Copilot session id belongs to the CLI store (natively resumable).
|
|
141
|
-
|
|
142
|
-
VS Code Copilot Chat sessions live in a different store the CLI can't resume,
|
|
143
|
-
so they must be continued by seeding the transcript into a fresh session.
|
|
144
|
-
"""
|
|
145
|
-
return _copilot_path(session_id) is not None
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def build_seed(session_id: str) -> str:
|
|
149
|
-
"""Render a Copilot transcript as a compact preamble to continue it elsewhere.
|
|
150
|
-
|
|
151
|
-
Used when a session can't be natively resumed (e.g. a VS Code Copilot Chat
|
|
152
|
-
session continued from the phone): the prior user/assistant turns are replayed
|
|
153
|
-
as context in a fresh session. Tool calls and thinking are omitted; only typed
|
|
154
|
-
prompts and assistant text are kept, tail-capped at ``_SEED_MAX_CHARS``.
|
|
155
|
-
"""
|
|
156
|
-
try:
|
|
157
|
-
detail = read_session("copilot", session_id)
|
|
158
|
-
except (FileNotFoundError, OSError, ValueError):
|
|
159
|
-
return ""
|
|
160
|
-
lines: list[str] = []
|
|
161
|
-
for event in detail.get("events", []):
|
|
162
|
-
kind = event.get("kind")
|
|
163
|
-
text = event.get("text")
|
|
164
|
-
if not isinstance(text, str) or not text.strip():
|
|
165
|
-
continue
|
|
166
|
-
if kind == "prompt":
|
|
167
|
-
lines.append(f"User: {text.strip()}")
|
|
168
|
-
elif kind == "text":
|
|
169
|
-
lines.append(f"Assistant: {text.strip()}")
|
|
170
|
-
if not lines:
|
|
171
|
-
return ""
|
|
172
|
-
body = "\n\n".join(lines)
|
|
173
|
-
if len(body) > _SEED_MAX_CHARS:
|
|
174
|
-
body = "…(earlier turns omitted)…\n\n" + body[-_SEED_MAX_CHARS:]
|
|
175
|
-
return (
|
|
176
|
-
"[Continuing a previous conversation. Transcript so far:]\n\n"
|
|
177
|
-
+ body
|
|
178
|
-
+ "\n\n[End of previous transcript — continue from here.]"
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
|
|
182
101
|
def read_session(provider: str, session_id: str) -> dict[str, Any]:
|
|
183
102
|
"""Return a normalised transcript ``{provider,title,cwd,model,...,events}``."""
|
|
184
103
|
if provider == "claude":
|
|
@@ -192,17 +111,10 @@ def read_session(provider: str, session_id: str) -> dict[str, Any]:
|
|
|
192
111
|
raise FileNotFoundError(f"codex session not found: {session_id}")
|
|
193
112
|
header, events = _codex_read(path, _codex_index())
|
|
194
113
|
elif provider == "copilot":
|
|
195
|
-
# A "copilot" session may live in the CLI store or the VS Code Copilot
|
|
196
|
-
# Chat store — both are Copilot to the user. Prefer the CLI store, fall
|
|
197
|
-
# back to the VS Code panel transcript.
|
|
198
114
|
path = _copilot_path(session_id)
|
|
199
|
-
if path is
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
vpath = _vscode_chat_path(session_id)
|
|
203
|
-
if vpath is None:
|
|
204
|
-
raise FileNotFoundError(f"copilot session not found: {session_id}")
|
|
205
|
-
header, events = _vscode_chat_read(vpath)
|
|
115
|
+
if path is None:
|
|
116
|
+
raise FileNotFoundError(f"copilot session not found: {session_id}")
|
|
117
|
+
header, events = _copilot_read(path)
|
|
206
118
|
else:
|
|
207
119
|
raise ValueError(f"unknown provider: {provider}")
|
|
208
120
|
# Keep the MOST RECENT events when a transcript is huge — the tail is the
|
|
@@ -713,160 +625,6 @@ def _copilot_tool_result_text(data: dict[str, Any]) -> Any:
|
|
|
713
625
|
return _stringify(error)
|
|
714
626
|
|
|
715
627
|
|
|
716
|
-
# --- VS Code Copilot Chat --------------------------------------------------
|
|
717
|
-
# The panel stores each session as an append-only delta log of `{kind, v}` lines
|
|
718
|
-
# (format v3). We don't replay the deltas; we recursively find the request
|
|
719
|
-
# objects (dict with `message.text` + `response`), which is resilient to delta
|
|
720
|
-
# opcode changes across VS Code releases. These are surfaced as ordinary
|
|
721
|
-
# `copilot` sessions (same backend); read-only, so continuing makes a copy.
|
|
722
|
-
def _list_vscode_chat(limit: int) -> list[dict[str, Any]]:
|
|
723
|
-
files: list[Path] = []
|
|
724
|
-
for root in VSCODE_CHAT_ROOTS:
|
|
725
|
-
if root.exists():
|
|
726
|
-
files.extend(root.glob("*/chatSessions/*.jsonl"))
|
|
727
|
-
files = sorted(files, key=_safe_mtime, reverse=True)[:limit]
|
|
728
|
-
return _summaries(files, _vscode_chat_summary)
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
def _vscode_collect_requests(obj: Any, out: list[dict[str, Any]]) -> None:
|
|
732
|
-
"""Collect chat-request objects, pruning descent into the huge `response`."""
|
|
733
|
-
if isinstance(obj, dict):
|
|
734
|
-
msg = obj.get("message")
|
|
735
|
-
if isinstance(msg, dict) and "text" in msg and "response" in obj:
|
|
736
|
-
out.append(obj)
|
|
737
|
-
return # don't recurse into this request's response array
|
|
738
|
-
for value in obj.values():
|
|
739
|
-
_vscode_collect_requests(value, out)
|
|
740
|
-
elif isinstance(obj, list):
|
|
741
|
-
for value in obj:
|
|
742
|
-
_vscode_collect_requests(value, out)
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
def _vscode_requests(path: Path) -> list[dict[str, Any]]:
|
|
746
|
-
seen: dict[str, dict[str, Any]] = {}
|
|
747
|
-
ordered: list[dict[str, Any]] = []
|
|
748
|
-
for rec in _iter_jsonl(path):
|
|
749
|
-
found: list[dict[str, Any]] = []
|
|
750
|
-
_vscode_collect_requests(rec.get("v"), found)
|
|
751
|
-
for req in found:
|
|
752
|
-
rid = req.get("requestId")
|
|
753
|
-
key = rid if isinstance(rid, str) else str(id(req))
|
|
754
|
-
if key not in seen:
|
|
755
|
-
seen[key] = req
|
|
756
|
-
ordered.append(req)
|
|
757
|
-
ordered.sort(key=lambda r: r.get("timestamp") or 0)
|
|
758
|
-
return ordered
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
def _vscode_user_text(req: dict[str, Any]) -> str:
|
|
762
|
-
msg = req.get("message")
|
|
763
|
-
text = msg.get("text") if isinstance(msg, dict) else None
|
|
764
|
-
return text.strip() if isinstance(text, str) else ""
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
def _vscode_chat_summary(path: Path) -> dict[str, Any]:
|
|
768
|
-
reqs = _vscode_requests(path)
|
|
769
|
-
title = ""
|
|
770
|
-
updated_at = _mtime_ms(path)
|
|
771
|
-
for req in reqs:
|
|
772
|
-
ts = req.get("timestamp")
|
|
773
|
-
if isinstance(ts, int):
|
|
774
|
-
updated_at = max(updated_at, ts)
|
|
775
|
-
if not title:
|
|
776
|
-
text = _vscode_user_text(req)
|
|
777
|
-
if text and not _is_context_noise(text):
|
|
778
|
-
title = _clean_title(text)
|
|
779
|
-
return {
|
|
780
|
-
"provider": "copilot",
|
|
781
|
-
"session_id": path.stem,
|
|
782
|
-
"title": title or "(no prompt)",
|
|
783
|
-
"cwd": _vscode_cwd(path),
|
|
784
|
-
"git_branch": None,
|
|
785
|
-
"updated_at": updated_at,
|
|
786
|
-
"message_count": len(reqs),
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
def _vscode_chat_read(path: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
791
|
-
header: dict[str, Any] = {
|
|
792
|
-
"cwd": _vscode_cwd(path),
|
|
793
|
-
"git_branch": None,
|
|
794
|
-
"model": None,
|
|
795
|
-
"title": "",
|
|
796
|
-
}
|
|
797
|
-
events: list[dict[str, Any]] = []
|
|
798
|
-
for req in _vscode_requests(path):
|
|
799
|
-
ts = req.get("timestamp") if isinstance(req.get("timestamp"), int) else None
|
|
800
|
-
model = req.get("modelId")
|
|
801
|
-
if header["model"] is None and isinstance(model, str) and model:
|
|
802
|
-
header["model"] = model
|
|
803
|
-
text = _vscode_user_text(req)
|
|
804
|
-
if text and not _is_context_noise(text):
|
|
805
|
-
events.append({"kind": "prompt", "text": text, "ts": ts})
|
|
806
|
-
if not header["title"]:
|
|
807
|
-
header["title"] = _clean_title(text)
|
|
808
|
-
_vscode_response_events(req.get("response"), ts, events)
|
|
809
|
-
return header, events
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
def _vscode_response_events(response: Any, ts: int | None, events: list[dict[str, Any]]) -> None:
|
|
813
|
-
if not isinstance(response, list):
|
|
814
|
-
return
|
|
815
|
-
for part in response:
|
|
816
|
-
if not isinstance(part, dict):
|
|
817
|
-
continue
|
|
818
|
-
kind = part.get("kind")
|
|
819
|
-
if kind is None: # markdown text block: {"value": "...", ...}
|
|
820
|
-
value = part.get("value")
|
|
821
|
-
text = value.get("value") if isinstance(value, dict) else value
|
|
822
|
-
if isinstance(text, str) and text.strip():
|
|
823
|
-
events.append({"kind": "text", "text": text, "ts": ts})
|
|
824
|
-
elif kind == "thinking":
|
|
825
|
-
value = part.get("value")
|
|
826
|
-
if isinstance(value, str) and value.strip():
|
|
827
|
-
events.append({"kind": "thinking", "text": value, "ts": ts})
|
|
828
|
-
elif kind == "toolInvocationSerialized":
|
|
829
|
-
msg = part.get("pastTenseMessage") or part.get("invocationMessage")
|
|
830
|
-
label = msg.get("value") if isinstance(msg, dict) else msg
|
|
831
|
-
events.append(
|
|
832
|
-
{
|
|
833
|
-
"kind": "tool_use",
|
|
834
|
-
"tool": part.get("toolId"),
|
|
835
|
-
"tool_use_id": part.get("toolCallId"),
|
|
836
|
-
"input": label if isinstance(label, str) else None,
|
|
837
|
-
"ts": ts,
|
|
838
|
-
}
|
|
839
|
-
)
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
def _vscode_chat_path(session_id: str) -> Path | None:
|
|
843
|
-
if not _safe_id(session_id) or session_id in (".", ".."):
|
|
844
|
-
return None
|
|
845
|
-
matches: list[Path] = []
|
|
846
|
-
for root in VSCODE_CHAT_ROOTS:
|
|
847
|
-
if root.exists():
|
|
848
|
-
matches.extend(root.glob(f"*/chatSessions/{session_id}.jsonl"))
|
|
849
|
-
matches = [p for p in matches if p.is_file()]
|
|
850
|
-
return max(matches, key=_safe_mtime) if matches else None
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
def _vscode_cwd(path: Path) -> str | None:
|
|
854
|
-
# chatSessions/<id>.jsonl -> workspace.json sits two levels up (workspace hash dir)
|
|
855
|
-
meta = path.parent.parent / "workspace.json"
|
|
856
|
-
try:
|
|
857
|
-
data = json.loads(meta.read_text(encoding="utf-8"))
|
|
858
|
-
except (OSError, json.JSONDecodeError, ValueError):
|
|
859
|
-
return None
|
|
860
|
-
folder = data.get("folder") if isinstance(data, dict) else None
|
|
861
|
-
if isinstance(folder, str) and folder.startswith("file://"):
|
|
862
|
-
path = unquote(urlparse(folder).path)
|
|
863
|
-
# Windows file URLs parse to "/C:/..." — drop the spurious leading slash.
|
|
864
|
-
if re.match(r"^/[A-Za-z]:/", path):
|
|
865
|
-
path = path[1:]
|
|
866
|
-
return path or None
|
|
867
|
-
return folder if isinstance(folder, str) and folder else None
|
|
868
|
-
|
|
869
|
-
|
|
870
628
|
# --- shared helpers --------------------------------------------------------
|
|
871
629
|
def _iter_jsonl(path: Path) -> Iterator[dict[str, Any]]:
|
|
872
630
|
try:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "coding-bridge"
|
|
7
|
-
version = "2026.7.
|
|
7
|
+
version = "2026.7.3.0"
|
|
8
8
|
description = "Node daemon for AceDataCloud Coding Bridge — run Claude Code on your own machine and drive it from the web."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|