#Providers and attached agents
On this page
Where 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 notesThis 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, andprovider_resume_observer. The request brokers return boxed futures, butcheckpoint_activationtakes no argument, andProviderResumeObserverholds synchronous start/completion callbacks. Authority is not uniformly payload-only: the permission broker captures the peer whileRuntimePermissionRequestcarries session and room identifiers; checkpoint capture requests also carry a runtime-session identifier. Question and message brokers capture the room.RuntimeMessageBrokertakes only a message body, so that send path cannot select another identity or room (lib.rsbroker types;manager.rs::runtime_permission_broker;jam-domain/src/permission.rs::RuntimePermissionRequest). - Safe defaults when a broker is absent.
RuntimeHostContext::request_permissionreturnsdeny_oncewith "no runtime permission broker configured".ask_questionreturnsNone, which adapters treat as "proceed with your best judgment".request_environment_approvalreturnsCancel.send_messagereturns an error. - Exact-session capabilities.
tools: Option<Arc<RuntimeToolRegistry>>,task_access, andcapability_accessare 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, andnative_local_platform_route. - Event outlets.
event_ingress: Option<RuntimeEventIngress>for owned hosts,startup_eventsfor startup phases before the route is committed, andevents, 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:
- Bumps per-session activity for the idle reaper and tracks turn liveness from
TurnStartedandTurnComplete. - Calls
project_runtime_activity, which maps the kind to anActivityKind, publishesEventKind::Activity, and appends to the activity log. This is how owned runtimes light up the same UI as hook-driven attached agents. - Projects the working lease (
RuntimeWorkingPublishers), rate-limit and context gauges (project_runtime_gauges), and usage (project_runtime_usage). - Handles content and settlement: buffers
AssistantTextdeltas and final text per turn key, recordsTurnDispositionStaged, and onTurnCompletecommits or discards what it buffered. - 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::deliversendsRuntimeCommand::Prompton 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_loopselects between provider notices and commands.handle_promptrenders the prompt (render_prompt), runsrun_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 asAssistantTextwithTurnComplete(Complete). - Steering. A
Promptthat arrives mid-turn is steered into the running turn with the app-serverturn/steerRPC and added to the active-message set under the samedisposition_stagelock. If steering fails, the message goes tostate.carryoverand runs as its own turn before any new command is taken. Retries re-carrysteered_so_farso a steered message stays settleable (testsapp_server.rs::a_mid_turn_message_is_steered_and_survives_compaction_retry,a_message_arriving_during_compaction_steers_the_next_turn). - Usage.
usage_eventbuildsAgentEventKind::Usagefrom theturn/completedusage block, subtracting cached input from input and leaving reasoning, event id, and timestamp asNone. After a clean turn,factory.refresh_rate_limitsemitsRateLimits.RuntimeNotice::TokenUsagefeedsContextGauge, not usage. - Where usage is actually persisted.
worker::runtime_usage_observationreturnsNoneunless the session'sspawn.sandbox.enabledis true, then validates durable peer, host-session, runtime-host, and room-binding ownership before building aUsageObservation. 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_kindmaps (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_idmust 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_TURNdispositions per turn. - Staging, event emission, and rollback on emit failure happen under one
disposition_stagemutex.close_disposition_stagetakes the same mutex and setsdisposition_open = falsebefore the adapter emitsTurnComplete. Every acceptedTurnDispositionStagedis 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
dynamicToolswhenevertoolsis present, and routes calls throughhandle_runtime_tool_call. On a Shared host the SDK has one process-wide dynamic-tool handler, soSharedProtocolRouterroutes each call by exact provider thread id (testshared_runtime_tools_route_by_exact_provider_thread_and_never_cross_rooms). Host-native Codex instead gets thejam mcp tasksserver through one-run--config mcp_servers.jam_tasks.*overrides (task_mcp::TaskMcpRegistration), controlled byJAM_CODEX_TASK_MCP. - Copilot projects registry specs as native SDK tools only on a Shared host (
copilot_runtime_capability_toolsreturns nothing otherwise). Sandboxed Copilot also attaches Docker's MCP gateway (apply_docker_mcp_gateway). - Owned Claude Code host-native uses
jam mcp dispositionplus thejam_tasksserver (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_toolspre-approves the Jam catalogue by exact name. - ACP host-native passes
jam mcp tasksas ACPsession/newMCP-server data (jam_mcp_server). Dedicated Docker ACP attaches Docker's task gateway. Only a Shared reviewed Docker profile exposes the registry, through a loopbackRuntimeToolHttpServerannounced 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:
owned_policy::renderfillsagent-guidance/owned-runtime-policy-core.md, embedded withinclude_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.room_work_guidance::renderembedsagent-guidance/room-work-core.mdand rewrites host-CLI passages whenRoomWorkCapabilities::host_jam_cliis false (sandboxed runtimes cannot run the hostjam).render_current_planadds the attached room plan.runtime_reference_guidance::renderlists read-only reference folders for a sandbox.- An
## Operator briefingsection with the peer's briefing, whenworker::owned_runtime_injects_operator_briefingis true (Codex, Copilot SDK, owned Claude Code, and OpenCode). team_guidance::renderfor the team the agent joined with.- Adapter-specific rules: Codex's private-task mechanics, Jam API and identity rules, and the
jam-managed-workspace-v1contract whentoolsis present (jam_developer_instructions); the Copilot equivalent; Claude'sbriefing.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::Delivererandjam_host::Host.delivermust return quickly: the room lane and an engine inbound slot are held until it does. Owned adapters only enqueue a channel command.prepareandteardownmust be idempotent (testkit::run_conformance). Adapters return errors instead of panicking or writing stderr (module docs inlib.rs).AgentEventstream throughRuntimeEventIngress. Sequence numbers are monotonic per adapter;TurnDispositionStagedprecedesTurnCompletefor the same turn; startup-buffered events are replayed in order before live ones.Usagefields areNonewhen the provider omitted the category, never fabricated as zero (field docs inagent_event.rs).- Probe entry points used by
Control::test_runtimeand the model probes:codex::app_server::probeandprobe_models,copilot::sdk::probeandprobe_models,acp::probe,claudecode::owned::probeandmodel_probe::probe_models,opencode::probe_models. Failures areRuntimeProbeFailurewith aRuntimeProbeLayerofRuntimeCapability,Service, orModelCredential, plus typedtimed_out. The manager'sHostRuntimeProviderProbebounds Codex and Copilot probes withruntime_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 thejam mcp dispositionserver.
#Consumed by this subsystem
- Brokers in
RuntimeHostContext, described in The context and its brokers. Controlmethods reached by attached agents and CLI-side tools:reply,ack,receive_pull(held open),ask_agent_question(held open),report_activity, the ninecopilot_bridge_*methods (poll,permission, andquestionare held open),runtime_task_call, andruntime_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, anddecide_runtime_permission. See Control API crosswalk for routes and clients, and Daemon event kinds foractivity,runtime_gauges,runtime_permission_*, andagent_question*.- Provider protocols. Codex app-server JSON-RPC through the rev-pinned
codex-app-server-sdkfork (thread/start,thread/resume,turn/start,turn/steer,turn/interrupt,dynamicTools, approval andrequest_user_inputserver requests;MIN_CODEX_DYNAMIC_TOOLS_VERSIONis0.146.0and older versions are refused). The Copilot CLI through the rev-pinnedgithub-copilot-sdkfork. Claude Codeclaude -pstream-json on stdin and stdout. ACP version 1 throughagent-client-protocol, plus Cursor'scursor/ask_question. The Claude teammate mailbox JSON file. Claude Code and VS Code hook payloads on stdin. The Copilot extension's JSON-line protocol throughband 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.
- Adapter. Add a module under
crates/jam-host/src/that implementsDelivererandHost, gated onruntime-providers, and export it fromlib.rs. ReuseEventContextpatterns for staging,send_runtime_eventfor emission,runtime_envfor resolution,sandbox::PreparedExecfor Docker, anddisposition.rsfor the settlement tools. - Composition. Add one branch to
bins/jam/src/jamd.rs::build_hostbefore the attached fall-throughs, and update the PTY error message that lists supported transports.build_hosthas seven constructing branches today plus the PTY rejection. - Domain. Add a
HostTransportvariant with its serde name, atransport_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 anis_owned_*predicate onHostSession.validate_sandbox_authand the sandbox-cell constants must cover it. - 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, andclear_unsupported_thread_settings_for_transport.runtime_host.rs::transport_tag.analytics.rsmaps tojam_analytics::RuntimeTransport, which also needs a variant. - Manager capability claims.
worker::owned_runtime_injects_operator_briefingif the adapter injects the briefing;worker::validate_runtime_room_authorityis Claude-only today. - Store.
crates/jam-store/src/sqlite.rs: theTRANSPORTStable andtransport_str. - CLI.
bins/jam/src/main.rs: the transport display match and any clap value;agent_management.rs;cli-help.golden.txt. - Probes. Wire the adapter's probe into
Manager::test_runtimeand the model probes inmanager.rs.jam-manager/src/lib.rs::RuntimeProviderProbeRequesthas onlyCodexandCopilotvariants. - Runtime tools. Decide how the adapter projects
RuntimeHostContext::tools(see the next subsection). - Usage. Emit
AgentEventKind::UsagewithNonefor unknown categories. Only sandboxed sessions are ingested live. - Desktop.
LocalAgentCreatePanel.tsx::providerCardCopyis an exhaustive switch overRuntimeTransportView, so a new variant failstscuntil it has copy. Regenerate bindings withjust bindings.ProviderIcon.tsxand fixtures may need rows. - Attached activity. For a hook-reporting harness, add a translator module like
copilot_hooks.rsand a branch inmain.rs::translate_hook_report, which matches"claudecode"and"copilot"by string.jam_store::hook_terminal_outbox::retainonly retainsclaudecodereceipts. - Layer-1 tasks. For a native task store, implement
jam_core::WorkItemSourceand add an arm tojamd.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
dynamicToolsautomatically. - Copilot: projected only on Shared hosts.
- Owned Claude Code in Docker: advertised by the guest relay automatically, but
command.rs::docker_runtime_tool_namesandjam_allowed_toolspre-approve names fromruntime_task_tool_specs,runtime_collaboration_tool_specs, andmanaged_workspace_tool_specsexplicitly; a new provider's names must be added there or Claude will prompt or deny. - ACP: exposed only through
RuntimeToolHttpServeron a Shared reviewed Docker profile. - Dedicated Docker through the task service:
bins/jam/src/mcp/runtime_tasks.rsbuilds 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.mdsays owned sessions use "Codex app-server, ACP, PTY, Copilot, or Claude Code CLI transports";build_hostrejects PTY. - Root
AGENTS.mdsays managed Copilot "remainsDedicatedSessionHost" and must not advertise Shared;COPILOT_SANDBOX_CAPABILITY_CELLSincludes aSharedAgentHostcell, and commitb10fa88c4(2026-09-16) addedSharedCopilotRuntimeHost. crates/jam-host/AGENTS.mdnames regressionsa_message_arriving_during_compaction_is_steered_into_the_turn_that_follows_itanda_steered_message_survives_the_compaction_retry_of_the_turn_it_joined; no such functions exist. The Codex tests area_message_arriving_during_compaction_steers_the_next_turnanda_mid_turn_message_is_steered_and_survives_compaction_retry. It also says each adapter hasa_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.rsmodule docs list five adapters and omitCodexAppServerandClaudeCodeOwned. RuntimeHostContext::operator_instructionsdocs say "owned Codex, Copilot, and Claude Code only", and the doc comment onworker::owned_runtime_injects_operator_briefingsays "the ACP lane" has no injection point; the function returns true for OpenCode, which runs on the ACP adapter.codex/mod.rssays a new provider is "a host module + a vocab table + onejamdselection 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_factorysays the backend port "exposes only inventory, readiness, clone start, and clone status";ManagedWorkspaceToolProvideralso exposes branch, commit, push, pull-request, and checks operations. - The module docs in
disposition.rssay each adapter calls the shared description functions so drift "cannot be written";copilot/sdk.rs::runtime_disposition_toolsuses the shared schemas but writes its own description strings.