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

#Providers and attached agents

On this pageWhere it livesHow it worksTwo delivery modes and two ownership modesThe Host traitComposition: choosing the adapterCapability matrixThe context and its brokersThe runtime event streamOne owned Codex turnSettlement: dispositions and the fallbackQuestions and permissionsRuntime toolsOwned Copilot, Claude Code, and ACP in briefInstructions and promptsExecutable resolutionThe attached Claude Code pathPull receiversThe Copilot CLI extension bridgeState and ownershipContractsProvided by this subsystemConsumed by this subsystemTimeouts enforced in codeInvariantsFailure and recoveryExtension pointsAdding a coding-agent harnessAdding a runtime toolAdding an AgentEventKind variantRefactor notes

This subsystem is the seam between Jam and the coding agents it serves. Every agent, whether Jam launched it or the user started it in a terminal, is reached through one trait, jam_host::Host, and each coding-agent harness has an adapter behind that trait: Codex app-server, the GitHub Copilot SDK, headless Claude Code, the Agent Client Protocol (ACP, which also carries OpenCode and Cursor), the Claude Code teammate mailbox, the Copilot CLI extension bridge, and a pull-only baseline. Owned adapters translate native protocols into a provider-neutral event stream (AgentEvent) and receive manager services through RuntimeHostContext. Attached adapters deliver through a mailbox, bridge, or durable pull queue; their activity and questions arrive separately through hooks or extension APIs. The subsystem deliberately does not own room routing, the durable inbound queue, or Band settlement (Messaging and room state), worker and host-session lifecycle (Execution and lifecycle), or the Docker Sandbox, workspace, and checkpoint mechanics (Sandbox, workspaces, and continuity).

#Where it lives

The adapters live in crates/jam-host. Everything that decides which adapter runs, and everything that consumes adapter output, lives outside it.

