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

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

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:
ClaudeCodeSurface.on_bootstrap performs:
Other surfaces’ on_bootstrap is empty (pass).

§5 — Integration with prism_wrap

The replacement at mcp/server.py prism_wrap (line ~2278–2281) is:
ClaudeCodeSurface.on_wrap performs:
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 26, 2026