Skip to main content
Status: draft · Version 0.1 · Filed 2026-04-26

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

# 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:
# 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:
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:
# 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:
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_startprism_wrapprism_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.
Last modified on April 27, 2026