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
68 changes: 66 additions & 2 deletions lib/session-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ first app on it.

| File | Role |
|---|---|
| `session_bridge.py` | The `Transport` port, the loopback adapter (`LoopbackWatcher`, `LoopbackHandler`, `start`, `serve`) and the client half the app's control script runs |
| `session_bridge.py` | The `Transport` port, the loopback adapter (`LoopbackWatcher`, `LoopbackHandler`, `start`, `serve`), the client half the app's control script runs, the channels adapter (`ChannelRelay`, `ChannelServer`) and `select_transport` |
| `watch.sh` | The watcher: long-polls `/api/wait` from a background Bash task and prints one JSON line when there are events |
| `wake.sh` | One wake: the app's `apply` on `<data_dir>/ops.json`, then `watch.sh` |
| `test_session_bridge.py` | Tests against a toy app: port, guards, long-poll, lease, event stream, client, `watch.sh` and `wake.sh` |
| `test_session_bridge.py` | Tests against a toy app: port, guards, long-poll, lease, event stream, client, `watch.sh` and `wake.sh`, adapter selection, and the channel server over stdio |

These are canonical sources. Each carrying plugin gets a generated copy through
`scripts/shared-copies.txt` and `scripts/sync-shared-copies.sh` (ADR 0019); edit here, then run the
Expand Down Expand Up @@ -77,6 +77,70 @@ is `WATCH_ID`, else `CLAUDE_CODE_SESSION_ID`, else `<hostname>-<parent pid>`.
`watcher_lease` and `release_lease` are the pieces an app's control script composes into
`ensure-running`, `stop` and `lease`.

## The channels adapter

Claude Code's native [channels](https://code.claude.com/docs/en/channels) are a research preview:
an MCP server the session spawns pushes `notifications/claude/channel` events into it
([channels reference](https://code.claude.com/docs/en/channels-reference)). Verified 2026-10-03;
recheck when either page changes the flags, the `claude/channel` capability or the policy keys.

- `session_bridge.py relay` runs `ChannelServer`, a stdio MCP server that declares the
`claude/channel` capability and three tools taking `data_dir`: `watch`, `events` and `unwatch`.
It reads `NAME` and `CONTROL` from `session-bridge.conf` beside it, as `watch.sh` does.
- `watch` starts a `ChannelRelay` for the data dir. The relay implements the port against the page
server: it long-polls `/api/wait` with the token from the 0600 env file and holds the lease in
place of `watch.sh`, so the page shows the session as listening. Its log is the batch the page
server delivered that the session has not read yet.
- On new events the relay rings the session with one channel event. The event names only the data
dir, `seq` and `count`; it never carries page text, so nothing from the page reaches the session
as a channel message. `events` returns the batch in `watch.sh`'s line shape, with the data note,
and `next` is the app's apply command (`CONTROL --dir <data_dir> apply --file <data_dir>/ops.json`).
No re-arm is needed: the relay keeps polling and does not ring again for events it already rang.
- A 409 (lease held or released), a changed token, the page server stopping, or 12 unreachable
polls end the relay; it rings once more with `stopped="1"` and the reason. `unwatch` releases the
lease and rings nothing.
- The relay's poll records its own pid, and `end_watcher` signals only a `watch.sh`, so the app's
`stop` never signals the channel server.

An app ships the relay by registering `session_bridge.py relay` in its plugin's `.mcp.json`; the
person then starts the session with `--channels plugin:<plugin>@<marketplace>` (only when the
organization's `allowedChannelPlugins` lists it) or
`--dangerously-load-development-channels plugin:<plugin>@<marketplace>`. No app ships it yet: the
planning interview stays on the loopback watcher.

## Choosing the adapter

`select_transport(entries)` (or `session_bridge.py select <entry>...`) returns
`{"transport": "channels" | "loopback", "reason": ...}`, where `entries` are the relay's flag forms
(`plugin:<plugin>@<marketplace>`, `server:<name>`). It picks channels only when every check below
passes, in this order, and otherwise keeps the loopback watcher and names the first failed check.
An input it cannot read counts as failed, so an unknown never selects channels.

| Check | Reads | Keeps loopback when |
|---|---|---|
| Provider | `CLAUDE_CODE_USE_BEDROCK`, `_VERTEX`, `_FOUNDRY`, `_MANTLE`, `_ANTHROPIC_AWS` | Any is set: channels need claude.ai or Console auth |
| Session opt-in | The argv of the nearest ancestor naming `--channels` or `--dangerously-load-development-channels` (`/proc`, else `ps`) | No entry is named, or the ancestors cannot be read (Windows) |
| Auth | `claude auth status --json` | It cannot be read, `loggedIn` is not true, or `apiProvider` is not `firstParty` |
| Organization | The first managed source with a policy key: the server-managed cache (`~/.claude/remote-settings.json`), then `managed-settings.json` with `managed-settings.d/*.json` | A source exists without `channelsEnabled: true`, or none exists and `subscriptionType` is `team` or `enterprise` |
| Allowlist | `allowedChannelPlugins` in that source | The entry came by `--channels` and the list does not name its plugin and marketplace |

MDM policies (a macOS plist, the Windows registry) are not read, so a policy delivered only by
MDM reads as none. Claude Code drops channel events silently when a policy blocks them; its
startup notice says so.

## Prerequisites

The channels adapter's prerequisites. Each one's absence keeps the loopback watcher, which needs
none of them. A plugin that registers the relay adds the `claude` row to its `prerequisites.json`;
the others are not checker kinds, and `select_transport` reports them.

| id | need | detect | degrade |
|---|---|---|---|
| `claude` | optional | `claude auth status --json` | Without it the session's auth cannot be read, so the session keeps the loopback watcher. |
| `anthropic-auth` | optional | `loggedIn` and `apiProvider: firstParty`, no third-party provider variable | On Bedrock, Agent Platform, Foundry or another provider, channels are unavailable; the loopback watcher carries events as before. |
| `channels-opt-in` | optional | The session's launch flags name the relay | A session started without the flag keeps the loopback watcher. |
| `org-channels-enabled` | optional | `channelsEnabled: true` in a readable managed source, or a plan with no organization checks | A Team or Enterprise organization that has not enabled channels, or a managed policy without `channelsEnabled: true`, keeps the loopback watcher. |

## Tests

```bash
Expand Down
Loading
Loading