Jam architecture main at 59f67f5d0 · 2026-09-29
View source

#Extension seams

On this pageCase study: adding OpenCodeWhat already works as a seamWhere transport knowledge leaksMatches on HostTransport outside jam-hostThe same wire names in five placesProvider string literalsDesktop copyChecklist: adding a harness todayChecklist: adding a cross-cutting featureObservations for the refactor

Adding OpenCode, a coding-agent harness that reuses the existing ACP protocol adapter, changed 25 files across seven Rust packages, the desktop TypeScript, and documentation. That is one measured extension, not a minimum for every harness. The seam that is supposed to hold provider knowledge, jam-host, does hold the protocol code, but the facts about each transport (its name, its settings, its credentials, its sandbox rules, its provider tag) are spread across the domain, manager, store, CLI, analytics, and desktop. The measurements are from main and identify what a plugin boundary would have to own.

The analysis covers two kinds of extension: a new harness (a coding-agent runtime Jam can own or attach to), and a new cross-cutting feature (a capability every harness should expose, such as a new runtime tool).

#Case study: adding OpenCode

Commit 3520e617f ("feat(opencode): first-party OpenCode runtime for hosted agents", 2026-09-18) added OpenCode as a first-class transport. OpenCode speaks Agent Client Protocol, so the commit reused the existing ACP adapter rather than writing a new protocol client. It still changed 25 files with 1,987 inserted lines:

Area Files What had to change
Domain crates/jam-domain/src/host_session.rs (215 lines added, 30 removed), lib.rs New HostTransport::Opencode variant, serde name, catalog row, is_owned_opencode, sandbox rules
Provider adapter crates/jam-host/src/opencode.rs (new, 432 lines), acp.rs (814 added, 16 removed), lib.rs OpenCode-specific launch and state, plus ACP changes to accommodate it
Composition bins/jam/src/jamd.rs (47 added, 3 removed) One more condition in the build_host if-chain, and an error message listing transports
Manager crates/jam-manager/src/manager.rs (264 added, 8 removed), runtime_host.rs, worker.rs, analytics.rs (+19) apply_runtime_opts, parse_runtime_transport, runtime_transport_name, default_owned_runtime_provider, clear_unsupported_thread_settings_for_transport, model checks, a transport tag, and an analytics enum mapping
Store crates/jam-store/src/sqlite.rs Two more entries in the transport string maps
Contract and analytics crates/jam-contract/src/lib.rs, crates/jam-analytics/src/contract.rs Doc strings listing transports; a new analytics enum value
CLI bins/jam/src/main.rs, agent_management.rs, cli-help.golden.txt A new RuntimeTransport clap value, its wire mapping, help text
Desktop LocalAgentCreatePanel.tsx, RuntimeTemplatePanel.tsx, fixtures.ts (+81), bindings.ts A title and description in providerCardCopy, a label in transportLabel, fixture rows, regenerated bindings
Docs CHANGELOG.md, docs/analytics.md, docs/user-guide.md Release notes and analytics documentation

The adapter code (about 1,260 lines in jam-host) is the part that is inherently per-harness. Most of the rest registers the same facts in parallel places.

#What already works as a seam

Several abstractions already do the job a plugin boundary needs, and the refactor should build on them rather than replace them.

The Host trait (crates/jam-host/src/lib.rs). Every adapter implements one trait: name, delivery_mode, prepare, teardown, teardown_for, and deliver through the jam-core::Deliverer supertrait. Optional capabilities have default bodies: compact, interrupt_turn, runtime_models, notify_room_unbound, runtime_pid, runtime_transport_active, idle_reap_policy, and readiness_warning. The manager drives every adapter through this trait and never names a concrete adapter type.

RuntimeHostContext (crates/jam-host/src/lib.rs). The manager hands each owned adapter a context carrying every provider-neutral broker it may need: event sink and ingress, permissions, environment approvals, questions, messages, roster, room and plan context, runtime tools, task access, artifacts gate, checkpoint broker and coordinator, resume observer, and team context. Adapters translate native requests into these brokers. That keeps questions, permissions, usage, and activity provider-neutral downstream.

