> ## Documentation Index
> Fetch the complete documentation index at: https://prism.ntecdev.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# SPEC-046 v0.1: Agent Surface Adapters — Per-Surface Lifecycle Modules for prism_start / prism_wrap

> Agent Surface Adapters — Per-Surface Lifecycle Modules for prism_start / prism_wrap

<Info>**Status:** `draft` · **Version** `0.1` · Filed 2026-04-26</Info>

# SPEC-046 v0.1 — Agent Surface Adapters: Per-Surface Lifecycle Modules for prism\_start / prism\_wrap

## Version

0.1

## Status

draft

## Origin

Two converging signals from the 2026-04-26 work surface this:

1. The SPEC-044 idle-wake fix (commit `4652772`) added
   `channel_bridge.capture_session_lazy()` to `prism_start` and
   `channel_bridge.reset_all()` to `prism_wrap`. Both calls are Claude
   Code-specific — Codex, Cursor, and Claude Desktop have no use for the
   channel bridge — but they live unconditionally on the hot path of
   every bootstrap and every wrap. Today they're inert no-ops on
   non-channel surfaces; tomorrow's Codex App Server inject hook
   (SPEC-035 trajectory) will need its own bootstrap+wrap pair, and
   conditionals will start to multiply inline.

2. `mcp/strategies/` already implements the right pattern at half-scale.
   `select_strategy(surface)` returns a `SignalDeliveryStrategy` per
   `PRISM_AGENT_SURFACE`, and adding Codex inject support reduces to
   "one new class + one map entry." That pattern exists because signal
   delivery is genuinely surface-specific. It belongs to a wider
   concern: **the entire MCP-process lifecycle is surface-specific in
   places, not just signal delivery.**

This spec generalizes the strategy pattern into a full agent-surface
adapter layer that owns bootstrap and wrap hooks alongside signal
strategy selection.

## §1 — Goals

1. **Single dispatch site for surface-specific behavior.** Adding a new
   agent surface (next candidates: ChatGPT Desktop, ACP-compliant
   editors) is one new file plus one registry entry.
2. **Symmetric `prism_start` / `prism_wrap` lifecycle.** Whatever a
   surface attaches on bootstrap, it detaches on wrap. No more "the
   channel bridge release is in prism\_wrap because there was nowhere
   else to put it."
3. **No surface-specific code on the hot path of `prism_start` /
   `prism_wrap` cores.** Both verbs read the surface once via
   `get_surface()` and delegate.
4. **No new env-var plumbing or argument propagation.** Surface
   resolution stays on `PRISM_AGENT_SURFACE` (already exported by
   `bin/coder.sh`, `bin/coder.ps1`, and `install/install.py` per
   SPEC-032 Phase A identity-fix delta).

### Non-goals (v0.1)

* Status-card rendering hints. Today's status card is uniform across
  surfaces and no surface has asked for variation. Pull into the
  surface contract only when concretely needed.
* Identity-elicitation response shape. Same reasoning.
* Heartbeat cadence overrides. Heartbeat is surface-agnostic — every
  surface refreshes the same Redis TTL.
* Any change to the WebSocket session stream (SPEC-045) endpoint or
  framing. The strategy attached to the stream is surface-supplied;
  the stream itself stays in `prism_start` core.

## §2 — Surface Contract

```python theme={null}
# mcp/agent_surfaces/__init__.py

@dataclass
class BootstrapContext:
    mcp_server: Any            # mcp._mcp_server (low-level handle)
    client: PrismHTTPClient
    pid: str
    session_id: str
    agent_identity: str
    project_id: str | None     # from controller_status['registration']

@dataclass
class WrapContext:
    mcp_server: Any
    client: PrismHTTPClient
    pid: str
    session_id: str | None     # may be None if bootstrap aborted
    agent_identity: str

class AgentSurface(Protocol):
    name: str   # "claude_code" | "codex" | "claude_desktop" | "cursor" | "other"

    async def on_bootstrap(self, ctx: BootstrapContext) -> None:
        """Called from prism_start AFTER controller registration succeeds
        and AFTER signal-stream spawn. The surface owns: signal-bridge
        wiring, push-channel attach, any surface-specific session
        priming. Idempotent — must tolerate being called twice across
        re-bootstrap-in-same-process paths."""

    async def on_wrap(self, ctx: WrapContext) -> None:
        """Called from prism_wrap BEFORE controller deregistration.
        The surface owns: signal-bridge release, push-channel detach,
        any surface-specific cleanup. Idempotent — must tolerate being
        called when on_bootstrap never fired (e.g., bootstrap aborted on
        identity_conflict)."""

    def signal_strategy(self) -> SignalDeliveryStrategy:
        """Return the strategy this surface uses for signal delivery.
        Replaces strategies.select_strategy() — strategy class
        definitions stay in mcp/strategies/, but the surface→strategy
        mapping moves into the surface modules themselves."""
```