Area Location Key types and functions
The seam crates/jam-host/src/lib.rs (1,567 lines) Host, Mode, HostError, IdleReapPolicy, RoomBindPrompt, RuntimeHostContext (28 fields), RuntimeEventIngress, RuntimeEventLease, broker type aliases, RuntimeProbeFailure, resolution_inputs
Delivery supertrait crates/jam-core/src/engine.rs Deliverer::deliver, DeliverResult
Composition bins/jam/src/jamd.rs build_host, new_work_source closure, work_provider, runtime_context_for_app, drain_hook_terminals
Owned Codex crates/jam-host/src/codex/ (27,913 lines in six files) app_server.rs: CodexAppServer, CodexAppServerRegistry, runtime_loop, handle_prompt, run_prompt_turn, EventContext, jam_developer_instructions, SharedProtocolRouter; shared_dispatch.rs: SharedHostRegistry, SharedSessionRouter, SharedTurnCoordinator; task_mcp.rs: JamTasksServerSpec, TaskMcpRegistration, fallback_decision; provider_state.rs; process_control.rs; mod.rs: CodexSource
Owned Copilot crates/jam-host/src/copilot/sdk.rs (9,324 lines), copilot/jam_scope.rs, copilot/provider_state.rs CopilotSdk, CopilotSdkRegistry, JamPermissionHandler, JamUserInputHandler, copilot_runtime_capability_tools, runtime_disposition_tools
Attached Copilot crates/jam-host/src/copilot.rs, crates/jam-manager/src/copilot_bridge.rs, plugins/copilot-jam-extension/extension.mjs, bins/jam/src/copilot.rs CopilotCli, BridgeSink, DeliveryRequest, CopilotBridgeRegistry
Owned Claude Code crates/jam-host/src/claudecode/owned/ (19,861 lines in 13 files) ClaudeCodeOwned (mod.rs), turn loop (runtime.rs), argv (command.rs), stream-json (protocol.rs), DispositionBridge (bridge.rs), guest MCP relay (guest_relay.rs), briefing.rs, model_probe.rs, probe.rs
Attached Claude Code crates/jam-host/src/claudecode/mod.rs, lock.rs, hooks.rs, tasks.rs; plugins/band-peer/hooks/hooks.json ClaudeCode (mailbox), acquire_lock, parse_hook_event, ask_user_question, ClaudeCodeSource
ACP and OpenCode crates/jam-host/src/acp.rs (9,862 lines), acp_process.rs, acp_provider_state.rs, opencode.rs AcpHost, new_session_request, jam_mcp_server, handle_cursor_question; OpenCode's default_spawn, probe_models
Pull baseline crates/jam-host/src/generic.rs; Manager::receive_pull in crates/jam-manager/src/manager.rs; plugins/band-peer/skills/jam/jam-monitor.sh Generic, PullReceiverEntry, PullReceiveGuard
Runtime tools crates/jam-host/src/runtime_tools.rs, runtime_task_tools.rs, runtime_workspace_tools.rs, runtime_collaboration_tools.rs, runtime_tools_http.rs, runtime_task_access.rs RuntimeToolProvider, RuntimeToolRegistry, RuntimeTaskToolProvider, ManagedWorkspaceToolProvider, RuntimeCollaborationToolProvider, RuntimeToolHttpServer, RuntimeTaskAccess
Settlement contract crates/jam-host/src/disposition.rs; bins/jam/src/mcp/disposition.rs JAM_REPLY_TOOL, JAM_NO_REPLY_TOOL, reply_tool_schema, tool_specs, mcp_config_document
Instructions crates/jam-host/src/prompt.rs, owned_policy.rs, room_work_guidance.rs, team_guidance.rs, runtime_reference_guidance.rs; agent-guidance/*.md INCOMING_BAND_MESSAGE_TEMPLATE, owned_policy::render, RoomWorkCapabilities
Executable resolution crates/jam-host/src/runtime_env.rs, crates/jam-setup/src/executable.rs, crates/jam-setup/src/coding_agents.rs, root clippy.toml resolve_runtime_executable, resolution_inputs_for, host_runtime_env, CODING_AGENT_COMMANDS
Manager side of the seam crates/jam-manager/src/worker.rs, manager.rs runtime_context_for_session, QuestionGate, project_runtime_event, commit_turn_disposition, runtime_usage_observation, runtime_tool_registry_factory, runtime_permission_broker, ask_room_question
Transport metadata crates/jam-domain/src/host_session.rs (8,330 lines) HostTransport, the transport_catalog! macro, TransportCatalogEntry, SandboxCapabilityCell, validate_sandbox_auth, is_owned_* predicates
Event vocabulary crates/jam-domain/src/agent_event.rs AgentEvent, AgentEventKind (22 variants), TurnDisposition

jam-host compiles in two shapes. The daemon links it with the runtime-providers feature, which pulls in the Codex and Copilot SDK forks, the ACP crate, and jam-setup. The thin jam CLI links it with only runtime-tools, which keeps disposition, claudecode::hooks, copilot_hooks, codex::task_mcp, runtime_tools, and runtime_collaboration_tools available without any provider SDK (crates/jam-host/Cargo.toml, [features]). That split is load-bearing: the Claude Code hooks, jam mcp disposition, jam mcp tasks, and jam mcp runtime-tasks all run inside the CLI binary.

#How it works

#Two delivery modes and two ownership modes

Every agent falls into one of two ownership modes, and every adapter into one of two delivery modes. The distinction matters because it decides who settles an inbound message.

An owned runtime is a process jamd launches and supervises: a codex app-server, a Copilot CLI behind the Copilot SDK, a claude -p child speaking stream-json, or an ACP agent on stdio. Its host session has HostRuntime::Owned and one of the owned HostTransport variants. The adapter turns each inbound message into a provider turn and reports the result as AgentEvents. The worker settles the Band message from those events. The agent never runs band reply.

An attached agent is a session the user started, such as Claude Code or Copilot CLI in a terminal. Its host session has HostRuntime::AttachInbox (HostTransport::AttachInbox, serialized attach-inbox). Jam owns no process. The agent settles each message itself by running band reply or band ack, which reach Control::reply and Control::ack.

Host::delivery_mode reports Mode::Push when deliver writes a native surface the agent ingests, and Mode::Pull when the agent fetches its own messages. Every owned adapter and two attached adapters push. Only Generic pulls. The durable queue is the pull surface for everyone: Generic::deliver returns Ok(()) because the message is already queued.

Inferred: Mode is descriptive only: the manager/core production caller of delivery_mode() is the QuestionGate passthrough, not a routing decision. Tests also call it.

#The Host trait

Host extends jam_core::Deliverer, so the core inbound pipeline can call deliver on any adapter without knowing the concrete type. The manager uses the rest of the trait. It declares 13 methods: four required (name, delivery_mode, prepare, and teardown) and nine with default bodies. Its Deliverer supertrait adds the required deliver, making 14 methods across the two traits:

Method Default Overridden by Used for
teardown_for(peer, reason) calls teardown Codex, Copilot SDK, ACP Records which ProviderCheckpointReason the final managed checkpoint uses
readiness_warning None ClaudeCode, CopilotCli Degrades peer status when a push surface is prepared but not consumable
runtime_pid None Codex, Copilot SDK, owned Claude Process identifier for liveness, never durable identity
runtime_transport_active None ACP, owned Claude Protocol-task liveness when the SDK hides the child process
idle_reap_policy Allow owned Claude (KeepAlive) Whether elapsed inactivity may stop the child
notify_room_unbound no-op ClaudeCode Writes the "room needs binding" prompt into the mailbox
compact error Codex only Control::compact_runtime_session
interrupt_turn error Codex, Copilot SDK, owned Claude, ACP Control::interrupt_runtime_turn
runtime_models error Codex, Copilot SDK, owned Claude, ACP (OpenCode only) Control::runtime_models

crates/jam-host/src/testkit.rs::run_conformance is the trait's behavioral contract: a non-empty name, idempotent prepare and teardown, repeatable deliver after prepare, and re-preparation after teardown. Only Generic and the mailbox ClaudeCode run it (lib.rs tests generic_passes_conformance and claudecode_passes_conformance). The owned adapters need a live provider or a scripted runner and test the same properties with their own harnesses.

#Composition: choosing the adapter

jamd builds adapters through one closure, NewHost, which the manager calls with a Peer, a HostSession, the app directory, and a RuntimeHostContext. The closure calls bins/jam/src/jamd.rs::build_host. This flowchart answers: given a host session, which adapter does build_host construct?

The order is significant. The owned Claude Code check must precede the peer.host == "claudecode" fall-through, or an owned session on a legacy claudecode peer would silently take the mailbox adapter (comment in build_host, test jamd.rs::owned_claude_code_cli_session_selects_the_owned_adapter_not_the_attach_mailbox). The owned Copilot check must precede the provider-string check, or an owned Copilot session would get the extension bridge (test owned_copilot_sdk_session_selects_copilot_sdk_before_bridge). A legacy owned PTY record fails construction instead of falling through to Generic (test legacy_owned_pty_session_is_rejected_instead_of_using_generic_host).

The two owned branches that go through registries differ from the others. CodexAppServerRegistry and CopilotSdkRegistry are daemon-scoped, created once in jamd.rs, and hold a SharedHostRegistry keyed by RuntimeHostId. When the session's managed runtime has RuntimeHostPlacement::SharedAgentHost, build returns a facade that joins the one live SharedSdkRuntimeHost (Codex) or SharedCopilotRuntimeHost (Copilot) for that host UUID, after ensure_compatible confirms that command, arguments, sandbox, and roots match. Otherwise it returns an ordinary one-process-per-session adapter. Entries are Weak, so a stopped host is neither retained nor resurrected (shared_dispatch.rs, doc comment on SharedHostRegistry).

The manager wraps every constructed adapter in worker::QuestionGate. Its deliver first offers the message to Manager::question_intercept, which tries to resolve a pending permission (approve <id> or deny <id>) and then a pending question. An intercepted message is acknowledged off the delivery path and never reaches the agent. Every other Host method passes through (test worker.rs::question_gate_preserves_the_wrapped_idle_reap_policy).

#Capability matrix

Capabilities depend on the adapter and the catalog rows in crates/jam-domain/src/host_session.rs (transport_catalog! and the *_SANDBOX_CAPABILITY_CELLS constants). "Docker" means a Docker Sandboxes microVM; "Shared" means RuntimeHostPlacement::SharedAgentHost. Shared cells require Jam-managed configuration and a Managed workspace; Docker-environment, Private, and Direct cells are Dedicated. Shared placement does not imply one provider process: Codex and Copilot share an SDK process through their registries, while Claude Code and ACP construct a separate adapter and child per session (build_host).

Harness and boundary Ownership, mode Questions Permissions Live usage events Compaction Interrupt Models Private tasks Workspace tools Shared placement
Codex app-server, host-native Owned, push request_user_input, jam_ask_user Four approval handlers plus binary MCP approval elicitation Emitted, not ingested (file scan instead) Jam-driven and provider Yes, turn/interrupt List jam_tasks MCP when JAM_CODEX_TASK_MCP is unset or auto; empty and other values disable it; TodoList observed as WorkItems No No
Codex app-server, Docker Owned, push Same Same Ingested Jam-driven and provider Yes List Dedicated: Docker task service. Shared: registry dynamicTools Yes when Managed, both placements Yes
Copilot SDK, host-native Owned, push ask_user JamPermissionHandler with jam_scope carve-out Emitted, not ingested Provider only Yes, session.abort List SQL todos observed as WorkItems; no Jam task server No No
Copilot SDK, Docker Owned, push Same Same Ingested Provider only Yes List Dedicated: Docker gateway and task service. Shared: registry as native tools Shared only (see Refactor notes) Yes
Claude Code, owned host-native Owned, push AskUserQuestion MCP compatibility tool and native-tool --owned-ask hook --permission-prompt-tool Emitted, not ingested (file scan instead) Provider only Yes, 10 s acknowledgement List and validate jam_tasks MCP plus Claude's native tasks No No
Claude Code, owned Docker Owned, push AskUserQuestion MCP compatibility tool through the guest relay Same Ingested Provider only Yes List and validate Dedicated: Docker gateway. Shared: registry through the relay Yes when Managed, both placements Yes
ACP generic, host-native Owned, push Cursor's cursor/ask_question only session/request_permission None Provider-dependent; no Jam API Yes, session/cancel No jam mcp tasks in session/new No No
ACP, reviewed Cursor profile in Docker Owned, push cursor/ask_question plus AskUserQuestion tool Same None Provider-dependent; no Jam API Yes No Dedicated: Docker gateway. Shared: registry over HTTP MCP Shared only Yes
OpenCode, host-native Owned, push, ACP adapter None session/request_permission None Provider-dependent; no Jam API Yes List and validate jam mcp tasks in session/new No No
OpenCode, Docker Owned, push, ACP adapter None Same None Provider-dependent; no Jam API Yes List and validate Docker task gateway No No, Dedicated only
Claude Code, attached (mailbox or pull) Attached, push or pull PreToolUse --ask hook Claude's own prompts None; file scan Provider only No No Claude task files watched by ClaudeCodeSource No Not applicable
Copilot CLI, attached Attached, push Extension ask_user bridge Extension permission bridge None Provider only No No Extension todo reports No Not applicable
Generic pull Attached, pull None None None Not applicable No No CLI only No Not applicable

"Emitted, not ingested" means the adapter emits AgentEventKind::Usage but worker::runtime_usage_observation returns None because spawn.sandbox.enabled is false; host-native accounting comes from the file scan described in Usage, analytics, accounts, and discovery. Host-native Copilot file-scan coverage is not established by runtime_usage_observation; that function establishes only that live ingestion is skipped. "Provider only" compaction means the provider compacts inside its own turn and Host::compact returns an error. Inferred: compaction for ACP, including Cursor and OpenCode, depends on the selected executable; AcpHost has neither a compact override nor a negotiated compaction capability, so the adapter alone does not establish provider-side support. Codex declines general MCP form or URL elicitation that cannot be translated into binary approval (handle_unknown_server_request). "List" and "validate" are the catalog's models and model_check flags; for ACP, AcpHost::runtime_models answers only when an OpenCode snapshot exists.

jamd.rs also builds NewWorkSource, which picks a Layer-1 task watcher per session through work_provider: ClaudeCodeSource for claudecode, CodexSource for codex, and nothing otherwise. A session with no provider falls back to the peer's host, and the provider-neutral pull provider maps to Claude's task files (tests falls_back_to_peer_host_when_session_provider_unset, provider_neutral_pull_selects_the_claude_task_source). The watcher runs only when JAM_WORKITEMS resolves to WorkMode::Watch (jam-core/src/worksource.rs).

#The context and its brokers

The manager is the only layer that holds Band credentials, durable ownership, and user decisions. Adapters reach those through closures and handles in RuntimeHostContext, which worker::runtime_context_for_session builds for every owned session (attached sessions get RuntimeHostContext::empty()). The worker then layers on the managed runtime, checkpoint brokers, room context (enrich_runtime_room_context), and the tool registry (attach_runtime_tool_registry) before calling NewHost. The manager-owned services an adapter receives, and what it sends back:

The fields group into five kinds of authority:

  • Brokers the adapter calls. permissions: RuntimePermissionBroker, questions: RuntimeQuestionBroker, messages: RuntimeMessageBroker, environment_approvals: EnvironmentApprovalBroker, checkpoints: RuntimeCheckpointBroker, checkpoint_activation, and provider_resume_observer. The request brokers return boxed futures, but checkpoint_activation takes no argument, and ProviderResumeObserver holds synchronous start/completion callbacks. Authority is not uniformly payload-only: the permission broker captures the peer while RuntimePermissionRequest carries session and room identifiers; checkpoint capture requests also carry a runtime-session identifier. Question and message brokers capture the room. RuntimeMessageBroker takes only a message body, so that send path cannot select another identity or room (lib.rs broker types; manager.rs::runtime_permission_broker; jam-domain/src/permission.rs::RuntimePermissionRequest).
  • Safe defaults when a broker is absent. RuntimeHostContext::request_permission returns deny_once with "no runtime permission broker configured". ask_question returns None, which adapters treat as "proceed with your best judgment". request_environment_approval returns Cancel. send_message returns an error.
  • Exact-session capabilities. tools: Option<Arc<RuntimeToolRegistry>>, task_access, and capability_access are ephemeral authority bound to one runtime session. The model-facing schemas never carry an agent, room, host, or session selector.
  • Startup facts. self_agent, room, room_plan, team, roster (a closure for turn-start membership diffs), operator_instructions, global: RuntimeDefaults (including the executable pin table), artifacts_root, artifact_gate, config_dir, profile, scope, and native_local_platform_route.
  • Event outlets. event_ingress: Option<RuntimeEventIngress> for owned hosts, startup_events for startup phases before the route is committed, and events, a legacy sink kept for tests.

#The runtime event stream

Owned adapters report runtime events through AgentEvent (jam-domain/src/agent_event.rs). Each event carries the local session_id, chat_id, provider agent_session_id, the source_message_id the turn is answering, a turn_id, a per-turn sequence, explicit_disposition_capable, fidelity, redacted, truncated, and one of 22 AgentEventKind variants:

Group Variants
Turn boundary TurnStarted, TurnComplete { outcome, message }, ProcessExit, RuntimeStartup { phase }
Content AssistantText { text, is_delta }, Thought, ThoughtStream, ToolCall, ToolResult
Settlement TurnDispositionStaged { source_message_id, disposition }
Work WorkItems { provider, items }
Accounting Usage { … }, RateLimits, RateLimitsSnapshot, RateLimitsUnavailable, ContextGauge
Control PermissionRequested, PermissionResolved, RuntimeStatus, RuntimeIdentity, DeliveryEcho, Compaction

Events cross from the adapter to the worker through RuntimeEventIngress, which wraps the worker's mpsc::channel::<RuntimeEvent>(64) (created in worker::spawn_worker) and stamps each event with a RuntimeEventLease. The ingress exists because an adapter can emit events during construction and prepare, before the worker has committed the host into its routing table. This state diagram answers: when does an event from a host instance reach the worker?

In Pending and Activating, send buffers events; activate replays the buffer in order before any concurrent event can overtake it. In Suspended and Retired, send rejects the event. The worker's supervise loop also discards any event whose lease no longer matches the live host's ingress (runtime_event_is_active), so a retired process cannot write into a successor's turn (test manager.rs::only_the_current_runtime_lease_can_project_a_turn_boundary).

worker::project_runtime_event is the single consumer. For every event it:

  1. Bumps per-session activity for the idle reaper and tracks turn liveness from TurnStarted and TurnComplete.
  2. Calls project_runtime_activity, which maps the kind to an ActivityKind, publishes EventKind::Activity, and appends to the activity log. This is how owned runtimes light up the same UI as hook-driven attached agents.
  3. Projects the working lease (RuntimeWorkingPublishers), rate-limit and context gauges (project_runtime_gauges), and usage (project_runtime_usage).
  4. Handles content and settlement: buffers AssistantText deltas and final text per turn key, records TurnDispositionStaged, and on TurnComplete commits or discards what it buffered.
  5. Mirrors room-visible kinds (thoughts, tool calls, task markers) to Band as chat events through runtime_chat_event, subject to the peer's room-activity level.

The worker also derives two durable side effects from any event: runtime_binding_update records a changed provider session id on the HostSession (persisted through save_peer_with_connectivity), and runtime_identity_update stores the name an ACP agent reported for itself.

#One owned Codex turn

One queued message reaching an owned Codex runtime, with its durable commit points and the places usage and activity are emitted. The inbound half up to the durable queue row is described in Messaging and room state.

The details the diagram compresses:

  • Delivery is a channel send. CodexAppServer::deliver sends RuntimeCommand::Prompt on its internal channel and returns. The room lane is therefore released as soon as the command is queued.
  • The turn loop is one task. runtime_loop selects between provider notices and commands. handle_prompt renders the prompt (render_prompt), runs run_prompt_turn, and implements the retry ladders: an exact-resume failure policy, a one-time fresh-thread fallback when resume fails (TurnSummary::StreamError), provider-overload retries with delay, and for context exhaustion a bounded sequence of compact-and-retry, then fresh thread, then an explicit apology posted as AssistantText with TurnComplete(Complete).
  • Steering. A Prompt that arrives mid-turn is steered into the running turn with the app-server turn/steer RPC and added to the active-message set under the same disposition_stage lock. If steering fails, the message goes to state.carryover and runs as its own turn before any new command is taken. Retries re-carry steered_so_far so a steered message stays settleable (tests app_server.rs::a_mid_turn_message_is_steered_and_survives_compaction_retry, a_message_arriving_during_compaction_steers_the_next_turn).
  • Usage. usage_event builds AgentEventKind::Usage from the turn/completed usage block, subtracting cached input from input and leaving reasoning, event id, and timestamp as None. After a clean turn, factory.refresh_rate_limits emits RateLimits. RuntimeNotice::TokenUsage feeds ContextGauge, not usage.
  • Where usage is actually persisted. worker::runtime_usage_observation returns None unless the session's spawn.sandbox.enabled is true, then validates durable peer, host-session, runtime-host, and room-binding ownership before building a UsageObservation. Host-native usage therefore does not come from this event; it comes from the ccusage file scan described in Usage, analytics, accounts, and discovery.
  • Activity. Every event that runtime_activity_kind maps (turn start and end, tool calls, thoughts, status, compaction) is published and appended by the worker, not by the adapter.

#Settlement: dispositions and the fallback

Owned Codex, Copilot SDK, and owned Claude Code settle each inbound message with one of two tools whose names, descriptions, and schemas come from crates/jam-host/src/disposition.rs: jam_reply_to_message { message_id, text } stages a reply, and jam_no_reply { message_id, reason } stages an acknowledgement. The three adapters register them differently. Codex adds them to the app-server dynamicTools in build_thread_options through runtime_disposition_tool_specs. Copilot registers SDK Tools with handlers (runtime_disposition_tools). Claude Code reaches them over MCP: host-native spawns write a mode-0600 --mcp-config naming a jam mcp disposition server; Docker spawns instead name the Node guest relay. Both authenticate to the per-spawn DispositionBridge with a 256-bit token before answering MCP initialize (claudecode/owned/runtime.rs::write_runtime_mcp_config). The startup gate refuses a runtime whose system/init.tools lacks either mcp__jam__… name, lists one twice, or has no authenticated handshake (claudecode/owned/bridge.rs module docs).

Each adapter's EventContext enforces the same staging rules (app_server.rs::EventContext::stage_disposition, copilot/sdk.rs::EventContext::stage_disposition, claudecode/owned/runtime.rs impl DispositionSink for EventContext):

  • The message_id must name a message in the turn's active set. An unknown id is refused rather than guessed.
  • An identical retry is idempotent; a conflicting second disposition is refused.
  • At most TurnDisposition::MAX_PER_TURN dispositions per turn.
  • Staging, event emission, and rollback on emit failure happen under one disposition_stage mutex. close_disposition_stage takes the same mutex and sets disposition_open = false before the adapter emits TurnComplete. Every accepted TurnDispositionStaged is therefore sequenced before the terminal event, and any later tool call is refused.

The worker commits only on TurnComplete { outcome: Complete }. For each staged disposition in order, commit_turn_disposition posts a Reply through post_assistant_reply (which strips trailing Codex memory-citation blocks, chunks at chunk::BAND_CONTENT_CAP, and calls Engine::reply_parts) or acknowledges a NoReply through Engine::ack. A local Jam notice is only acknowledged. A Failed or Cancelled outcome discards both the staged dispositions and the text buffer, so the source stays queued (tests lifecycle.rs::staged_disposition_on_failed_or_cancelled_turn_does_not_consume_agent_inbound, invalid_staged_disposition_cannot_post_or_consume_agent_inbound).

When a turn completes without any disposition, the worker may post the buffered final assistant text as a compatibility fallback. It does so when the source was a human, when the event is not explicit_disposition_capable, or when JAM_OWNED_EXPLICIT_DISPOSITION is 0 or off. For an agent-originated message from a disposition-capable runtime, it posts nothing and projects the text as a thought, so two agents cannot loop on each other's final text (tests agent_origin_runtime_complete_without_disposition_does_not_reply_or_ack, human_origin_runtime_complete_keeps_legacy_final_text_fallback, owned_transport_without_disposition_tools_keeps_legacy_agent_reply_fallback).

ACP has no disposition tools; its events carry explicit_disposition_capable: false. When an ACP turn ends successfully without any assistant message, AcpHost's EventContext::emit stages a synthetic NoReply before TurnComplete, so a silent turn acknowledges its source instead of leaving it queued (test lifecycle.rs::acp_silent_disposition_settles_only_its_source_without_sending_a_reply). A turn with text replies through the fallback.

#Questions and permissions

Both brokers are provider-neutral and live in the manager. Adapters translate only the native request and response.

Questions. Manager::ask_room_question requires one human recipient: the explicit recipient_id, or the sole human in the room. In a multi-human room with no recipient, it returns None rather than broadcasting. It allocates a manager-owned group id, revision, and short answer code, posts the question as the agent's room message, attaches Human-in-the-loop cards to the Work board, and waits. The wait deadline resolves in this order: the request's timeout_secs, the per-agent setting, JAM_QUESTION_TIMEOUT_SECS, then RuntimeDefaults::question_timeout_secs; zero or unset waits indefinitely. The first correlated reply from that human, intercepted by QuestionGate, resolves the oneshot and is acknowledged instead of delivered as a turn. The native translations are:

Adapter Native ask Translated in
Codex request_user_input server request, and the jam_ask_user dynamic tool bridge_user_input_request, handle_dynamic_question
Copilot SDK ask_user via the SDK UserInputHandler JamUserInputHandler
Owned Claude Code AskUserQuestion MCP compatibility tool; host-native configuration also installs a PreToolUse --owned-ask hook for a native AskUserQuestion call bins/jam/src/mcp/disposition.rs, claudecode/owned/bridge.rs::DispositionBridge, claudecode/owned/runtime.rs::write_mcp_config_for_runtime
ACP Cursor's blocking cursor/ask_question extension handle_cursor_question
Attached Claude Code AskUserQuestion via the PreToolUse hook with --ask bins/jam/src/main.rs::ask_bridge_hook calling Control::ask_agent_question
Attached Copilot ask_user in the extension Control::copilot_bridge_question

Permissions. Manager::request_runtime_permission first consults durable always-rules stored in the settings table under keys prefixed runtime.permission.always|<peer>| (permission_rule_prefix). With no rule, it records a pending RuntimePermissionRecord in Inner.runtime_permissions, publishes runtime_permission_requested, and waits for Control::decide_runtime_permission or an approve <id> or deny <id> room reply from an eligible decider. The deadline is JAM_RUNTIME_PERMISSION_TIMEOUT_SECS, then RuntimeDefaults::permission_timeout_secs, then DEFAULT_RUNTIME_PERMISSION_TIMEOUT_SECS, which is 0 (no timer). The native translations are Codex's apply_patch, exec_command, command_execution, and file_change approval handlers plus MCP elicitation (install_server_request_handlers, handle_unknown_server_request); Copilot's JamPermissionHandler, which first applies the approval policy and the jam_scope carve-out that auto-approves single, non-destructive jam shell commands; Claude's --permission-prompt-tool mcp__jam__jam_request_permission; ACP's session/request_permission, answered by the stored AcpApprovalPolicy or the broker; and the attached Copilot extension through Control::copilot_bridge_permission. Attached Claude Code keeps its own permission prompts; Jam does not intercept them.

Each adapter writes its own human-readable description (app_server.rs::permission_description, copilot/sdk.rs::permission_description, and the Claude and ACP equivalents), bounded and passed through summary::redacted_summary. Details of the decision authority are in Tasks, questions, plans, and artifacts.

#Runtime tools

RuntimeToolRegistry (runtime_tools.rs) is an immutable list of RuntimeToolProviders for one runtime session. Construction rejects empty names, duplicate names, and non-object schemas, so a later provider cannot shadow an earlier one (test runtime_tools.rs::duplicate_names_are_rejected_instead_of_shadowed). call dispatches to the first provider that claims the name and returns a RuntimeToolError with one of eight fixed RuntimeToolErrorKind classes. The three built-in providers keep 128-entry replay caches keyed by call id plus the complete request. A reused call id with different arguments fails closed while its entry remains cached; the registry trait itself imposes no replay cache (tests runtime_workspace_tools.rs::reused_call_id_with_different_arguments_fails_closed, runtime_task_tools.rs::mutations_replay_by_call_id_and_reject_changed_arguments).

Manager::runtime_tool_registry_factory decides which providers a session receives:

Provider Tools Given when
ManagedWorkspaceToolProvider WorkspaceRepositories, WorkspaceGitHubReadiness, WorkspaceClone, WorkspaceBranch, WorkspaceCommit, WorkspacePush, WorkspacePullRequest, WorkspaceChecks, WorkspaceOperation The managed runtime has a Jam-owned primary workspace
RuntimeTaskToolProvider TaskCreate, TaskUpdate, TaskGet, TaskList, and the SharedTask* board tools Shared placement and capability access
RuntimeCollaborationToolProvider RoomParticipants, RoomSend, ReachablePeerSearch, RoomParticipantAdd, RoomParticipantRemove, RoomList, and AskUserQuestion for ACP and Cursor Shared placement and capability access

Each provider holds a least-authority port (ManagedWorkspaceToolControl, RuntimeTaskToolControl, RuntimeCollaborationToolControl) that is blanket-implemented for any Control, and closes over the operator Target and the immutable runtime-session UUID. Dedicated Docker sessions do not receive task or collaboration providers in the registry. They reach the same operations through the Docker MCP task service (sandbox/task_service.rs), which runs jam mcp runtime-tasks (bins/jam/src/mcp/runtime_tasks.rs) with task and collaboration tools but no workspace tools.

Each adapter projects the registry into its provider differently, and the projection rules are not uniform:

  • Codex appends every registry spec to dynamicTools whenever tools is present, and routes calls through handle_runtime_tool_call. On a Shared host the SDK has one process-wide dynamic-tool handler, so SharedProtocolRouter routes each call by exact provider thread id (test shared_runtime_tools_route_by_exact_provider_thread_and_never_cross_rooms). Host-native Codex instead gets the jam mcp tasks server through one-run --config mcp_servers.jam_tasks.* overrides (task_mcp::TaskMcpRegistration), controlled by JAM_CODEX_TASK_MCP.
  • Copilot projects registry specs as native SDK tools only on a Shared host (copilot_runtime_capability_tools returns nothing otherwise). Sandboxed Copilot also attaches Docker's MCP gateway (apply_docker_mcp_gateway).
  • Owned Claude Code host-native uses jam mcp disposition plus the jam_tasks server (write_mcp_config_for_runtime). Sandboxed Claude uses a dependency-free Node relay staged under the protected runtime-control directory, outside the primary workspace (guest_relay.rs; runtime.rs::runtime_tempfile_root), which advertises the disposition tools plus every registry spec, and adds Docker's gateway when task access exists. command.rs::jam_allowed_tools pre-approves the Jam catalogue by exact name.
  • ACP host-native passes jam mcp tasks as ACP session/new MCP-server data (jam_mcp_server). Dedicated Docker ACP attaches Docker's task gateway. Only a Shared reviewed Docker profile exposes the registry, through a loopback RuntimeToolHttpServer announced as HTTP MCP.

#Owned Copilot, Claude Code, and ACP in brief

The three other owned adapters share Codex's shape: a deliver that sends a command on a channel, one turn loop per session, an EventContext that stamps and emits AgentEvents, and the same broker calls. Their provider mechanics differ.

Copilot SDK (copilot/sdk.rs) drives the installed copilot CLI through the rev-pinned github-copilot-sdk crate. Steering re-sends the message with DeliveryMode::Immediate. Interrupt calls session.abort. Usage comes from assistant.usage events, mapped literally: input, cache read, cache write, output, and reasoning, with the provider call id as provider_turn_id and authoritative_cost always None, because Copilot's multiplier and nano-AIU values are not documented as USD. Todos come from the SDK's SQL todo table (project_copilot_sql_todos) as WorkItems. There is no compact override; Copilot compacts inside its own turn. Owned Copilot turns have no whole-turn deadline.

Owned Claude Code (claudecode/owned/) spawns claude -p with --input-format stream-json and --output-format stream-json, keeps stdin open for the child's life (closing it is the shutdown signal), and injects mid-turn messages as additional type:"user" lines. Jam owns the argv: command.rs always emits --permission-mode, --permission-prompt-tool, --mcp-config, and --allowedTools, and filters operator arguments through an allowlist. Usage is read only from each turn's usage block, with cache creation promoted to the canonical extension category; total_cost_usd and modelUsage are ignored because they are cumulative. Account windows come from the get_usage control response and surface as RateLimits or RateLimitsUnavailable. It reports IdleReapPolicy::KeepAlive. A --resume whose transcript Claude has pruned can fall back to a fresh --session-id only without an exact-resume contract; managed exact resume fails closed (runtime.rs::start_child_probed, exact_resume_contract).

ACP (acp.rs) is a generic Agent Client Protocol client over the child's stdio: initialize at protocol version 1, session/new or session/load, prompts with streaming SessionUpdates, session/request_permission, session/cancel for interrupt, and session/close. The agent names itself in the handshake and the adapter emits RuntimeIdentity, which the manager normalizes into the session's provider. OpenCode is the same adapter with is_opencode configuration: opencode.rs supplies the default spawn (opencode acp, falling back to opencode2 only when the record names no command), the opencode models probe, and effort vocabulary; the ACP host carries OpenCode's model snapshot and briefing prefix. ACP UsageUpdate becomes a RuntimeStatus line ("context used/size tokens"), not a Usage event. Messages that arrive mid-turn start the next turn rather than steering (test acp.rs::an_inbound_reply_queued_during_a_turn_starts_the_next_turn_on_the_same_session).

#Instructions and prompts

Owned runtimes receive two kinds of text: session-level developer instructions, once per thread, and a per-message prompt envelope.

Developer instructions are assembled per adapter from shared pieces:

  1. owned_policy::render fills agent-guidance/owned-runtime-policy-core.md, embedded with include_str!, with three provider-specific slots ({delegation_delivery}, {web_fallback_wait}, {runtime_notes}). It panics on an unresolved slot. Codex, Copilot, and owned Claude call it.
  2. room_work_guidance::render embeds agent-guidance/room-work-core.md and rewrites host-CLI passages when RoomWorkCapabilities::host_jam_cli is false (sandboxed runtimes cannot run the host jam). render_current_plan adds the attached room plan.
  3. runtime_reference_guidance::render lists read-only reference folders for a sandbox.
  4. An ## Operator briefing section with the peer's briefing, when worker::owned_runtime_injects_operator_briefing is true (Codex, Copilot SDK, owned Claude Code, and OpenCode).
  5. team_guidance::render for the team the agent joined with.
  6. Adapter-specific rules: Codex's private-task mechanics, Jam API and identity rules, and the jam-managed-workspace-v1 contract when tools is present (jam_developer_instructions); the Copilot equivalent; Claude's briefing.rs, written to the file behind --append-system-prompt-file.

ACP has no instruction channel, so OpenCode's briefing and team section ride as a prefix on the first prompt of each connection and again only when the text changes (briefing_prefix), and room-work guidance is appended to the first envelope.

The per-message envelope is built by prompt.rs. Push adapters for attached agents use INCOMING_BAND_MESSAGE_TEMPLATE, which tells the agent to reply with jam --profile … --session … reply <id> or ack <id>. Codex, Copilot SDK, and owned Claude use explicit disposition envelopes (render_explicit_runtime_reply_prompt, plus render_explicit_steered_prompt for steering). ACP instead calls render_runtime_reply_prompt, which says the final assistant response will be posted by Jam; it does not advertise disposition tools. A Jam-local notice gets a [JAM NOTICE] envelope that says not to reply in the room.

agent-guidance/ holds four files. Two are embedded in the daemon (owned-runtime-policy-core.md, room-work-core.md). Three are synchronized into public skills by scripts/sync-room-work-guidance.sh (room-work-core.md, jam-onboarding-core.md, jam-connect-core.md), and just check-room-work-guidance fails when the copies drift. The owned policy file states in its header that it must not be synchronized into public skills.

#Executable resolution

Owned adapters never search PATH themselves. They call runtime_env::resolve_runtime_executable (or resolve_runtime_executable_with_probes), which wraps jam_setup::executable. Resolution order is: an explicit path in the record (never ranked or replaced, only verified), the environment default, the machine-wide pin table in RuntimeDefaults::provider_executables, then every copy on the terminal PATH asked for its version, highest wins. resolution_inputs fills the terminal PATH (a GUI-launched daemon's own PATH lacks version-manager shims) and empties the pin table for sandboxed runtimes, because host paths do not exist inside the guest. host_runtime_env gives the child the same PATH it was resolved against. jam_setup::coding_agents::CODING_AGENT_COMMANDS declares companion programs, such as Codex's codex-code-mode-host; a symlink that hides a companion is spawned through its target.

A failed resolution is typed. jam_setup::ResolutionRefusal travels in HostError::Resolution and RuntimeProbeFailure::resolution_refusal, so the manager can offer "choose the executable" without parsing prose. Root clippy.toml lists every public jam_setup::executable resolver in disallowed-methods. The only allowed call sites are one #[allow] inside runtime_env.rs, one in bins/jam/src/main.rs (the CLI's integration_resolution), and the module-level allow in jam-setup/src/executable.rs itself.

#The attached Claude Code path

The mailbox adapter is legacy: new onboarding in the Band peer skill uses pull receivers (band onboard --receiver pull), and manager.rs::legacy_parked_session_readiness_warning and docs/advanced-reference.md both call it the legacy Claude mailbox. build_host still selects it for any non-owned, non-Copilot session on a peer whose host is "claudecode". The Claude Code hooks apply to every Claude Code window with the Band peer plugin, whether it receives through the mailbox or a pull lease.

How a message reaches an attached Claude Code window through the mailbox, and how hooks report what it does:

The mailbox is ~/.claude/teams/<team>/inboxes/<teammate>.json (teammate defaults to team-lead). Writes take a proper-lockfile-compatible mkdir <inbox>.lock with 12 retries of exponential backoff between 7 and 120 ms, and steal a lock older than 15 seconds (claudecode/lock.rs). Inferred: Claude Code polls and ingests the teammate file without Jam's lock; the module docs in claudecode/mod.rs describe this mailbox contract, but the external Claude Code poller is not implemented in this repository. Jam's atomic writer is directly verifiable in atomic_write_0600. readiness_warning reports when the team has no config.json, which means no Claude session leads it, and names the teams that are led. notify_room_unbound writes a one-time "room needs binding" prompt keyed by the synthetic id jam-bind-prompt:<room_id>.

The hooks are declared in plugins/band-peer/hooks/hooks.json: PreToolUse (all tools, plus a second AskUserQuestion entry with --ask and a 660-second timeout), PostToolUse, PostToolUseFailure, UserPromptSubmit, Stop, StopFailure, Notification, SessionStart, SessionEnd, and PreCompact. Each runs band hook claudecode --event <name>. bins/jam/src/main.rs::run_hook reads at most 1 MiB of stdin, resolves the native ancestor process only for SessionStart, and always exits successfully so a hook cannot break the coding session. translate_hook_report dispatches to jam_host::claudecode::hooks::parse_hook_event (or copilot_hooks::parse_hook_event), which are pure functions pinned by captured fixtures in crates/jam-host/tests/fixtures/claudecode-hooks/. On Stop, the hook reads the transcript tail (2 MiB) and reports each assistant text block as a Thought before TurnEnded. Terminal TurnEnded receipts are written to jam_store::hook_terminal_outbox before the IPC call, and jamd.rs::drain_hook_terminals replays unacknowledged receipts every second. JAM_ACTIVITY_DISABLED=1 and JAM_QUESTION_BRIDGE_DISABLED=1 are kill switches.

The ask hook holds Control::ask_agent_question open. If the room answers, it prints PreToolUse control JSON (permissionDecision: "allow" with updatedInput carrying the answer). If there is no answer, it prints nothing, and Claude Code shows its native picker. ask_bridge_stdout makes "print nothing" a testable value.

#Pull receivers

A pull receiver is an attached session whose peer has host = "generic", provider pull (jam_contract::PULL_PROVIDER; copilot-pull is a legacy tag), and agent_session_id equal to a durable receive lease id. build_host gives it Generic, so delivery is only the queue row. The agent's standby monitor (jam-monitor.sh, or jam-monitor.ps1) runs band receive --lease <id> --supervised --window-pid $PPID in a loop, which calls the held-open Control::receive_pull. How one message reaches a pull receiver, and what stops a second waiter or a lost message:

begin_pull_receive refuses a second concurrent wait on the same lease with Conflict. After a delivery the entry stays in AckRequired(message) until reply or ack clears it, and a re-armed receive that finds the same message still queued returns it again with ack_required_message_id set. Dropping PullReceiveGuard without a delivery logs "pull receive released" and frees the lease. A receive against an explicitly stopped peer is refused; against a peer with no worker, receive_pull starts one first.

#The Copilot CLI extension bridge

Attached Copilot sessions use a manager-owned job registry instead of a file. CopilotCli::deliver calls BridgeSink::enqueue on CopilotBridgeRegistry with a DeliveryRequest whose delivery_id is copilot:<session>:<message_id>. The extension, plugins/copilot-jam-extension/extension.mjs (version 0.1.48, embedded into the jam binary with include_str! and written to ~/.copilot/extensions/jam/extension.mjs), spawns band copilot bridge --stdio and talks JSON lines through it to nine copilot_bridge_* Control methods: register, poll, injected, failed, todos, permission, question, activity, and status.

The delivery contract is at least once, deduplicated by delivery_id (module docs in copilot_bridge.rs). register mints a new generation and bridge_token and re-pends every job that is still open. poll waits up to 55 seconds in 10-second slices so the 45-second extension TTL cannot expire mid-poll, and returns at most 16 jobs within a 512 KiB budget. A single job larger than that budget is marked Failed with a bounded error instead of wedging the session. The extension keeps a seen set of up to 1,000 delivery ids, injects with session.send({ prompt, mode: 'enqueue' }), and reports injected. Manager::reply and Manager::ack call settle_message, which removes the job; the durable queue remains the source of truth.

#State and ownership

Aggregate Key and scope Authority Mutation coordinated by Durable representation Projections Freshness fence Recovery Deletion authority
Live adapter instance SessionId within one worker Worker spawn_worker, live bind, teardown Memory (LiveHost in the worker's hosts map) Session status, runtime_pid, runtime_transport_active RuntimeEventLease per instance Rebuilt from HostSession on respawn Worker teardown
Runtime event admission One host instance RuntimeEventIngress Worker activate, suspend, resume_with, retire Memory None Lease comparison in runtime_event_is_active New lease on rebuild retire
Shared provider process RuntimeHostId SharedHostRegistry in the Codex or Copilot registry get_or_try_init, ensure_compatible Memory (Weak map); host membership is durable elsewhere Several room facades Compatibility check on every build Re-created when last facade drops Last facade drop
Turn state and staged dispositions Provider thread plus Jam turn id Adapter EventContext disposition_stage mutex Memory TurnDispositionStaged events disposition_open closed before TurnComplete Discarded on failure; source stays queued Turn end
Worker settlement buffers (lease, session, turn) key Worker project_runtime_event Memory, at most 256 open turns and 64 KiB per text buffer Reply or ack Lease-keyed; retired turns purged Lost on crash; source redelivered TurnComplete
Provider session id HostSession.agent_session_id Adapter reports, manager persists record_runtime_session_binding Store peer record Resume on next spawn, usage attribution Manager validates against the session Read on respawn Session removal
Pending permission Peer, request id Manager Inner.runtime_permissions under the manager lock Memory; always-rules in the settings table (`runtime.permission.always …`) runtime_permission_requested and _resolved events, room banner Exact request id in decisions Pending asks are lost on restart; rules persist
Pending question Peer, room, group id and revision Manager Inner.pending_questions Memory; board projection agent_questions_asked and _resolved, Work-board cards Group id plus revision; manager-owned answer code Lost on restart; the provider's ask ends Answer, timeout, or cancel
Claude mailbox Team and teammate path under ~/.claude Claude Code reads; Jam appends mkdir lock plus atomic rename JSON array file, mode 0600 Claude Code's teammate UI Dedup by band.message_id Re-delivery is a no-op teardown removes the file
Copilot bridge jobs Provider and Copilot session id CopilotBridgeRegistry Registry mutex Memory; bounded to 256 jobs per session, pruned after 24 hours without an extension copilot_bridge_status Generation and bridge_token Re-registration re-pends open jobs settle_message, settle_session
Pull receiver entry Lease id Manager pull_receivers mutex Memory; the lease id is durable in HostSession.agent_session_id Peer liveness Generation per wait; beat token Guard drop frees the lease Delivery, cancel, disconnect
Hook terminal receipts Provider session plus time CLI writes, daemon acknowledges File create and remove <app>/hook-terminal-outbox/*.json, at most 4,096 Activity TurnEnded Content hash filename drain_hook_terminals every second Acknowledgement
Disposition bridge credentials One Claude spawn DispositionBridge Created per spawn Memory plus a mode-0600 MCP config file Handshake flag Token compared in constant time (secret_eq) New token each spawn Child exit
Runtime tool registry Runtime session Manager factory Built once per host construction Memory; 128-entry replay caches Provider tool lists Call id plus full request Rebuilt per spawn Host drop
Transport catalog HostTransport variant transport_catalog! in jam-domain Compile time Code Control catalog read, desktop pickers Exhaustive generated match Not applicable Not applicable

#Contracts

#Provided by this subsystem

  • jam_core::Deliverer and jam_host::Host. deliver must return quickly: the room lane and an engine inbound slot are held until it does. Owned adapters only enqueue a channel command. prepare and teardown must be idempotent (testkit::run_conformance). Adapters return errors instead of panicking or writing stderr (module docs in lib.rs).
  • AgentEvent stream through RuntimeEventIngress. Sequence numbers are monotonic per adapter; TurnDispositionStaged precedes TurnComplete for the same turn; startup-buffered events are replayed in order before live ones. Usage fields are None when the provider omitted the category, never fabricated as zero (field docs in agent_event.rs).
  • Probe entry points used by Control::test_runtime and the model probes: codex::app_server::probe and probe_models, copilot::sdk::probe and probe_models, acp::probe, claudecode::owned::probe and model_probe::probe_models, opencode::probe_models. Failures are RuntimeProbeFailure with a RuntimeProbeLayer of RuntimeCapability, Service, or ModelCredential, plus typed timed_out. The manager's HostRuntimeProviderProbe bounds Codex and Copilot probes with runtime_provider_probe_timeout_ms.
  • Hook translators used by the CLI: claudecode::hooks::parse_hook_event, turn_thoughts, ask_user_question, answered_hook_output; copilot_hooks::parse_hook_event. All are pure functions.
  • Settlement tool contract in disposition.rs, shared by Codex, Copilot, owned Claude, and the jam mcp disposition server.

#Consumed by this subsystem

  • Brokers in RuntimeHostContext, described in The context and its brokers.
  • Control methods reached by attached agents and CLI-side tools: reply, ack, receive_pull (held open), ask_agent_question (held open), report_activity, the nine copilot_bridge_* methods (poll, permission, and question are held open), runtime_task_call, and runtime_collaboration_call. Control surfaces that act on hosts: compact_runtime_session, interrupt_runtime_turn, runtime_models, probe_runtime_models, probe_runtime_template_models, test_runtime, and decide_runtime_permission. See Control API crosswalk for routes and clients, and Daemon event kinds for activity, runtime_gauges, runtime_permission_*, and agent_question*.
  • Provider protocols. Codex app-server JSON-RPC through the rev-pinned codex-app-server-sdk fork (thread/start, thread/resume, turn/start, turn/steer, turn/interrupt, dynamicTools, approval and request_user_input server requests; MIN_CODEX_DYNAMIC_TOOLS_VERSION is 0.146.0 and older versions are refused). The Copilot CLI through the rev-pinned github-copilot-sdk fork. Claude Code claude -p stream-json on stdin and stdout. ACP version 1 through agent-client-protocol, plus Cursor's cursor/ask_question. The Claude teammate mailbox JSON file. Claude Code and VS Code hook payloads on stdin. The Copilot extension's JSON-line protocol through band copilot bridge --stdio.

#Timeouts enforced in code

Boundary Value Where
Codex prepare 30 s host-native, 630 s sandboxed app_server.rs PREPARE_TIMEOUT, SANDBOX_PREPARE_TIMEOUT
Codex child shutdown and runtime finalization 3 s and 30 s TEARDOWN_TIMEOUT, RUNTIME_FINALIZATION_TIMEOUT
Copilot SDK prepare and teardown 45 s and 10 s copilot/sdk.rs
Owned Claude prepare and teardown 30 s or 630 s, and 8 s host-native claudecode/owned/mod.rs
Owned Claude interrupt acknowledgement 10 s ClaudeCodeOwned::interrupt_turn
ACP prepare and teardown 30 s or 630 s, and 8 s acp.rs
Hook IPC client 150 ms run_hook path in main.rs
Ask hook 660 s declared in hooks.json; the daemon wait follows the question deadline plugins/band-peer/hooks/hooks.json
Copilot bridge poll 55 s maximum, 10 s slices, 45 s extension TTL copilot_bridge.rs
Permission and question waits Indefinite by default; configurable effective_permission_timeout_secs, ask_room_question
Runtime task and collaboration tool calls 60 s and 15 s defaults DEFAULT_TASK_TOOL_TIMEOUT, DEFAULT_COLLABORATION_TOOL_TIMEOUT, DEFAULT_WORKSPACE_TOOL_TIMEOUT

#Invariants

Rule Enforced by Known exceptions
Only a successful terminal turn consumes an inbound message. project_runtime_event commits on TurnComplete { Complete } only; tests staged_disposition_on_failed_or_cancelled_turn_does_not_consume_agent_inbound, runtime_final_reply_failure_keeps_source_queued_for_retry The context-exhaustion ladder ends with an apology posted as Complete so the requester is never left silent.
A disposition names an active inbound message and is sequenced before its turn's terminal event. stage_disposition and close_disposition_stage under disposition_stage in each adapter's EventContext ACP stages a synthetic NoReply for silent turns.
An agent-originated message is not answered with final-text fallback by a disposition-capable runtime. explicit_disposition_required in worker.rs; test agent_origin_runtime_complete_without_disposition_does_not_reply_or_ack JAM_OWNED_EXPLICIT_DISPOSITION=off restores the fallback.
A retired or replaced host cannot project events. RuntimeEventLease, RuntimeEventIngress states, runtime_event_is_active None.
Message sends and model-facing runtime tools use manager-bound identity and scope. runtime_message_broker captures peer/room; runtime tool schemas omit selectors and providers capture RuntimeTaskScope or exact target/session. Permission and checkpoint request types carry session identifiers; they are not payload-only broker contracts.
Missing brokers fail safe. RuntimeHostContext::request_permission denies; ask_question returns None; request_environment_approval cancels None.
Disposition tool schemas are identical across runtimes. Every adapter calls disposition::reply_tool_schema and siblings instead of copying literals (module docs) Tool descriptions differ: Copilot's runtime_disposition_tools writes its own description strings.
Shared-host provider traffic routes by exact provider thread, never broadcast. SharedSessionRouter, SharedProtocolRouter, SharedNoticeRouter (64 threads by 8 notices pre-bind buffer); tests shared_permission_requests_route_by_exact_provider_thread_and_unknown_denies, shared_runtime_tools_route_by_exact_provider_thread_and_never_cross_rooms Not applicable to Dedicated hosts.
A runtime tool name has exactly one provider; built-in providers reject changed requests for cached call ids. RuntimeToolRegistry::new; per-provider replayed caches Replay protection is limited to each provider's 128 retained entries and is not required by RuntimeToolProvider.
Executables resolve through one entry per process, and a refusal stays typed. clippy.toml disallowed-methods; HostError::Resolution; test the_probe_and_the_spawn_agree_on_every_axis in jam-setup Two annotated call sites: runtime_env.rs and main.rs.
Every transport has a catalog row or is excluded by name. transport_catalog! generates a wildcard-free catalog_entry and ALL Unknown is excluded as not_a_transport.
An unknown stored transport survives a load and save by an older build. persist_enum in jam-store/src/sqlite.rs; test undecodable_enum_survives_a_load_and_save_round_trip Requires the decoder fallback and persist_enum degraded_to to name the same variant.
Mailbox delivery is idempotent. Dedup by band.message_id in append_notification; test duplicate_delivery_is_idempotent None.
A Copilot bridge job is not reinjected while its successful delivery id remains cached. Extension rememberSeenDelivery and handleDelivery; the adapter test deliver_enqueues_one_idempotent_injection_job checks enqueue behavior, not extension injection. The cache retains at most 1,000 ids; eviction or an extension restart can permit reinjection.
One active pull wait per lease. begin_pull_receive returns Conflict None.
Hooks never fail the coding session. run_hook swallows errors and exits 0; ask_bridge_stdout prints nothing without an answer None.

#Failure and recovery

Adapter crash or daemon crash mid-turn. The inbound message stays in the durable queue because nothing settles before TurnComplete(Complete). On the next worker spawn, worker::redeliver hands the backlog to the new host. Owned runtimes start a new turn, so side effects may repeat; that is the cost of the Complete-only rule. The mailbox and bridge paths deduplicate by message and delivery id. Events from the dead instance are rejected by lease.

Resume failure. Codex resumes the persisted provider thread. If the first resumed turn returns a stream error, it starts a fresh thread once and retries, unless the managed runtime requires exact resume (ResumeFailurePolicy::RequireExact), in which case it records a failed resume observation, emits TurnComplete(Failed), and returns an error. Owned Claude Code can fall back from --resume to a fresh --session-id when the transcript is gone, or retry once when a resumed turn reaches result without passing its structural gate. Neither fallback applies when requires_exact_resume is true: startup and turn-boundary failures report failed continuity instead (claudecode/owned/runtime.rs). Checkpoint and exact-resume rules are in Sandbox, workspaces, and continuity.

Context exhaustion and overload. Codex compacts proactively when last_input_tokens reaches the configured budget, and reactively compacts, then starts a fresh thread, then posts an apology. Provider overload is retried with a delay; if the agent already replied or staged a disposition, a later overload refusal completes the turn instead of discarding the answer.

Interrupt. An operator interrupt is terminal. Codex tracks interrupt_requested and does not re-enter the retry ladder; the turn ends Cancelled, and partial text and staged dispositions are discarded.

Teardown failure. The worker suspends the host's ingress during teardown. If teardown fails, it can restore the exact host with a new lease (resume_with) and keep its binding, rather than orphaning the child. HostError::RuntimeStopped means the process is definitively gone and must not be re-tracked.

Missing or slow broker answers. A permission without a broker is denied once. A question without a broker, or one that times out, returns None, and the provider proceeds without an answer. Lifecycle cancellation (runtime stop, transport loss) settles and removes pending asks. Pending permissions and questions are in memory and do not survive a daemon restart; the runtime that asked is also gone at that point.

Hooks. Each telemetry IPC request has a 150 ms client timeout, not the whole hook. A terminal hook can send several transcript thoughts followed by TurnEnded; stdin, process lookup, and local file work also sit outside that request timeout (main.rs::deliver_hook). The plugin declares a 10-second timeout for ordinary hooks and 660 seconds for the ask hook. Failed telemetry is dropped except for retained terminal TurnEnded receipts, which the outbox replays. An ask hook that gets no answer prints nothing and Claude Code shows its own picker.

Copilot extension restart. Re-registration mints a new generation and re-pends open jobs; the extension's bounded seen cache prevents reinjection only while the delivery id remains cached. A poll response larger than the extension's 1 MiB line cap would re-register and re-pend forever, so poll pages are bounded and an oversized job is failed with backoff.

Pull receiver loss. If the monitor or band receive dies without delivering, the guard drop frees the lease for a replacement. If it dies after delivery but before reply or ack, the AckRequired fence re-offers the same message on the next receive.

Executable resolution. A refusal names every candidate and quotes its own output. It is returned, never replaced by spawning the bare command name.

#Extension points

#Adding a coding-agent harness

Commit 3520e617f (OpenCode, 2026-09-18) is a worked example; it changed 25 files and is analyzed in Extension seams. The list below is what the code requires today for a new owned transport. An attached harness can reuse Generic and the pull protocol without a new adapter. A native push or hook integration may need steps 1, 2, 12, and 13, plus its receive-side plugin and any bridge contracts. Copilot, for example, also requires the manager registry and copilot_bridge_* Control methods.

  1. Adapter. Add a module under crates/jam-host/src/ that implements Deliverer and Host, gated on runtime-providers, and export it from lib.rs. Reuse EventContext patterns for staging, send_runtime_event for emission, runtime_env for resolution, sandbox::PreparedExec for Docker, and disposition.rs for the settlement tools.
  2. Composition. Add one branch to bins/jam/src/jamd.rs::build_host before the attached fall-throughs, and update the PTY error message that lists supported transports. build_host has seven constructing branches today plus the PTY rejection.
  3. Domain. Add a HostTransport variant with its serde name, a transport_catalog! row (label, command, provider, reports_provider, env allowlist, offered and resolvable auth, sandbox cells, GitHub policy, control channel, model flags, create-picker flag, integration, permission mode, launch examples, settings), and an is_owned_* predicate on HostSession. validate_sandbox_auth and the sandbox-cell constants must cover it.
  4. Manager transport maps. manager.rs: default_owned_runtime_provider, parse_runtime_transport, runtime_transport_name, sandbox_agent_for_session (if Docker), validate_owned_runtime_configuration, and clear_unsupported_thread_settings_for_transport. runtime_host.rs::transport_tag. analytics.rs maps to jam_analytics::RuntimeTransport, which also needs a variant.
  5. Manager capability claims. worker::owned_runtime_injects_operator_briefing if the adapter injects the briefing; worker::validate_runtime_room_authority is Claude-only today.
  6. Store. crates/jam-store/src/sqlite.rs: the TRANSPORTS table and transport_str.
  7. CLI. bins/jam/src/main.rs: the transport display match and any clap value; agent_management.rs; cli-help.golden.txt.
  8. Probes. Wire the adapter's probe into Manager::test_runtime and the model probes in manager.rs. jam-manager/src/lib.rs::RuntimeProviderProbeRequest has only Codex and Copilot variants.
  9. Runtime tools. Decide how the adapter projects RuntimeHostContext::tools (see the next subsection).
  10. Usage. Emit AgentEventKind::Usage with None for unknown categories. Only sandboxed sessions are ingested live.
  11. Desktop. LocalAgentCreatePanel.tsx::providerCardCopy is an exhaustive switch over RuntimeTransportView, so a new variant fails tsc until it has copy. Regenerate bindings with just bindings. ProviderIcon.tsx and fixtures may need rows.
  12. Attached activity. For a hook-reporting harness, add a translator module like copilot_hooks.rs and a branch in main.rs::translate_hook_report, which matches "claudecode" and "copilot" by string. jam_store::hook_terminal_outbox::retain only retains claudecode receipts.
  13. Layer-1 tasks. For a native task store, implement jam_core::WorkItemSource and add an arm to jamd.rs::work_provider.

#Adding a runtime tool

Implement RuntimeToolProvider with a least-authority control port, register it in Manager::runtime_tool_registry_factory under the right condition, and describe it in the adapter's instructions. Then check each projection, because they are not automatic:

  • Codex: appended to dynamicTools automatically.
  • Copilot: projected only on Shared hosts.
  • Owned Claude Code in Docker: advertised by the guest relay automatically, but command.rs::docker_runtime_tool_names and jam_allowed_tools pre-approve names from runtime_task_tool_specs, runtime_collaboration_tool_specs, and managed_workspace_tool_specs explicitly; a new provider's names must be added there or Claude will prompt or deny.
  • ACP: exposed only through RuntimeToolHttpServer on a Shared reviewed Docker profile.
  • Dedicated Docker through the task service: bins/jam/src/mcp/runtime_tasks.rs builds its own provider list (tasks and collaboration only).

#Adding an AgentEventKind variant

The worker's projections decide what a new kind does. runtime_chat_event and runtime_event_kind_label match exhaustively and will fail to compile. runtime_activity_kind ends in _ => None, so a new kind silently produces no activity unless it gets an arm. Run just bindings, because the type is exported to TypeScript.

#Refactor notes

Oversized adapter files. codex/app_server.rs is 23,045 lines, with about 10,100 before its test module. copilot/sdk.rs is 9,324 (about 4,830 production), acp.rs 9,862 (about 4,560), and claudecode/owned/runtime.rs 8,374 (about 4,200). The turn loop, retry ladders, event context, permission description, instruction assembly, sandbox preparation, and checkpointing all share one file per provider.

Four parallel event contexts, three explicit staging implementations. EventContext is a separate struct in app_server.rs, copilot/sdk.rs, claudecode/owned/runtime.rs, and acp.rs. Codex, Copilot, and Claude maintain active-message sets and serialized explicit disposition staging; their rules are shared by convention and parallel tests, not by shared code. ACP has begin_turn but no stage_disposition or close_disposition_stage; its emit synthesizes NoReply for a successful silent turn. Permission summaries are also provider-specific: Codex, Copilot, and Claude define permission_description; ACP uses acp_permission_description.

Instruction assembly is duplicated. Codex's jam_developer_instructions and Copilot's jam_developer_instructions call the same shared renderers in the same order with provider-specific text between them; Claude's briefing.rs and ACP's briefing_prefix do it again. The ## Operator briefing header and its sentence are literal strings in three files (app_server.rs, copilot/sdk.rs, acp.rs).

Provider-neutral code lives in provider modules. RuntimeCheckpointCoordinator and RuntimeCheckpointTurnPermit are aliases of codex::shared_dispatch::SharedTurnCoordinator and SharedTurnPermit. SharedHostRegistry lives in codex::shared_dispatch and is used by Copilot. JamTasksServerSpec and the Task tool names live in codex::task_mcp and are used by ACP, owned Claude, and runtime_task_tools.rs.

OpenCode is a mode of AcpHost. acp.rs carries OpenCode through is_opencode, opencode_sandbox, and opencode_snapshot flags (24 references), while opencode.rs holds only spawn, probe, and vocabulary helpers.

Runtime-tool projection is per adapter and uneven. The same registry reaches models through five mechanisms with different placement conditions, listed under Adding a runtime tool. A Dedicated Managed Copilot session receives a registry with workspace tools from the manager factory but does not project those tools: copilot_runtime_capability_tools returns an empty list unless placement is Shared, and the Dedicated task service supplies only task and collaboration tools.

Provider knowledge outside jam-host. Counts below are production matches from node scripts/architecture/count-production-refs.mjs, which excludes #[cfg(test)] items; files that are test-only by module declaration are called out.

Crate HostTransport:: matches Provider string literals
jam-manager 114 (manager.rs 95, analytics.rs 9, runtime_host.rs 8, worker.rs 1, managed_sandbox_kit.rs 1) 41 (manager.rs 29, runtime_host.rs 4, worker.rs 3, analytics.rs 3, account_limits.rs 1, usage_attr.rs 1)
jam-store 21 in sqlite.rs, plus 1 in the feature-gated testkit.rs 19 (sqlite.rs 11, file.rs 6, hook_terminal_outbox.rs 2), plus 33 in testkit.rs
jam-domain 41 (host_session.rs 38, mostly the catalog and validators; agent_event.rs 3 in doc links) 33 (host_session.rs 27, workitem.rs 4, host_runtime_catalog.rs 2)
jam-contract 0 2
bins/jam 11 (main.rs 10, agent_management.rs 1), plus 3 in the test-only agent_management_tests.rs 51 (main.rs 27, agent_management.rs 9, jamd.rs 7, mcp/install.rs 6, copilot.rs 2), plus 5 in test-only files
apps/desktop/src-tauri 1 15 (integration_setup.rs 12, commands.rs 3)

The provider-literal pattern was "(codex|copilot|claudecode|claude|opencode|opencode2|cursor|codex-app-server|copilot-sdk|claude-code-cli|codex-acp|copilot-pull)". In the desktop TypeScript, the same pattern matches 113 times in 22 files after excluding tests, stories, fixtures, story support, devtools, and generated bindings.ts. apps/desktop/src/lib/runtimeTransports.ts has no transport literal in code (two in comments); it derives everything from TransportCatalogEntry. LocalAgentCreatePanel.tsx has 20 code lines naming a transport or provider: 10 runtimeTransport === "…" comparisons, 8 case labels in providerCardCopy, INITIAL_CREATE_TRANSPORT = "codex-app-server", and a "codex" placeholder.

The transport wire name is written out in seven places. manager.rs::runtime_transport_name, manager.rs::parse_runtime_transport, runtime_host.rs::transport_tag, sqlite.rs::transport_str and its TRANSPORTS table, main.rs's display match, and the serde renames on the enum each spell the same strings. parse_runtime_transport and TRANSPORTS also accept aliases (codex, app-server, copilot).

Manager depends on concrete adapter types. jam-manager references jam_host::claudecode:: 31 times in production code (mostly owned::probe report types), jam_host::copilot:: 12, jam_host::opencode:: 10, jam_host::codex:: 5, and jam_host::acp:: once. Probe results are not provider-neutral: RuntimeProviderProbeRequest is a two-variant enum for Codex and Copilot, and Claude, ACP, and OpenCode are probed through separate code paths in manager.rs.

Special cases keyed on provider strings. build_host falls through on peer.host == "claudecode". work_provider maps the provider-neutral pull tag to Claude's task files. runtime_usage_observation ingests only sandboxed sessions, and worker.rs skips managed usage homes whose host provider is not "codex". hook_terminal_outbox::retain keeps only claudecode receipts. Manager ignores "claudecode" status-line rate limits because get_usage is authoritative. jam-usage::provider_key folds "", claude, and claudecode together. manager.rs treats "codex-app-server" | "copilot-sdk" as the host-native managed-workspace transports by string.

Unused or thin trait surface. Host::delivery_mode passes through QuestionGate but does not select a production routing branch (see Two delivery modes), and Host::name feeds one log field in manager.rs. Host::compact is implemented only by Codex. run_conformance covers only Generic and the mailbox adapter.

Legacy paths still compiled and selected. The Claude mailbox adapter, the copilot-pull tag, the Pty transport variant (rejected at construction but kept for display), RuntimeHostContext::events (the legacy sink), and CodexSource, whose task-file layout is documented as "ASSUMED, UNVERIFIED" in codex/mod.rs.

Contradictions between comments, docs, and code:

  • Root AGENTS.md says owned sessions use "Codex app-server, ACP, PTY, Copilot, or Claude Code CLI transports"; build_host rejects PTY.
  • Root AGENTS.md says managed Copilot "remains DedicatedSessionHost" and must not advertise Shared; COPILOT_SANDBOX_CAPABILITY_CELLS includes a SharedAgentHost cell, and commit b10fa88c4 (2026-09-16) added SharedCopilotRuntimeHost.
  • crates/jam-host/AGENTS.md names regressions a_message_arriving_during_compaction_is_steered_into_the_turn_that_follows_it and a_steered_message_survives_the_compaction_retry_of_the_turn_it_joined; no such functions exist. The Codex tests are a_message_arriving_during_compaction_steers_the_next_turn and a_mid_turn_message_is_steered_and_survives_compaction_retry. It also says each adapter has a_provider_auto_compaction_mid_turn_keeps_the_steered_message_settleable; only Copilot uses that name (Codex: a_provider_auto_compaction_mid_turn_steers_message_after_compaction; Claude: a_provider_auto_compaction_mid_turn_keeps_injected_messages_active).
  • The jam-host/src/lib.rs module docs list five adapters and omit CodexAppServer and ClaudeCodeOwned.
  • RuntimeHostContext::operator_instructions docs say "owned Codex, Copilot, and Claude Code only", and the doc comment on worker::owned_runtime_injects_operator_briefing says "the ACP lane" has no injection point; the function returns true for OpenCode, which runs on the ACP adapter.
  • codex/mod.rs says a new provider is "a host module + a vocab table + one jamd selection line — with ZERO change to domain/store/contract/wire/manager/desktop"; the OpenCode commit changed 25 files across those layers.
  • The doc comment on Manager::runtime_tool_registry_factory says the backend port "exposes only inventory, readiness, clone start, and clone status"; ManagedWorkspaceToolProvider also exposes branch, commit, push, pull-request, and checks operations.
  • The module docs in disposition.rs say each adapter calls the shared description functions so drift "cannot be written"; copilot/sdk.rs::runtime_disposition_tools uses the shared schemas but writes its own description strings.
Scroll to zoom, drag to pan.