Lease-scoped event ingress. Every event an owned adapter emits carries the RuntimeEventLease of the host instance that produced it, and passes through a RuntimeEventIngress that buffers startup events, releases them only after the worker commits ownership, and rejects events from suspended or retired hosts (state machine). A plugin adapter must emit through this ingress, not straight to the manager. Implementing Host alone is not enough.

The transport catalog (transport_catalog! macro and HostTransport::catalog() in crates/jam-domain/src/host_session.rs). One macro row per transport declares its label, default command, provider tag, environment allowlist, the offered and resolvable credentials, Docker sandbox capability cells, the sandbox GitHub credential policy, control channel, model support, create-picker visibility, integration provider, permission mode, launch examples, and runtime settings. HostTransport::ALL is generated from the rows, so a variant without a row fails to compile. The desktop reads the catalog through the runtime_transport_catalog command and derives its create picker and settings forms from it (apps/desktop/src/lib/runtimeTransports.ts), and apps/desktop/src-tauri/tests/transport_catalog_fixture.rs checks the desktop test fixture against it. This is the nearest thing Jam has to a plugin manifest.

Provider-neutral event and usage types. Adapters emit AgentEventKind values, including Usage with optional token categories, and all downstream layers use ActivityReport and ActivityKind. Usage from any owned adapter enters one pipeline (worker::runtime_usage_observation).

The runtime tool registry. Tools exposed to owned agents (tasks, workspace operations, collaboration, reply disposition) are defined once in jam-host (runtime_tools.rs, runtime_task_tools.rs, runtime_workspace_tools.rs, runtime_collaboration_tools.rs, disposition.rs) and translated per provider. Codex receives them as app-server dynamicTools, and host-native Codex also receives task tools through a jam mcp tasks MCP server.

#Where transport knowledge leaks

Production code only: scripts/architecture/count-production-refs.mjs excludes #[cfg(test)] items, test files, and test kits.

Production HostTransport:: references by file, outside jam-host. The provider crate is the intended seam, but manager.rs alone holds 95 of the 188 references.

HostTransport references outside jam-host

#Matches on HostTransport outside jam-host

File Production references Functions
crates/jam-manager/src/manager.rs 95 apply_runtime_opts (24), test_runtime_inner (9), clear_unsupported_thread_settings_for_transport (8), runtime_transport_name (8), parse_runtime_transport (7), set_runtime_settings (6), validate_owned_runtime_configuration (6), default_owned_runtime_provider (6), sandbox_agent_for_session (5), probe_runtime_models (4), probe_runtime_template_models (4), upsert_host_session (3), and others
crates/jam-domain/src/host_session.rs 38 Catalog-derived rules such as resolved_sandbox_capability_cell and validate_sandbox_auth, Display, and is_owned_* helpers
crates/jam-store/src/sqlite.rs 21 transport_str, the TRANSPORTS parse table, parse_transport
bins/jam/src/main.rs 10 runtime_summary
crates/jam-manager/src/analytics.rs 9 agent_runtime_configuration_for
crates/jam-manager/src/runtime_host.rs 8 transport_tag
crates/jam-domain/src/agent_event.rs (3); crates/jam-manager/src/managed_sandbox_kit.rs, crates/jam-manager/src/worker.rs, bins/jam/src/agent_management.rs, apps/desktop/src-tauri/src/commands.rs (1 each) 1 to 3 each Various

Within jam-host itself, adapters reference HostTransport only a handful of times. The provider branching is concentrated in the manager, not in the provider seam.

#The same wire names in five places

The string form of each transport ("codex-app-server", "copilot-sdk", and so on) is written out by hand in five separate functions:

