A local proxy that bridges supported Anthropic- and OpenAI-compatible clients to Claude through the Agent SDK or Antigravity through the official agy CLI. This document defines the module structure, dependency rules, and design decisions.
backend: "antigravity" (or MERIDIAN_BACKEND=antigravity) selects a separate
runtime at the public server entrypoint before Claude authentication, sessions,
plugins or background maintenance start. Claude remains the default. The
existing AgentAdapter describes incoming clients and is not reused as a
backend selector.
backends/antigravityProtocol.ts owns pure validation, history identity and
prompt rendering. Its opt-in numeric thinking-budget normalization maps to Gemini
effort variants before continuation identity is calculated; runtime model discovery
checks availability, and responses expose the effective model.
antigravityRuntime.ts owns the official CLI subprocesses and loopback MCP transport.
Its bounded interrupted-continuation fingerprints permit only exact completed-result
replay after cancellation joins the old process, before any subsequent client
call was emitted and with native actions disabled. Exact concurrent retries wait
for joining and use the existing result-ID claim; successful replay removes the
exception without clearing consumed IDs. Optional SQLite preserves only the
fingerprints, not in-flight work. antigravityAttachments.ts materializes supplied images,
documents and adapted media in a disposable workspace; the hook permits exact
generated paths. antigravityUrl.ts validates/pins public HTTPS image downloads;
antigravityMedia.ts owns local ffmpeg/Whisper adaptation. antigravityNative.ts
defines the separately opted-in browser/subagent policy; browser MCP uses isolated
Chrome. antigravityProcess.ts contains platform quoting and process termination.
antigravityProbe.ts runs only official version/configuration/model-discovery commands, bounds
output and deadlines, and joins termination (including forced kill) before one
timeout retry for configuration or model discovery (never version or ordinary exits). Failed read-only probes impose a five-second account-check cooldown with Retry-After; generation is never retried by this mechanism. Runtime account validation remains fresh and shared
only among concurrent callers; settings refusals and ordinary command failures
are never retried or replaced with cached authorization.
antigravityOpenai.ts validates supported OpenAI subsets before shared translation;
antigravityOpenaiMedia.ts preserves original OpenAI attachments while adapting
them into the common media pipeline. antigravityResponses.ts holds bounded,
credential-scoped Responses snapshots with one oldest-first count/byte ledger
across volatile payloads and durable metadata (rebuilt from SQLite on startup); antigravityJobs.ts owns background
cancellation and bounded event replay. antigravityReplay.ts saves terminal text
answers to exact Anthropic tool-result continuations in a separate credential-scoped
budget (128 entries, 16 MiB total, 1 MiB each, 30 minutes). Snapshots are saved
before terminal delivery and replay as JSON or lazily generated SSE without model,
response-hook or usage accounting duplication. Explicit request IDs extend this
same budget to ordinary prompts and tool batches. Identity binds a credential
scope and complete request fingerprint; concurrent duplicates wait with bounded,
abortable waiter sets. Tool batches are saved before the first tool block, preserve
original call IDs on replay, and become non-replayable when their results are
being accepted or consumed. CLI loss still uses completed-history result recovery.
Native-capability requests and OpenAI routes do not use this cache. Pi's example
extension assigns IDs before SDK retries and retains only a request hash/ID for an
exact failed-turn retry. New prompts, session operations, changed payloads and
successful/aborted turns reset that identity. OpenCode's V1 example plugin uses its
public active assistant-message identity because its processor reruns header hooks
on retry; a random ID per header-hook invocation would repeat generation. Its
provider fetch wrapper forwards text events incrementally and holds tool/terminal
events until EOF and message_stop (4 MiB, five minutes, abortable). Interrupted
delivery gets one cache-only JSON recovery with the original request identity;
the wrapper validates every displayed text prefix and message ID before emitting
only the missing suffix and tool batch. Cache misses never generate a replacement.
This preserves incremental display without executing an incomplete tool prefix
and does not alter server request validation or client permissions. Optional antigravityState.ts persists
Meridian-owned records in private SQLite with an exclusive lifetime owner guard.
antigravitySessions.ts atomically claims exact completed/joined text/client-tool
native mappings and uses the public CLI conversation flag. A separate hash-only unfinished-request journal prevents blind re-execution of
identified Messages requests after an unclean restart when no answer snapshot exists.
It is bounded to 128 entries/30 minutes, refuses admission instead of eviction,
and releases guards after joined cleanup. Backend shutdown waits for identified
request finalizers, including bounded telemetry observers, before closing SQLite.
It does not record external tool outcomes.
In-flight processes never enter that durable session cache; private CLI transcripts are never read.
antigravitySetup.ts owns explicit Pi/OpenCode client configuration, separate from
server orchestration. The build packages self-contained retry integrations under
dist/antigravity-clients; npm, Nix and Docker installations carry those assets.
Setup prepares edits before writing, preserves unrelated settings, rejects malformed
or conflicting input, creates private backups and uses per-file atomic replacement.
telemetry/providerSetup.ts shares pure command generation and setup presentation
between the web provider page and desktop. Desktop clipboard requests contain
choices rather than arbitrary text; the main process validates them against its
current service/model state before copying through Electron. Browser clipboard
denial falls back to manual selection. Neither UI executes the generated command.
Client defaults change only with --set-default; model limits are conservative
client settings, not provider-enforced generation caps.
antigravityPlugins.ts exposes explicit Antigravity request transforms and isolated
response/telemetry observers, separate from Claude SDK plugins.
antigravityGrammar.ts validates custom payloads in bounded worker/Python jobs
before client delivery, never on the HTTP event loop.
antigravityTokens.ts provides explicitly labeled, side-effect-free estimates. antigravityStops.ts is a pure incremental text-stop matcher.
antigravitySchema.ts compiles request-local Ajv validators for client tool
arguments and native structured results. It never fetches remote references or
coerces client data. Tool definitions travel in the prompt as well as MCP, so
model calls do not depend on access to private CLI schema files.
antigravity.ts adapts the standard Request/Response
interface to Anthropic JSON/SSE. Only server.ts imports Hono and binds the
public listener. Backend modules do not import Claude session or cache modules.
The runtime maps contain live/warm conversations and outstanding tool correlations,
like sessionTree.ts; they are not another durable session cache. Tool calls
remain pending inside the official CLI until their client result arrives.
The exact pending assistant prefix also permits appended user steering, either
beside the tool result or in subsequent user messages. New instructions travel
in a separate MCP result envelope field; they do not become tool output and do
not start a second process. Client-owned delegation tools follow the same MCP
path as file tools. Native subagents require an independent operator grant and
inherit the guarded workspace. MCP request identity is scoped per initialized
session, avoiding collisions between native children. The synthetic parallel MCP
tool validates a whole batch before delivery; reverse-order results remain correlated. Tool choice
may change between responses without changing the remaining pending contract.
Client plugin changes to system instructions or tool definitions use completed-
history replay after claiming all results and joining the old pending process.
Model, session, execution controls and delivered history must still match. The
new process installs its own tool catalog and deny hook; telemetry records
client-context-replay. Failed preflight releases the replay claim for retry.
Native schema mode permits finish, withholds prose and emits only the final
structured result after clean exit. Text stops deliberately terminate and join
the process; they are separate from native token-budget controls.
Completed ordinary requests retain an idle live process. Exact matching history
and contract append only new user messages through official stream stdin. Schema
and stop paths remain one-shot. Changed histories, compaction and expired/restarted
processes replay full client history unless an eligible completed/joined mapping
is restored through the public CLI conversation flag. A complete tool-call/result
request without a live owner also replays,
allowing recovery after expiry or process restart without an extra user message.
A bounded set of consumed tool IDs rejects recent duplicate results without an eligible saved answer; a transient
claim prevents simultaneous recovery during preflight. Consumed ID digests can
persist in the optional Meridian state store; neither mechanism promises
exactly-once external tool execution. Admission may reclaim a process waiting
idle for a tool result or another user turn, joining it before replacement; active responses are never
evicted. A late completed result can use the same replay path. Native Claude transcript lifecycle and lineage persistence cannot be
applied to Antigravity. ProxyInstance.close() joins owned subprocesses;
direct fetch embedders use closeBackend(). Shutdown also joins workspace cleanup
for processes already removed from admission maps before closing SQLite.
backend: "combined" retains the Claude listener and mounts Antigravity at
/antigravity/*, with independent admission, processes, quotas and shutdown.
The /providers page and /providers/status report both without mixing account
identities or quota percentages. providerStatus.ts normalizes provider facts;
telemetry/providerView.ts is shared pure presentation for the web and desktop.
CLI quota reads are single-flight and cached, preserving stale readings on
failure. Bounded request metadata and native activity optionally persist in Meridian
state; runtime counters are rebuilt from retained exchanges. Claude session-cache
ownership is unchanged.
See the backend guide for the explicit permission opt-in, capability errors and recovery limits. Contract work is tracked in #1073.
Agent (OpenCode) ──► HTTP POST /v1/messages ──► Proxy Server
│
┌───────────┴───────────┐
│ Session Resolution │
│ (header or fingerprint)│
└───────────┬───────────┘
│
┌───────────┴───────────┐
│ Lineage Verification │
│ (continuation/compaction│
│ /undo/diverged) │
└───────────┬───────────┘
│
┌───────────┴───────────┐
│ Claude Agent SDK │
│ query() with MCP │
└───────────┬───────────┘
│
┌───────────┴───────────┐
│ Response Streaming │
│ (SSE, tool_use filter) │
└───────────┬───────────┘
│
Agent (OpenCode) ◄── SSE Response ◄─────────────────┘
Selected modules are shown below; the development guide provides source entry points for the other adapters and protocol routes.
src/
├── proxy/
│ ├── server.ts ← HTTP layer: routes, SSE streaming, concurrency, request orchestration
│ ├── concurrency.ts ← Abortable SDK query semaphore and concurrency config parsing
│ ├── requestAbort.ts ← HTTP request abort → SDK query abort bridge
│ ├── sessionTree.ts ← Live parent→child request registry; subtree cancellation (PURE bookkeeping)
│ ├── shutdown.ts ← Bounded HTTP drain and connection tracking
│ ├── inflight.ts ← Per-upstream in-flight request counts for GET /inflight (PURE bookkeeping)
│ ├── adapter.ts ← AgentAdapter interface (extensibility point for multi-agent support)
│ ├── adapters/
│ │ ├── opencode.ts ← OpenCode adapter (session headers, CWD extraction, tool config)
│ │ └── forgecode.ts ← ForgeCode adapter (fingerprint sessions, XML CWD, passthrough)
│ ├── query.ts ← SDK query options builder (shared between stream/non-stream paths)
│ ├── errors.ts ← Error classification (SDK errors → HTTP responses)
│ ├── retryAfter.ts ← Retry-After computation for 429/503/529 (PURE)
│ ├── models.ts ← Model mapping, Claude executable resolution
│ ├── authStatusProcess.ts ← Bounded auth/resolver children and explicit process/pipe joins
│ ├── claudeResolverOwnership.ts ← Independent leases for shared async resolver custody
│ ├── authStatusOwnership.ts ← Per-instance ownership of shared auth-status refreshes
│ ├── buildInfo.ts ← Build provenance: source detection, semver compare (PURE)
│ ├── localBuildInfo.ts ← Local build comparisons and public forge links (PURE)
│ ├── buildRuntime.ts ← Immutable runtime identity and independent disk status
│ ├── buildSnapshot.ts ← Git/source snapshot boundary
│ ├── buildFingerprint.ts ← Streaming file hashing and bounded metadata reads
│ ├── buildProvenanceError.ts ← Shared provenance boundary errors
│ ├── buildArtifacts.ts ← Serialized build certification and artifact validation
│ ├── buildLock.ts ← Local builder owner claims and dead-owner recovery
│ ├── buildObserver.ts ← Single-flight bounded disk observation cache
│ ├── buildObservationWorker.ts ← Off-thread source/artifact observation
│ ├── updateCheck.ts ← Opt-in cached npm registry lookup for the newest published version
│ ├── tools.ts ← Tool blocking lists, MCP server name, allowed tools
│ ├── messages.ts ← Content normalization, message parsing
│ ├── replay.ts ← Pure rendering of assistant calls and tool results for SDK replay
│ ├── types.ts ← ProxyConfig, ProxyInstance, ProxyServer types
│ ├── session/
│ │ ├── index.ts ← Barrel export
│ │ ├── lineage.ts ← Pure functions: hashing, lineage verification
│ │ ├── fingerprint.ts ← Conversation fingerprinting, client CWD extraction
│ │ ├── cache.ts ← LRU session caches, lookup/store operations
│ │ ├── turnCoordinator.ts ← Process-wide serialization for reliable session IDs
│ │ ├── crossProcessTurnCoordinator.ts ← Durable coordination across proxy processes
│ │ ├── processIncarnation.ts ← Process/host identity for lock ownership
│ │ └── durableFileSystem.ts ← Durable file operations
│ ├── sessionStore.ts ← Shared file store (cross-proxy session resume)
│ ├── profiles.ts ← Multi-profile support: resolve, list, switch auth contexts (leaf)
│ ├── profileCli.ts ← CLI commands for profile management (leaf, I/O)
│ ├── profileConfigStore.ts ← cross-process profile writer lock and atomic snapshots (leaf, I/O)
│ ├── profileLogin.ts ← Browser re-authentication state, redirect/paste completion and status
│ ├── profileAdd.ts ← Browser profile creation and isolated credential persistence
│ ├── profileOAuthBody.ts ← Runtime schemas for browser OAuth request bodies (pure)
│ ├── statusProbe.ts ← Asks a busy port whether it is Meridian, and collects what / shows
│ ├── agentDefs.ts ← Subagent definition extraction from tool descriptions
│ ├── agentMatch.ts ← Fuzzy agent name matching
│ ├── design.ts ← Claude Design MCP proxy (token store/refresh, auth precedence, login flow)
│ └── passthroughTools.ts ← Tool forwarding mode (agent handles execution)
├── fileChanges.ts ← PostToolUse hook: tracks write/edit ops, formats summary
├── mcpTools.ts ← MCP tool definitions (read, write, edit, bash, glob, grep)
├── logger.ts ← Logging with AsyncLocalStorage context
├── errorReporting/ ← Opt-in crash reporting to a GlitchTip/Sentry DSN (off without one)
│ ├── event.ts ← Thrown value → scrubbed Sentry event/envelope (PURE)
│ ├── deliver.ts ← Spool → collector; self-contained so a dying process can run it detached
│ └── index.ts ← Process hooks, spool writes, delivery scheduling
├── utils/
│ └── lruMap.ts ← Generic LRU map with eviction callbacks
├── telemetry/
│ ├── index.ts ← Barrel export
│ ├── store.ts ← Request metrics storage
│ ├── routes.ts ← Telemetry API endpoints
│ ├── logStore.ts ← Diagnostic log ring buffer
│ ├── dashboard.ts ← HTML dashboard
│ ├── pricing.ts ← Static API list prices + cost estimation (pure)
│ ├── pricingStore.ts ← User pricing overrides (persisted JSON)
│ ├── profileBar.ts ← Shared profile switcher bar (injected into HTML pages)
│ ├── profilePage.ts ← Profile management page HTML
│ ├── pageLayout.ts ← Contained/wide layout setting, stamped on each page as it is served
│ ├── cliDashboard.ts ← The landing page rendered for a terminal (pure)
│ └── types.ts ← Telemetry types
plugin/ ← Repository root, alongside src/
├── meridian.ts ← OpenCode V1 session header plugin
└── meridian-v2.ts ← OpenCode V2 plugin
Dependencies flow downward. A module may only import from modules at the same level or below.
server.ts (HTTP layer)
│
├── adapter.ts (interface)
├── adapters/opencode.ts ──► messages.ts, session/fingerprint.ts, tools.ts
├── query.ts ──► adapter.ts, mcpTools.ts, passthroughTools.ts
├── errors.ts
├── retryAfter.ts
├── requestAbort.ts
├── sessionTree.ts
├── models.ts
├── tools.ts
├── messages.ts
├── session/cache.ts ──► session/lineage.ts ──► messages.ts
│ ──► session/fingerprint.ts
│ ──► sessionStore.ts
├── profiles.ts
├── profileCli.ts
├── agentDefs.ts
├── agentMatch.ts
├── fileChanges.ts
├── passthroughTools.ts
├── mcpTools.ts
└── telemetry/
-
session/lineage.tsis pure. No side effects, no I/O, no caches. Only crypto hashing and comparison logic. Must stay testable without mocks. -
session/cache.tsowns all mutable session state. No other module should create or manage LRU caches for sessions. -
errors.ts,retryAfter.ts,models.ts,tools.ts,messages.ts,profiles.ts,profileCli.ts,buildInfo.ts,updateCheck.tsare leaf modules. They must not import fromserver.ts,session/, oradapter.ts.buildInfo.tsandretryAfter.tsare additionally pure — every export is a function of its arguments (plusprocess.envforbuildInfo.ts), so the registry I/O lives inupdateCheck.tsinstead. -
server.tsowns Hono route registration and orchestration. Hono middleware stays at the proxy boundary. StandardRequest/Responsedispatch lives in the provider backend where needed. Sharedsrc/headerSettings.tshandles only hostname settings validation, Origin policy and persisted consent, using standard web types andsettings.ts; it must not import a server, provider, Hono or session module. Backend auth remains at each caller's existing boundary. -
No circular dependencies. If you need to share types, put them in
types.tsor the relevant leaf module. -
adapter.tsis an interface only. No implementation logic. Adapter implementations go inadapters/. -
query.tsbuilds SDK options through the adapter interface, never importing tool constants directly. -
sessionTree.tsholds only live-request bookkeeping. No HTTP, no I/O, no logging: the caller supplies each entry's abort handle and owns the eviction and telemetry discipline that follows an abort. It must not import fromserver.ts,session/, oradapter.ts.
operationalLog.ts owns the existing process-wide operational stderr silence
policy. Server orchestration and lifecycle queue logging use it without importing
each other; diagnostic entries remain available to silent embedding hosts.
Agent-specific behavior is isolated behind the AgentAdapter interface (adapter.ts). The proxy calls adapter methods instead of hardcoding agent logic.
adapters/opencode.ts— OpenCode agent (session headers,<env>block parsing, tool mappings, and recognized transient hook envelopes for lineage)adapters/forgecode.ts— ForgeCode agent (fingerprint sessions,<current_working_directory>parsing,patch/shelltool mappings)
- Create
adapters/myagent.tsimplementingAgentAdapter - Wire it into
server.ts(currently hardcoded toopenCodeAdapter; future work will auto-detect) - No changes needed to
query.ts,session/, or other infrastructure
| Method | What It Does |
|---|---|
getSessionId(c) |
Extract session ID from request headers |
getAgentMode(c, body) |
Normalize an adapter-specific primary/subagent declaration |
extractWorkingDirectory(body) |
Parse working directory from request body |
normalizeContent(content) |
Normalize message content for hashing |
getBlockedBuiltinTools() |
SDK tools replaced by agent's MCP equivalents |
getAgentIncompatibleTools() |
SDK tools with no agent equivalent |
getMcpServerName() |
MCP server name for tool registration |
getAllowedMcpTools() |
MCP tools allowed through the proxy |
| Logic | Location | Status |
|---|---|---|
buildAgentDefinitions |
agentDefs.ts, transforms/opencode.ts |
Pure OpenCode Task parser invoked by the adapter transform. |
| Passthrough mode | passthroughTools.ts |
Agent-agnostic but OpenCode-motivated. Keep as-is. |
ALLOWED_MCP_TOOLS usage in server.ts |
Line ~176 | Used for buildAgentDefinitions. Move when adapter handles agent defs. |
Sessions map an agent's conversation ID to a Claude SDK session ID. Two caches work in tandem:
- Session cache: keyed by agent header (
x-opencode-session) - Fingerprint cache: keyed by hash of first user message + working directory (fallback when no header)
A client's session header is not always a conversation identity. OpenCode
runs its internal one-shot agents (title, summary, compaction) under the
user's session id, so x-opencode-session alone named two unrelated
conversations at once — the title prompt and the user's chat. They shared one
lineage and one turn lease, which cost the user's first turn either a 400
session_turn_conflict or a cold-cache full replay. openCodeAdapter.getSessionId
therefore appends the agent name for non-primary agents (ses_x#title), leaving
the primary agent's key byte-identical to the header. An adapter whose client
multiplexes agents over one session id needs the same treatment.
A session header is identity, never authentication. Polytoken's native
X-Polytoken-Session header is the cleanest example: the trimmed header value
IS the conversation identity — no agent-mode scoping, no lineage
canonicalization, no attestation. A blank/missing native header means "no
identity" rather than an invented fallback key: a headerless tool-result
continuation runs independent (never resumed, nothing stored) via the
existing client-driven-loop guard, and plain text turns keep the generic
fingerprint fallback shared by every headerless client. The polytoken adapter
forces client-owned passthrough unconditionally: instance
passthrough: false and global MERIDIAN_PASSTHROUGH=0 are ineffective for
that base, because the protocol's tool loop lives entirely in the client.
Both are LRU with coordinated eviction — evicting from one removes the corresponding entry in the other.
Every request verifies that incoming messages are a valid continuation of the cached session:
| Classification | Condition | Action |
|---|---|---|
| Continuation | Full stored prefix hash matches | Resume from the stored message boundary |
| Compaction | Suffix preserved, beginning changed | Resume after the matched suffix |
| Undo | Prefix preserved, suffix changed | Fork at rollback point |
| Diverged | No overlap, unverifiable state, or a changed cached prefix followed by new messages | Start fresh and replay the complete client history |
Lineage verification is side-effect free. A classification may select an SDK session and a replay boundary, but updated message counts and hashes are only stored after the upstream request succeeds. When Meridian cannot prove that an SDK session contains a section of client history, it starts fresh rather than silently skipping that section.
Meridian's clients are increasingly harnesses that run many concurrent sessions through one account, so a refusal has to say enough for them to coordinate.
Every 429, 503, and 529 carries a wait. retryAfter.ts computes the number:
upstream's own Retry-After if it survived into the error, then a hint embedded
in the upstream error text, then the account's observed window reset from
rateLimitStore, then a per-status constant (60s for a rate limit, 5s for
overload). It is clamped to at least 1 second and at most 24 hours, so no source
can produce "retry immediately" or "retry never". Non-streaming responses carry
it as a real Retry-After header; SSE turns carry it as error.retry_after in
the error frame, because a stream's headers went out with message_start long
before the failure existed. Under priority routing the wait names the pool's
earliest opening, not the last account tried.
A [1m] bench is scoped to whatever actually failed. Extra Usage exhaustion
is an entitlement fact about the account, so it benches the whole profile. A
plain rate limit benches only the session that hit it (models.ts,
recordExtendedContextRateLimited). Benching the profile on a rate limit
downgraded every concurrent sibling at once, and the model switch cold-caches
each of them — their cached prefixes were built on the 1M model. Clients with no
session identity still bench profile-wide; there is nothing narrower to use.
Cancellation is per-HTTP-request: requestAbort.ts forwards one socket's abort
into that request's SDK abort controller, and the abort path evicts the session
mapping so no interrupted tail stays resumable.
That is not enough for a client whose subagents are separate requests. Prime Agent's RLM children arrive on their own session keys, so cancelling the parent left every child running — holding an SDK permit and a turn lease, billing the subscription until its own socket closed or the lease watchdog tripped.
sessionTree.ts closes the gap. A client that knows its own tree stamps the
immediate parent alongside the child's session id (metadata.user_id →
{ session_id, parent_session_id }); server.ts registers that link for the
lifetime of the request and, on a client abort, aborts every live request whose
ancestry reaches the aborted key — through each child's own request abort
controller, so the eviction, permit release, and lease release that follow are
the existing abort path's rather than a second implementation.
Three properties bound it:
- Abort, not completion. A parent turn that finishes normally does not cancel children; a subagent routinely outlives the turn that spawned it. The shutdown path already aborts every request directly, and the lease watchdog is a proxy-side fence rather than a user intent, so neither cascades.
- Live requests only. An entry exists between "admitted" and "settled". A session that was seen once but has nothing in flight is not a cancellation target, so the registry is bounded by concurrency, not by history.
- Self-gating. Propagation can only reach a request that declared a parent, so every client that does not stamp linkage is unaffected with no flag to set.
POST /v1/sessions/:key/cancel cancels a subtree explicitly, and
GET /telemetry/summary reports the live gauges and cumulative counts under
sessionTree.
Three tiers, each catching different classes of bugs:
| Tier | Files | SDK | Speed | Runs In |
|---|---|---|---|---|
| Unit | src/__tests__/*-unit.test.ts |
None | Fast | CI (bun test) |
| Integration | src/__tests__/proxy-*.test.ts |
Mocked | Fast | CI (bun test) |
| E2E | E2E.md |
Real (Claude Max) | Slow | Manual, pre-release |
- Unit tests: Pure functions, no mocks, no I/O.
- Integration tests: HTTP layer with mocked SDK. Deterministic.
- E2E tests: Real proxy + real SDK + real Claude Max. See
E2E.mdfor runnable procedures covering session continuation, undo, compaction, cross-proxy resume, tool loops, streaming, and telemetry.
All tests import from source modules, not build output.
Tests that need clearSessionCache or createProxyServer import from ../proxy/server.
Every change must pass all existing unit and integration tests:
npm test # runs: bun testE2E tests (E2E.md) should be run before releases or after major refactors.
→ Create a new leaf module in src/proxy/. Add unit tests.
→ Add to the appropriate existing module (session/cache.ts, sessionStore.ts). Don't create new caches elsewhere.
→ Add to server.ts. Keep route handlers thin — delegate to extracted modules.
→ Implement AgentAdapter in src/proxy/adapters/. See adapters/opencode.ts for reference. Do not hardcode agent-specific logic in leaf modules.
sessionLifecycle.ts persists a publication lease atomically with each new request target before SDK launch. The lease survives physical SDK writer shutdown and commit until the synchronous durable mapping CAS succeeds, or the request abandons its target. Failed publication restores the lease. Collectors in other processes cannot depend on a proxy instance's private request pins, so they consult these durable leases as well as durable mappings.
Publication leases use the existing unarmed active-lease representation with purpose: "publication". Older collectors also retain them while the owner process is alive; exact process-incarnation death permits recovery. They do not count as exclusive SDK writers, and abandoning publication never removes an actual writer lease. Published transcripts are retained by their durable mappings and become collectible after eviction.
An SDK writer lease is released once its writer has been joined. If the lifecycle lock is busy at that point, the next GC sweep, including the shutdown sweep, retries the release; the completed turn does not fail. On win32 an executor's death does not prove its descendants gone, so a writer lease left by a crashed proxy is recovered only after the host reboots.
session/lifecycleLockQueue.ts admits lifecycle transactions in a per-lock-path
FIFO with at most 256 waiting callers. Local waiting does not consume the
two-second external-lock acquisition budget; only the head creates a durable
candidate. A holder stalled for 60 seconds rejects queued/new callers without
unlocking or abandoning its transaction. A stall deadline that runs more than a
second late was delayed by a blocked event loop, which delayed the holder too, so
it grants one additional window. The second deadline rejects waiters even if it
is late; the holder still owns the lock until its actual completion. Capacity and
stalled-holder errors are
distinct, defined in the dependency-leaf session/lifecycleErrors.ts.
A turn whose model already answered does not fail on any of these lock errors
at terminal publication: they are raised before the transaction runs, so the
turn invalidates its unchanged pre-turn mapping and the next turn replays. A
durable priority attempt still requires its atomic publication.
Request admission signals remove queued work and cancel external acquisition,
but a running durable callback always finishes before returning ownership.
Cleanup never receives the canceled admission signal. Publication callbacks
remain synchronous; same-context recursive acquisition is rejected explicitly.
sessionStore.ts mutations are synchronous and run on the event loop, so their cost is lag for every request. The parsed document is cached by file identity (device, inode, size, mtime, ctime); every writer publishes by rename while holding the store lock, so a locked mutation that finds the cache current builds on it without re-parsing. Mutators receive a copy-on-write draft and replace entries rather than editing them. New entries own a deep copy of caller data before serialization. Cached entries/maps and metadata are frozen; privately parsed nested arrays are frozen before a lookup or snapshot exposes them, avoiding a full nested walk for a single cold lookup. Each entry's serialized UTF-8 bytes are memoized, so a write encodes only the entries it changed. Unchanged entries keep their identity and serialized bytes. The file format, lock, fsync and rename are unchanged.
A conversation that has run under several profiles has one mapping per profile (<profile>:<session>), each holding full per-message hashes and pinning its own transcript. With explicit MERIDIAN_SESSION_PROFILE_COPY_PRUNE=1, before each GC sweep mappings superseded by a newer copy under another profile and unused for MERIDIAN_SESSION_PROFILE_COPY_GRACE_MS (default 24 hours, DEFAULT_PROFILE_COPY_GRACE_MS) are removed. Priority route and rollback mappings and conversations with a turn registered in this process are exempt. Removal only unpins transcripts; reconciliation retires them through the normal lifecycle backlog, and releaseSupersededProfileCopies limits the transcripts it unpins so that at least half of the pending budget stays free and admission never has to return the prune's retirements to live. A conversation returning to a pruned profile replays instead of resuming. Pruning is off by default to preserve native resume history, including SDK thinking that flattened replay cannot restore. Maintenance acquires nonwaiting conversation leases and reserves retirement capacity while holding the lifecycle lock; held or stale turn locks defer pruning.
session/lineage.ts hashes structured v2 records with separate history, message and block domains. Records preserve roles, block and message boundaries, tool call identity/arguments, and result identity/error status. JSON object keys are canonicalized; plain text and a single text block remain equivalent, and opaque thinking/cache hints remain excluded. Display-oriented normalizeContent is not a lineage proof.
Existing v1 digests cannot establish a v2 prefix. Their next request on an upgraded proxy safely replays the full supplied history and publishes v2 hashes; subsequent requests on upgraded proxies resume normally. Alternating between old and new proxy versions can repeat this replay cost until all participating proxies are upgraded. This migration relies on complete fresh replay, including completed tool calls/results and media. Stored transcript files are never rewritten to migrate hashes.
A trailing user tool-result slot may gain new content while every stored block remains an exact prefix. Lineage verification allows that continuation and sends only the appended canonical blocks; duplicate result IDs, edits and meaningful removals still replay. This supports text, images and other appended content without treating an ordinary user-message edit as an append-only tool continuation.
The OpenCode adapter separately recognizes complete user-prompt-submit-hook JSON envelopes for UserPromptSubmit additional context and common SDK hook-control fields, including continue. These per-turn blocks remain in the original SDK request but are excluded from durable lineage comparisons when a durable block remains. Unknown/malformed envelopes, surrounding prose, hook-only messages and assistant-authored lookalikes remain significant. Other adapters do not inherit this rule. A subset of arbitrary user blocks is never sufficient proof of a continuation.
Structured resume deltas are delivered in one SDK user input, matching text-delta delivery. SDK streamed inputs are independently answered live turns; splitting a growing request's appended context from its final question can yield concatenated answers. The shared pure coalescer preserves result wrappers and media order, and also backs fresh replay framing.
apps/desktop is an independent Electron package; headless installations do not
install or import it. The sandboxed renderer calls a narrow preload interface.
The main process validates the sender and performs local HTTP requests against
existing Meridian endpoints. No server API or plugin contract changes are
required. Native Liquid Glass loads only on supported macOS systems.
The renderer keeps page navigation and unsaved forms local. uiData.ts filters
request metadata without inspecting arbitrary nested content; direct tests cover
combined searches and the continuation-only low-cache filter.
The app can connect to an external service or own a separate installation. Managed versions are installed atomically under Electron's user-data directory, and run through the published CLI under bundled stock Node (avoiding Electron's native-module ABI). A Node preload watches the parent IPC channel; the CLI owns its normal signal drain. Startup checks the selected HTTP version and, on macOS, the listener PID. Failed activations restore the previous selected version. The tray keeps the app alive when its window closes; explicit quit drains only its owned child. Crash recovery is bounded to three attempts.
Docker/Nix/other external supervisors retain ownership when the UI connects. Compatible macOS LaunchAgents can transfer ownership through a confirmed IPC action. An encrypted journal precedes supervisor changes and remains until the original supervisor is healthy after return. Recovery recognizes an already restored supervisor after an interrupted return. The curated plugin installer uses versioned npm directories and atomic shared-configuration replacement; it never installs into an externally managed service. See the desktop README for implemented scope and remaining live verification.
The menu-bar panel has its own sandboxed renderer and native glass window. IPC
accepts only the exact main frame and local entry URL of either desktop window.
Both render the same manager snapshot and use the same lifecycle/profile actions.
notifications.ts separates desktop delivery policy from incident collection;
category preferences, burst thresholds and persisted cooldown timestamps prevent
per-request alerts. Recovery exhaustion is critical; individual child exits remain
in the in-app history.
Auth-status caches remain shared by profile/default context. Each proxy instance owns only the refreshes its routes or keepalive requested; closing one owner does not cancel a sibling's shared check. The last owner cancels and joins its check through the existing shutdown path. An independent direct caller retains its own ownership until the bounded process finishes. Caller patience is five seconds, while the process deadline is ninety seconds.
authStatusProcess.ts records callback, exit, close and both captured pipe
closures independently. Cancellation uses the exact owned child handle, with
bounded TERM/KILL escalation. Missing settlement rejects cleanup and retains
the single-flight slot; neither a settled result promise nor exitCode alone
proves the child joined. Cache expiry never detaches an in-flight check. The
existing ProxyInstance.close() and closeBackend() signatures are unchanged.