`BootstrapContext` / `WrapContext` are minimal dataclasses carrying the
in-process state a surface might legitimately need. Each new field is
justified by a real surface requirement; no speculative additions.

## §3 — File Layout

```
mcp/agent_surfaces/
  __init__.py          # AgentSurface protocol, BootstrapContext,
                       # WrapContext, SURFACE_REGISTRY, get_surface()
  claude_code.py       # ClaudeCodeSurface — channel bridge
                       # capture/release; ChannelsPushStrategy
  codex.py             # CodexSurface — piggyback v0; placeholder for
                       # App Server inject hook (SPEC-035 trajectory)
  claude_desktop.py    # ClaudeDesktopSurface — piggyback, empty hooks
  cursor.py            # CursorSurface — piggyback, empty hooks
  other.py             # OtherSurface — universal fallback
                       # (piggyback, empty hooks)
```

Resolution: `get_surface()` reads `PRISM_AGENT_SURFACE` env, looks it up
in `SURFACE_REGISTRY` (a dict of name → class), falls through to
`OtherSurface()` for unknown values. Memoized per-process via a module-
level singleton. Lookup cost is amortized to zero after first call.

## §4 — Integration with prism\_start

The replacement at `mcp/server.py` `prism_start` (line \~1764–1840) is:

```python theme={null}
# Resolve the surface once.
surface = agent_surfaces.get_surface()

# Use surface to pick the strategy attached to the WS stream.
strategy = surface.signal_strategy()
_spawn_stream(..., strategy=strategy)

# After registration + heartbeat + stream are up, hand off to the
# surface for any surface-specific bootstrap work.
await surface.on_bootstrap(BootstrapContext(
    mcp_server=mcp._mcp_server,
    client=client,
    pid=pid,
    session_id=session_id,
    agent_identity=_agent_identity(),
    project_id=project_id,
))
```

`ClaudeCodeSurface.on_bootstrap` performs:

```python theme={null}
channel_bridge.capture_session_lazy(ctx.mcp_server)
channel_bridge.reset_coalescing()
```

Other surfaces' `on_bootstrap` is empty (`pass`).

## §5 — Integration with prism\_wrap

The replacement at `mcp/server.py` `prism_wrap` (line \~2278–2281) is:

```python theme={null}
# Surface gets first crack at cleanup, while session_id and client
# are still attached.
surface = agent_surfaces.get_surface()
await surface.on_wrap(WrapContext(
    mcp_server=mcp._mcp_server,
    client=client,
    pid=pid,
    session_id=session_id_for_release,
    agent_identity=_agent_identity(),
))

# Then the existing surface-agnostic teardown:
#   _stop_controller_stream(); _stop_heartbeat(); _stop_stream();
#   _BOOTSTRAPPED.pop(); _SESSION_IDS.pop(); ...
```

`ClaudeCodeSurface.on_wrap` performs:

```python theme={null}
channel_bridge.reset_all()
```

Other surfaces' `on_wrap` is empty.

## §6 — Cutover Plan

### Phase 1 — extract ClaudeCodeSurface only

* Create `mcp/agent_surfaces/` package with protocol, registry,
  `OtherSurface` fallback.
* Move `channel_bridge.capture_session_lazy` + `reset_coalescing` into
  `ClaudeCodeSurface.on_bootstrap`.
* Move `channel_bridge.reset_all` into `ClaudeCodeSurface.on_wrap`.
* Move signal-strategy selection from `strategies.select_strategy` into
  surface modules. `strategies.select_strategy` becomes a thin
  back-compat shim that delegates to `get_surface().signal_strategy()`.
* All non-claude\_code surfaces resolve to `OtherSurface()` until Phase
  2 — piggyback strategy, empty hooks. **No behavioral change for
  them.**
* `prism_start` and `prism_wrap` each gain one `await surface.on_*(ctx)`
  call site replacing the inline `channel_bridge.*` logic.

### Phase 2 — fill in named surfaces

* `CodexSurface`, `ClaudeDesktopSurface`, `CursorSurface` each become
  thin classes: `name` + `signal_strategy()` returning piggyback +
  empty hooks.
* These exist for clarity (one file per known surface) and as the
  insertion point when surface-specific behavior actually arrives
  (Codex `inject_items` being the obvious first case).

Phase 1 is mergeable on its own. Phase 2 is a non-behavioral cleanup
that can follow without coupling.

## §7 — Verification

1. `prism_start` on Claude Code: channel bridge captured. The existing
   SPEC-044 idle-wake doorbell smoke remains green.
2. `prism_wrap` on Claude Code: channel bridge released; no captured
   handles persist between sessions inside the same MCP process.
3. `prism_start` with `PRISM_AGENT_SURFACE=codex`: no `channel_bridge.*`
   calls fire; signals route via piggyback.
4. `prism_wrap` with `PRISM_AGENT_SURFACE=codex`: no `channel_bridge.*`
   calls fire; `on_wrap` is a no-op even if `on_bootstrap` never ran
   (bootstrap-aborted-on-identity\_conflict path).