Location Purpose
impl Deserialize for HostTransport in crates/jam-domain/src/host_session.rs Wire decoding, with an explicit refusal of the confusable claude-code
transport_str and TRANSPORTS in crates/jam-store/src/sqlite.rs Database encoding, plus legacy aliases (app-server, codex, copilot)
parse_runtime_transport and runtime_transport_name in crates/jam-manager/src/manager.rs CLI and request parsing, plus aliases (attach, inbox)
transport_tag in crates/jam-manager/src/runtime_host.rs Compatibility fingerprint input
runtime_summary in bins/jam/src/main.rs CLI display

The enum-to-string functions (transport_str, runtime_transport_name, transport_tag, runtime_summary) are exhaustive matches, so adding a variant produces a compile error there. The string-to-enum direction is not: serde decoding falls back to Unknown, the store decodes through the hand-maintained TRANSPORTS table, and parse_runtime_transport has a catch-all error arm. A new variant compiles without entries in any of the three and fails only when a string is decoded. The facts live in five places with slightly different alias sets. AGENTS.md ("One representation, or make disagreement impossible") names this pattern as the root cause of recurring defects on the owned-runtime surface.

#Provider string literals

Production code outside jam-host also branches on provider name strings ("codex", "claudecode", "claude", "copilot", "opencode", "cursor"). The largest counts are in bins/jam/src/main.rs (20), crates/jam-manager/src/manager.rs (20), crates/jam-domain/src/host_session.rs (16), apps/desktop/src-tauri/src/integration_setup.rs (12), and bins/jam/src/jamd.rs (7). Inside jam-host, codex/app_server.rs (17) and copilot/sdk.rs (11) are expected owners of their own names.

Two composition points in bins/jam/src/jamd.rs choose behavior by provider name rather than by a registered capability:

  • build_host is an ordered if chain over is_owned_* predicates, then session.provider == COPILOT_PROVIDER, then peer.host == "claudecode", then a Generic fallback. The order matters. Owned Claude Code must be tested before the attached-Claude fallback, or an owned session silently gets mailbox delivery.
  • new_work_source and work_provider pick a private-task watcher by matching "claudecode" and "codex". Its comment says "a new provider is one more arm here + its host module."

#Desktop copy

The desktop derives capabilities and settings from the catalog, but two functions still restate per-transport copy: providerCardCopy in apps/desktop/src/components/blocks/LocalAgentCreatePanel.tsx (title and description) and transportLabel in apps/desktop/src/components/blocks/RuntimeTemplatePanel.tsx (a label that differs from the catalog's label). Both switch over the generated HostTransport type, so TypeScript rejects a missing case. PROVIDER_BY_TRANSPORT in ProviderIcon.tsx is a Record<RuntimeTransportView, ...> with the same property.

#Checklist: adding a harness today

A developer adding a new owned transport on main must touch at least the following. Compile errors from exhaustive matches catch most of them. The ones marked silent compile without the change and fail only at runtime or in review.

  1. crates/jam-domain/src/host_session.rs: add the HostTransport variant, its serde name in Deserialize, a transport_catalog! row, and an is_owned_<name> predicate.
  2. crates/jam-host/src/: write the adapter implementing Host, and translate native events, questions, permissions, usage, and tools to the manager's brokers.
  3. bins/jam/src/jamd.rs: add a branch to build_host in the right position. Silent if omitted or misordered: the session falls through to the attached or Generic adapter.
  4. bins/jam/src/jamd.rs: extend work_provider if the harness has a native task list. Silent if omitted: the lane stays CLI-only.
  5. crates/jam-manager/src/manager.rs: extend parse_runtime_transport, runtime_transport_name, default_owned_runtime_provider, apply_runtime_opts, clear_unsupported_thread_settings_for_transport, validate_owned_runtime_configuration, and the model probes as the harness requires.
  6. crates/jam-manager/src/runtime_host.rs: extend transport_tag.
  7. crates/jam-store/src/sqlite.rs: extend transport_str and TRANSPORTS. Silent for the parse table: a missing entry decodes as Unknown. The same is true of the serde Deserialize match in step 1 and parse_runtime_transport in step 5.
  8. crates/jam-manager/src/analytics.rs and crates/jam-analytics/src/contract.rs: add the analytics mapping and enum value, which requires a privacy review under docs/analytics.md.
  9. bins/jam/src/main.rs and agent_management.rs: add the CLI RuntimeTransport value and its display, then run just cli-help-update.
  10. Desktop: add copy in providerCardCopy and transportLabel, an icon mapping, fixture rows, and run just bindings.
  11. If the harness supports Docker Sandbox: the catalog's sandbox cells, the managed sandbox kit (crates/jam-manager/src/managed_sandbox_kit.rs), credential services, and sandbox_agent_for_session.
  12. Tests: lifecycle tests, the catalog fixture test, and a packaged E2E scenario in the shared catalog (apps/desktop/e2e/real-agents/scenario-catalog.mjs).

#Checklist: adding a cross-cutting feature

Control API methods by area, split by which client calls them. A new daemon-backed feature usually adds rows here, and each row is a full vertical change through jam-contract, jam-wire, jam-daemon, jam-client, jam-ipc, and the Tauri command allowlists.

Control API methods by area and client

A capability that every harness should expose, such as a new runtime tool or a new Control operation, has a different shape:

  • A new runtime tool is defined once in the jam-host tool registry and reaches every owned harness through RuntimeHostContext::tools. Each adapter must still translate it into the provider's tool mechanism, and AGENTS.md requires the tool's guidance to be conditional on the registry offering it.
  • A new daemon operation crosses nine layers: jam-contract::Control, jam-manager, jam-wire (request, response, route), the jam-daemon handler and route, jam-client, jam-ipc, the Tauri command plus collect_commands!, both isolation allowlists, and just bindings. 243 of the 255 Control methods have default bodies (see the crosswalk), which lets each implementor adopt a method incrementally but also means a missing override compiles and fails at runtime with "not supported".
  • A new event kind is added to EventKind in crates/jam-domain/src/event.rs and must be handled in applyEvent in apps/desktop/src/stores/daemon.ts. The events reference shows which kinds the desktop handles.

#Observations for the refactor

Facts about main that bear on a pluggable design. They are not a proposed design; Future directions compares the proposals.

  • The catalog is the de facto manifest, but it is compiled into jam-domain. A plugin boundary would need the catalog row, the adapter constructor, and the work-source constructor to live together. Today they live in jam-domain, jam-host, and bins/jam/src/jamd.rs respectively.
  • build_host dispatch is order-dependent and predicate-based. A registry keyed by (HostRuntime, HostTransport) would remove the ordering hazard, but attached delivery currently also depends on peer.host and session.provider, not only on the transport.
  • Transport names have no single codec. Serde, the store, the manager, the host fingerprint, and the CLI each map names independently, with different alias sets.
  • Per-transport rules in the manager (apply_runtime_opts, thread-setting clearing, configuration validation, the default provider) could move into the catalog, because several of them already mirror catalog fields. For example, the catalog's provider doc comment says it "mirrors default_owned_runtime_provider in the manager."
  • Registries for shared processes are per provider. CodexAppServerRegistry and CopilotSdkRegistry each implement "one live process per Shared runtime host" separately. Claude Code and ACP have no registry and run one process per session.
  • Size concentrates the risk. crates/jam-manager/src/manager.rs is about 58,000 lines, and its production code holds 95 of the HostTransport references outside jam-host. crates/jam-manager/tests/lifecycle.rs is about 68,000 lines. These two files had the most changes of any code files in the 90 days before this snapshot. Codebase metrics breaks manager.rs into its regions. The largest impl Manager block (about 12,800 production lines, 162 methods) is mostly runtime, session, host, and workspace lifecycle: 93 of its method names contain one of those words. The next (about 6,000 lines) mixes builder setup, runtime defaults, room binding generations, and artifact transfer. The Control adapter adds about 7,600 lines, and human and room reads about 8,000.
Scroll to zoom, drag to pan.