5. Unknown `PRISM_AGENT_SURFACE` value: resolves to `OtherSurface`,
   piggyback delivery, no errors. One-line `WARNING` log naming the
   unknown surface.
6. Re-bootstrap in same MCP process (`prism_start` → `prism_wrap` →
   `prism_start` on a different PID): `on_bootstrap` called twice
   without crashing; `on_wrap` called twice without crashing.
7. Existing SPEC-044, SPEC-045, and SPEC-038 smokes all green.
8. `mcp/server.py prism_start` no longer mentions `channel_bridge` by
   name (lint-style assertion). Same for `prism_wrap`.

## §8 — Decisions

* **D1 — Lifecycle hooks only.** v0.1 surface contract owns bootstrap
  * wrap + signal strategy. Status-card formatting and identity
    elicitation response shape are NOT in the contract; pull in only
    when a second surface concretely requires variation.
* **D2 — Env-var dispatch, no argument plumbing.**
  `PRISM_AGENT_SURFACE` is the contract. Surface is read once at
  first call (memoized). Adding a `surface=` argument to
  `prism_start` / `prism_wrap` is rejected as redundant — the env var
  is already set by every supported launcher (`coder.sh`, `coder.ps1`,
  `install/install.py` editor configs).
* **D3 — `strategies/` stays.** The strategy classes
  (`ChannelsPushStrategy`, `PiggybackStrategy`) keep their current
  home. Surface modules *instantiate* strategies; they don't redefine
  them. `strategies.select_strategy` becomes a thin back-compat shim
  and is a candidate for full removal once no caller imports it
  directly.
* **D4 — `OtherSurface` is the safe default.** Unknown surfaces never
  raise; they get piggyback + empty hooks + a one-line warning. Same
  fail-soft posture as SPEC-021 §4.1's three-tier identity resolution.
* **D5 — Async hook signatures from v0.1.** Even though Claude Code
  is the only currently-async surface concern, Codex `inject_items`
  will be async (network IO into the App Server). Keeping the
  contract async from v0.1 means no signature break when Codex lands.

## §9 — Open Questions

* **Q1 — `on_bootstrap` order vs WebSocket stream spawn.** The stream
  is constructed *from* the surface's `signal_strategy()`, so the
  surface must exist first. The hook fires AFTER the stream spawn
  because the surface may want a stream-up handle for any post-spawn
  attach work. Confirm during implementation.
* **Q2 — `surface.on_signal_received(signal)` post-delivery hook?**
  Today the strategy IS the delivery point. A separate hook would be
  useful for surface-specific logging or telemetry, but no caller has
  asked. **Defer.**
* **Q3 — Memoization invalidation.** If `PRISM_AGENT_SURFACE` were to
  change inside a running MCP process (very unlikely — env is read
  once at coder launch), the memoized surface would go stale.
  **Resolution:** document the contract as "surface is fixed at
  process start"; do not invalidate. Same posture as
  `_agent_identity()` today.

## §10 — Performance Consciousness

The surface object is constructed once per MCP process and memoized.
Per-bootstrap cost: one dict lookup + one method dispatch. Per-wrap:
same. No hot-path cost added. The `channel_bridge.*` calls already
happen on the hot path today; this spec just moves them inside a
surface method.

## §11 — Authorship

* **Author:** Donna (Claude Code, session 973926f3) 2026-04-26.
* **Trigger:** chat-level architecture discussion with Frank, recall
  surfaced when reviewing commit `4652772` (the SPEC-044 idle-wake
  fix that added Claude Code-specific calls to both `prism_start`
  and `prism_wrap` without a surface abstraction to host them).
* **Reviewer:** Lola (Desktop) — should review the surface contract
  shape (§2) and the cutover phase split (§6) before any code lands.
* **Implementation gate:** Frank's GO. Do not touch `mcp/server.py`
  prism\_start / prism\_wrap until the spec is reviewed.

## §12 — Relationship to Other Specs

* **Generalizes** SPEC-034 §7 (signal delivery strategies). The
  `strategies/` package keeps its strategy class definitions; the
  surface→strategy mapping moves into surface modules.
* **Resolves** the surface-specificity smell introduced by SPEC-044's
  channel bridge calls in `prism_start` / `prism_wrap` (commit
  `4652772`). Those calls become `ClaudeCodeSurface` methods.
* **Pre-positions** SPEC-035 trajectory work (Codex App Server
  inject\_items). The surface module is the natural insertion point.
* **Does not modify** SPEC-045 (WebSocket data plane) — the stream
  itself stays in `prism_start` core; surfaces only supply the
  strategy attached to it.
* **Does not modify** SPEC-038 (collision policy + operator-gated
  force) — controller registration stays in core; surface hooks fire
  AFTER registration succeeds.
* **Consistent with** SPEC-033 §4 Layer 4 tiered signal delivery
  (Tier 1 channels / Tier 2 inject / Tier 3 ACP / Tier 4 piggyback).
  One surface module per tier-implementation is the natural shape
  this spec implements.
