Container orchestration¶
A human can give one container the right to orchestrate: its agent then starts child containers, talks to them, brings their commits into its own repo and cleans up after them, without asking anyone each time. The grant is set on the host and bounds everything the agent can do.
Child GitHub proposals¶
Children cannot stage GitHub writes, including after adoption when the role
marker identifies them as children. They send the exact repository, target and
content to their orchestrator with jailbee inbox send message --text "..."
(or --kind question when a result is needed). The orchestrator stages the
proposal in its own outbox with jailbee issue or jailbee pr; the human
reviews and publishes on the host. Never write GitHub outbox files or create
outbox directories by hand. See container commands.
On done, pending child GitHub proposals cause the broker to keep the child and report the leftovers even when its policy is already keep. Broker stop records the pending count; an externally stopped child without a recorded count has unknown outbox state and is conservatively kept for inspection.
Overview¶
The motivating workflow is a bug backlog. An orchestrator container reads the
tickets from GitHub (the container's gh is read-only, which is enough),
starts one child per bug, each with its own prompt, and waits for the
children's "done" messages. For each one it imports the child's branch,
merges it, and destroys the child to free a slot for a queued one. At the end
it offers the combined work as a pull request through the existing
PR outbox, which a human publishes.
The container never gets host access. Every action is a manifest the agent writes into an outbox directory in the container; a host-side broker validates it against the grant and acts. Manifests are narrow, validated schemas, never shell strings.
Roles:
- Orchestrator: a container that holds a grant. It may do what the grant permits.
- Child: a container the broker created for an orchestrator or the host user adopted into its topology. It can only send messages to that orchestrator. A child never orchestrates.
- Ordinary container: neither. Nothing changes for it.
Child containers are not a substitute for an agent's own subagents. The
bundled jailbee-orchestration skill tells the orchestrator to keep ordinary
delegation (research, review, plan tasks) on its subagent tool and to spawn
children only when the user asks for them: a grant permits orchestration, it
does not request it. This matters most when default_profile grants every
new container.
Grants¶
A grant is stored in the orchestrator's Incus config
(user.jailbee.orchestration.grant), which the container cannot write, so it
cannot widen its own grant. It holds:
max_running_children: 4
operations: [spawn, message, import, restart, stop, destroy, rights, refresh]
grantable:
mounts: [data, docs] # names of the repo's optional mounts
loose: false # may set a child to loose network
egress: ["pypi.org", "*.npmjs.org"] # extra egress hosts for children
spawn and message are implied by any grant. A new grant without
--profile starts from the configured
orchestration.default_profile, so the defaults are
yours to set. Without one, fields you omit default to 4 running children,
operations spawn message import restart stop destroy rights refresh, nothing grantable.
Leave destroy out with --deny destroy.
jb orchestrate grant bugs --children 4 --mount data --egress pypi.org
jb orchestrate grant bugs --profile bugfixer # start from a configured profile
jb orchestrate show bugs # the grant, what this repo leaves of it, running children
jb orchestrate ls # every container of this repo with a grant (--json)
jb orchestrate revoke bugs
jb orchestrate adopt bugs worker1 worker2
jb orchestrate release worker1
grant takes --profile, --children, --allow OP / --deny OP,
--mount M / --clear-mounts, --loose / --no-loose and --egress H /
--clear-egress; each repeatable flag can be given several times. Fields you
do not name keep their current value (or the profile's, or the defaults).
Granting again edits the grant. A missing container is picked
interactively, as for every command; revoke always shows the picker.
jb orchestrate grant also makes sure the broker service is
running.
Over remote SSH, grant needs the host's remote.ssh.orchestration setting
(ssh by default; off refuses it). ssh limits the grantable mounts, loose network and egress
to what the session could do itself (remote.ssh.mounts, remote.ssh.network;
mount_roots never); orchestrator limits them to the orchestrator's
permanent rights. The child limit and operations are always adjustable,
narrowing is always allowed, and elements the stored grant already holds are
kept. The check happens when the grant is made, so later turning the remote
settings off does not narrow stored grants. See
Security and config.
Revoking stops new spawns and other operations and drops queued spawns. Existing children are untouched and stay children.
Publish the orchestrator's own outboxes¶
Publishing is opt-in, separate from child operations. A grant without
publish has no publish rights; children never receive them. The broker uses
the host user's GitHub/git credentials and the same locks, freshness checks,
expected fields, receipts and journals as manual publication.
publish:
issues: [create, edit, comment, labels, assign_self, state] # own repo and declared submodules
foreign_issues:
repos: [someorg/upstream-lib] # case-insensitive owner/name, or ["*"]
actions: [comment]
pull_requests: true
pr_reviews: false
max_actions_per_hour: 30 # 1..500
jb orchestrate grant bugs --publish-issues comment,state --publish-prs
jb orchestrate grant bugs --publish-foreign-issues someorg/upstream-lib:comment
jb orchestrate grant bugs --publish-pr-reviews --publish-rate 10
jb orchestrate grant bugs --no-publish
Assignment has two kinds. assign_self covers only assignees changes whose
logins all equal the publishing account, compared case-insensitively after
@me is resolved. That includes removal: assign_self lets the orchestrator
unassign the host user (remove: ["@me"]), also from issues the user
claimed by hand. assign covers any login and implies assign_self. A
repo cap can narrow assign to assign_self. Publishing needs assign when
an action adds or removes another login, or when no publisher login is known.
Over remote SSH, adding either kind, or going from assign_self to assign,
counts as widening.
--publish-issues none clears issue rights. Repeatable
--publish-foreign-issues REPO:KINDS replaces the foreign block; every listed
repository must use the same kinds. --publish-foreign-issues none clears the
foreign block alone. --publish-prs/--no-publish-prs and
--publish-pr-reviews/--no-publish-pr-reviews toggle their rights.
--no-publish clears everything and is exclusive with other publish flags.
A bare grant keeps existing publish rights. show and ls display them;
the terminal dashboard's Orchestration… menu shows and edits them too.
Profiles may carry the same block; the repo cap intersects rights and takes
the smaller rate, while publish: false forbids publication.
Every publication the broker performs is recorded in the Sent log with publisher
orchestrator (jb outbox sent ls; see
git-bridge).
After staging a manifest in its own issue or PR outbox, the orchestrator sends:
jailbee inbox send publish --issue close-12.json --wait
jailbee inbox send publish --pr imported-branch.json --wait
The version-1 protocol request is
{"version":1,"type":"publish","id":"publish-unique","outbox":"issue","manifest":"close-12.json"}.
The target is a bare JSON file name, never a path or .progress.json file.
The broker checks every pending action before acceptance and again under the
domain lock. Any uncovered kind or repository refuses the whole request.
Acceptance replies started; a later done lists result URLs and created
issue numbers in manifest order. --wait can return at started: read the
final status for the same ref through the ordinary inbox. A worker failure
replies failed. An uncertain outcome, including a broker restart during
publication, is never retried automatically: ask the human to inspect
jb outbox on the host. Use a new request id for a deliberate retry.
Source publication cannot force-push, use the default/base branch as head,
or overwrite an existing upstream head unless it belongs to a PR bound to
this container. New PRs use the default base; bound updates keep that PR's
base. Updates require an authored BranchPrMap binding created by this
container's source publication; review-only PR/stacked labels and adopted
bindings do not authorize pushes. PRs opened here retain authorship for later
updates. Updating any PR opened some other way remains a host step. Review comments and replies target only bound PRs in the own repository,
never foreign repositories or submodules. Description rewrites additionally
require an authored, non-adopted source-PR binding; review-only labels do not
authorize editing someone else's title or body.
Only one publish runs per orchestrator. Its full pending-action count is
reserved against a sliding-hour cap before starting, including failed or
partially applied attempts. A publish that the in-lock policy refuses before
any GitHub mutation gets its reservation back; failed or partly applied
attempts keep theirs. One source
publish costs one action. Revocation or --no-publish refuses new requests;
an already accepted worker finishes and reports its result.
The broker writes finished requests, action kinds/repos, URLs and outcome to
publish-log.jsonl under its state root (~/.local/state/jailbee/orchestration/).
Existing outbox receipts and history remain available through jb outbox.
A worker shutdown that leaves a review outcome uncertain also leaves a
host-side block for that instance and manifest name: further broker requests
for it are refused until the human resolves the outbox on the host. A manual
host publication is not subject to this broker block. Do not rename and
resubmit an uncertain manifest. The broker waits up to 25 seconds for workers
on shutdown/upgrade; later recovery never automatically retries a cut request.
Over SSH, granting publish rights requires the session's command policy to
permit the equivalent manual operation: issue apply, pr push
--source-manifest, or review apply. The same router policy checks these
permissions; enabling orchestrate grant alone does not unlock publishing.
Adopt running containers¶
The host user can adopt existing containers into an orchestrator's topology, including stopped containers. The orchestrator must hold an effective grant; each child must belong to the same repo and must not be mount-mode, hold a grant, have children, or already belong to a different orchestrator. Adoption is refused if the resulting number of running children exceeds the effective cap. An existing child of the same orchestrator is left unchanged.
Adoption clears stale child-state labels, records the parent and sets the
on-done policy to keep. It sends an adopted inbox message to each side, but
does not create an import baseline or runner, relaunch an agent, or start
supervision. Destruction therefore requires an import first or --force.
To supervise a hand-started agent, ask the user to close it before using
jailbee inbox send restart --child C --agent-only; otherwise a second agent
may be launched. JAILBEE_ROLE=child is set on the next agent launch, not in
the existing session.
jb orchestrate release removes the relationship, child-state labels, watch
and pending state. It revokes orchestrator-owned rights but leaves user-owned
rights intact, and refuses while an autostart run is live. Release removes the
child role marker before clearing the parent label. A stopped child retains
its relationship: start it and retry release so marker cleanup can be verified.
Cleanup failures likewise retain the parent for retry. An agent launched with
JAILBEE_ROLE=child must be relaunched after release to drop its old environment.
If no child is
named, release always prompts and is refused without an interactive terminal.
Over remote SSH, adoption requires remote.ssh.orchestration to be enabled;
release is allowed as a narrowing action without that setting.
Profiles¶
Named profiles and an optional default live in the orchestration: block of
the global config and the host-local config (jb config edit --local):
orchestration:
profiles:
bugfixer:
max_running_children: 5
grantable: { mounts: [data] }
default_runner: { agent: claude, model: opus }
default_profile: bugfixer # applied to every new container when set
A profile may set default_runner for children of its holder; the runner is
copied into the container preference along with the profile's rights.
A profile is copied into the grant when it is applied. Editing the profile
later does not change grants already given; running jb orchestrate grant
--profile again does. default_profile is applied when a container is
created, never to a child, and not to a mount-mode container. Where a local
profile has the same name as a global one, the local one wins.
Save a container's stored grant as a profile with jb orchestrate profile save
bugs reviewer. The profile is written to global.yaml; --force replaces an
existing global profile. Saving is refused while repos/<prefix>.yaml defines
that name, because the host-local profile would override it.
The repo layer (.jailbee/config.yaml) is different: it can only narrow.
It cannot define profiles or a default, but may set:
orchestration:
allowed: false # no container of this repo may orchestrate
max_running_children: 2 # cap
mounts: [docs] # only these mounts may ever be granted to children
egress: ["pypi.org"] # only these egress entries
loose: false # children may never be loose in this repo
The reason is that a mount-mode container can write the host checkout's repo
config, so a widening there would let a container raise its own rights. The
cap applies when the grant is made and again at every broker check;
jb orchestrate show prints the effective grant when it differs.
A grant is refused for a mount-mode container (it can neither fork nor
import), for a child, and for a container of a repo that is not registered
(jb apply registers it).
Child rights¶
When the orchestrator spawns a child it names the child's rights: mounts,
loose and egress. The broker refuses the spawn if any right exceeds the
grant:
- each mount must be in
grantable.mountsand be one of the repo's optional mounts; looseonly ifgrantable.looseis true;- each egress host must match a
grantable.egressentry (the same matching asegress_allow), or effectivegrantable.loosemust be true and the entry covered by the repo's egress cap (if one is set). Loose implies narrower egress permission for both spawn and runtime grants.
All of it is also subject to the repo's cap. A child gets exactly these rights and nothing else: it inherits only git commits from the orchestrator. Mounts are attached persistently and loose is its network mode, with no timeout.
The child's own head is treated as untrusted. If the branch it starts from
contains an autostart step that wants network: loose or a
mount, the spawn is accepted only when the child's rights already include that
mount, and, for a loose step, loose. Otherwise the spawn is refused with the
reasons, so an orchestrator cannot commit its way to more than its grant.
Change runtime rights¶
The rights operation (included in new grants by default) lets an orchestrator
change its own child's rights, never its own. Granting uses the current grant
and repo cap, just like spawn; revoking requires an active grant but not the
rights operation. A removed grant disables both.
jailbee inbox send rights --child fix-12 --egress pypi.org --for 5m --wait
jailbee inbox send rights --child fix-12 --mount aws --for never --wait
jailbee inbox send rights --child fix-12 --revoke-mount aws --wait
Every grant requires --for: a positive duration, at most 24h, or never.
One duration covers all granted items. Prefer an egress host over loose, a
bounded duration over never, and revoke when the need ends. The existing
60-second refresh timer takes timed rights back, including timed egress;
clock deadlines survive host restarts. Stopped containers keep the changes
for their next start, and mounts are attached as instance devices.
Revocation touches only rights orchestration gave, not rights a human added
by hand; a right whose live expiry no longer matches the recorded deadline
counts as the human's. A grant never touches a right held by hand (it is
reported as held by hand; left as is). A timed grant never shortens a right
orchestration owns: the later deadline wins, and never removes an expiry. Spawn and permanent
runtime rights live in the child rights label; timed provenance is separate.
There is no restart-time escalation check. Requests are preflighted before
changes, revoke before grant, and roll back on application failure (best effort).
Applied requests are journalled; no human notification is sent. Existing
network/mount views reflect the changes.
A child may send a revoke-only rights manifest for itself (omit --child
or name itself). It cannot include grant or name another child; the refusal
is children may only revoke their own rights. Successful relinquishment
sends its orchestrator a note. Requests for additional access stay question
messages.
Primary agent and initial prompt¶
A child starts its work from an initial prompt. The prompt goes to the repo's primary agent as its first message, on the command line at launch. It is sent once: later starts of the container run the plain agent command, so a restart never repeats a task.
primary_agent: auto(default) or an agent name picks which agent that is.agents.<name>.prompt_argssays how the agent takes a first message. The presets forclaude,codexandopencodeset it.jb new <branch> --prompt FILE|-andjb fork <source> <branch> --prompt FILE|-use the same mechanism by hand.- The prompted agent launches only after every autostart stage, deferred
ones included, has run, so a child never starts its task before its
container is ready. A failed stage stops the run before the agent
launches, and the spawn is reported
failed.
An agent without prompt_args cannot take a prompt; the request is refused
up front. See Primary agent and initial
prompt and the
primary_agent key. For a child, the broker
prepends a short fixed preamble naming its orchestrator and the child skill,
and sets JAILBEE_ROLE=child and JAILBEE_ORCHESTRATOR=<name> in the
agent's environment.
Fork warm paths¶
Fork spawns seed the source's Git-ignored build outputs from fork.warm_paths
using btrfs reflinks, before mounts, caches and autostart. They seed HEAD forks
only: a dirty source, a source that moved past the pinned fork commit, a non-HEAD
--ref, or another storage driver is skipped with an advisory. A reflink failure
never falls back to a full copy and never fails the fork. While warming runs,
the source's entire repository snapshot is visible read-only to the child,
including ignored secrets such as .env; fork only to trusted children.
Paths equal to or below later mount targets are skipped rather than copied
into storage that would immediately be hidden.
For jailbee inbox send spawn, repeatable --warm PATH replaces the configured
list; --no-warm disables it. The flags are mutually exclusive and are refused
with --new or --pr. The version-2 fork manifest field is warm_paths:
omitted uses configuration, [] disables seeding, and a non-empty list replaces
configuration. Patterns are repo-relative globs of ignored paths (* does not
cross /; ** matches zero or more segments). Unmatched patterns get a note.
See fork.
Autostart stage selection¶
spawn and restart accept optional autostart_profile: "NAME", also
available as jailbee inbox send spawn|restart --autostart-profile NAME.
Names are validated against the host repo's autostart profiles.
Spawn passes the choice to creation; restart stores it before booting, runs
missing one-shot stages, then the selected start stages in a background boot
worker. Ordinary restarts return started with a log path, keeping the
broker responsive and reserving the child's capacity while it boots. A later
setup failure is also recorded in the job and log and reaches the orchestrator
as a second status, failed (restart of child `x` failed: ...), for the same ref;
it does not free a running child's slot.
Queued requests
remember the selection and revalidate it before starting. An unknown name is
refused with autostart profile `NAME` is not defined. This field selects
stages, not the agent or its LiteLLM profile; omit it to use the creation
default or keep a child's existing choice on restart.
For a light child, jailbee inbox send spawn --branch task --services web --wait
starts only web and its dependencies; restart also accepts --services.
The equivalent manifest field is services: ["web"] (at most 32 names).
An empty list or --services '' starts none. Omission uses the profile or all
services on spawn and preserves an explicit selection on restart. An explicit
selection overrides the profile's service set only; nonservice stages still follow
the profile. Services are prepared once per container and service, including
newly selected dependencies. Child preparation cannot exceed its stored rights.
For ordinary containers, preparation cannot exceed the elevation approved at
creation; legacy containers fall back to host-effective declarations. Re-assess
the configuration and use an accepted host or orchestrator refresh to widen
approval for successfully prepared services. An in-place profile switch starts safe services but
defers unprepared elevated services and their dependents until the next restart. Neither services nor --services can be combined with
agent_only / --agent-only.
Refresh a child's services¶
After update --merge changes dependencies or migrations, or when a child asks,
use jailbee inbox send refresh --child C --services db,web --wait. The
refresh operation must be granted. For an existing grant without it, run
jb orchestrate grant ORCHESTRATOR --allow refresh on the host; a bare grant
preserves the existing operations. Final status reports per-service outcomes,
including cascaded restarts. The
accepted plan may contain child-edited commands: the orchestrator authorizes
their execution within the grant and repository cap. Restricted SSH cannot run
host service refresh, even with network and mount unlocks.
If a managed agent is running, an elevated refresh requires its known session and
idle state, pauses it, restores the network and mounts, then resumes the same
session, even if prepare failed. A failed prepare skips later prepares and the
restart; the resumed agent receives the outcomes and a continuation prompt.
Unknown agent state or session, insufficient rights, a busy child or a foreign
live agent causes refusal. Use --force only after the child confirms
its work is paused or stopped; it bypasses only the idle check. It never permits
interrupting a working child. On the host --yes only consents to foreign
agents remaining active during elevation; it does not force the idle check.
If restoration cannot be confirmed, resume is withheld and the failure reports
recovery commands such as jb net strict and, when needed, mount removal. If
supervision registration fails after restoration, resume is also withheld; the
failure preserves service outcomes and reports a recovery command. Once safe,
recover supervision with jailbee inbox send restart --child C --agent-only
--resume SESSION --text 'Continue your task.'.
The detached refresh worker continues across a broker restart; a final status for a stopped orchestrator is parked for delivery. A retry of a saved final status retries delivery only, not prepare. If execution was interrupted and its outcome is uncertain, inspect the child and its services manually before sending another refresh: prepare commands such as migrations might already have run. Durability across host power loss is not guaranteed.
Reuse a running worker without autostart¶
Use jailbee inbox send restart --child C --agent-only --text "Next task"
(or manifest "agent_only": true) for a persistent worker kept with
on_done: "keep". The child must be RUNNING and idle: either its done
was accepted, or its previous monitored agent has ended (the broker reported,
or would report, blocked). A working child or uncertain agent liveness is
refused; there is no force option. Active startup workers also prevent relaunch.
Without the flag, a running child is still refused with "stop it first".
A kept worker's agent keeps jailbee inbox wait --timeout none in the
background between tasks, re-arming it after handling messages.
Only the agent tmux window is replaced. Container boot, setup stages and all
on_start actions are skipped; existing services stay running. The fresh
session receives the prompt with the usual child preamble as its first input.
Runner options work exactly as ordinary restart: omit to keep, name
--agent/--profile/--model to change, or use --default-runner.
--on-done may change the completion policy. --autostart-profile is refused
with --agent-only, since no autostart runs.
The child already occupies its running slot, so this action adds no slot or
reservation and never queues, even at the running-child limit. It returns
started, followed by done after ready-and-alive confirmation; an early
failure returns failed, and later exit without accepted child done sends
blocked, just like spawn/restart. For shared-state rework, send update
first and include merging the imported ref in the new task; the broker never
merges it itself.
Runner: which agent a child runs¶
A spawn or restart manifest may carry an optional runner: agent
(claude or claude-jb), profile (a LiteLLM profile, claude-jb only) and
model (fable, opus, sonnet or haiku). It rewrites the Claude
command of the repo's agents.claude.command for that child. When omitted on
spawn, the container default applies, then the local, repo, or global
orchestration.default_runner, in that order; without any default the child
runs the repo's primary agent as configured. Set the container preference with
jb orchestrate grant bugs --runner-agent claude-jb --runner-profile grok --runner-model sonnet;
--runner-profile and --runner-model require --runner-agent. Use
--clear-runner to remove the container preference and fall back to config.
Reapplying --profile also clears the container preference unless a runner is
specified or the profile names one. jb orchestrate show and ls display the
effective preference; the dashboard offers a default runner entry. This preference grants no rights.
On restart, an absent runner keeps the child's previous one, while
"runner": null selects the current effective default, or clears the runner
if no default is configured. The runner is stored on the child as a label and
shown on its dashboard card.
A runner that cannot be honoured (Claude not enabled, no prompt_args, a
command that is not claude/claude-jb, LiteLLM disabled, an unserved
profile or a tier the profile lacks) is refused with the reason; it never falls
back to the default. Orchestrators run jailbee inbox runners first to see
the configured default and available profiles and tiers. Omitting runner on
spawn starts the default. They may choose a cheaper tier for routine,
well-specified work or a stronger tier for hard or open-ended work, following
the user's model guidance; when in doubt they use the default. They keep the
default's agent and profile unless the guidance says otherwise. A restart
without runner keeps the child's runner; --default-runner selects the
current default. A refusal is reported to the human, not retried with another
model. Without a configured default, the command shows default: omit runner
and the launch agent, or unknown when that agent is not known.
The broker¶
The broker is a systemd user service, jailbee-broker.service. jb init /
jb setup install and enable it, and jb orchestrate grant starts it if it
is not active. It is always on: every ~5 seconds it checks each running
orchestrator and child for new manifests, and with no running orchestrator or
child it slows to one check every 60 seconds. jb doctor reports whether the
unit is active. Container-creation workers it starts outlive a broker restart.
When a different jailbee version is installed, the broker finishes its round
and exits with status 75, and systemd starts it again on the new code;
make install restarts it as well, which also covers a reinstall of the same
version. A unit written by an older jb setup restarts it too, but logs the
exit as a failure until jb setup rewrites the unit.
The broker logs to the user journal (journalctl --user -u jailbee-broker).
Each creation or restart worker it starts writes its own output to
~/.local/state/jailbee/logs/<container>-<timestamp>.log ($XDG_STATE_HOME
when set). A failed status whose worker left no error does not name that
file, since statuses reach the container; the broker log does.
For each manifest the broker validates it, acts on it at most once, deletes
it, and answers with a status message in the sender's inbox: queued,
started, done, failed or refused, with a detail. Spawn and restart
return started (or queued, then started), followed by done
(child `<short>` is ready; agent is alive) only after setup finishes and
the actual prompted process survives two observations at least two seconds
apart. Failure to start within 120 seconds after setup, an immediate exit,
or a failed boot/setup returns a final failed for the same ref. A child that
already reported done before readiness was confirmed still gets a final done
(child `<short>` is ready; it already reported done), so the ref always ends.
The broker continues monitoring that process. Any exit without an accepted
child done sends the orchestrator a blocked message, including exit 0,
nonzero exits and signals. The detail includes the exit status and signal
name when known, and up to 4096 characters from the last 30 tmux output
lines when the pane survives. If the wrapper itself or the container dies,
the broker reports that the process disappeared; an unavailable exit status
is explicitly unknown. Accepted done and successful broker stop/destroy
end monitoring; a restart starts a fresh run. External kills/stops are not
silenced. A refused done does not finish the run. Transient probe errors
are retried, not misreported as death. Existing children need a broker
restart request to acquire the new supervision. At most 32 manifests per
container are read per round.
A live prompted agent that waits for input without accepted done also
triggers an idle message. The default threshold is 15 minutes, configured
per repo with orchestration.idle_after_seconds (positive seconds, default
900); the same interval controls repeats. The body names the child, elapsed
idle duration, last accepted child message kind and first line (at most 240
characters, or none), plus the same bounded tmux tail as blocked when
available. Read that message and answer the child; this is not completion
and does not authorize restarting an agent that is still alive.
The broker matches its prompted PID and process start to the account
adapter's session, currently Claude. waiting is input required; idle
counts only with readable transcript evidence and no subagent history.
Claude transcripts have no reliable subagent-finished marker, so even old
subagent files conservatively suppress plain idle; a genuine waiting
state still reports input required.
busy and shell do not count. Adapter or probe failures and unsupported
agents are unknown, not idle. An unchanged pane is deliberately not used:
a silent tool call could still be working. For those agents the separate
message-silence watchdog remains available. The episode resets on activity
or accepted child messages; timestamps survive a broker restart. Accepted
done, successful broker stop/destroy and agent-exit blocked end it. An
accepted child-sent blocked suppresses idle until its next accepted message.
A refused done does not suppress monitoring. wait only filters these
messages; it neither detects idle nor configures the threshold.
The broker is non-interactive. Anything that would prompt the operator (the autostart escalation gate for an untrusted source, an autofetch that needs a hardware-key touch) is refused with that reason; the broker never answers a prompt for you. Messages for a stopped recipient wait in the broker's on-disk queue and are delivered when it runs again, also across broker restarts.
Inside each orchestrator and child, /home/dev/.jailbee/orchestration/ holds
outbox/ (container to host), inbox/ (host to container) and, for an
orchestrator, grant.json, a read-only copy of its limits. The broker creates
both directories in every orchestrator and child it serves. New images include
jailbee inbox (wait, ls, read, runners, children, send), so agents
need not write JSON by hand. jailbee inbox children [--json] lists each child
from the broker's children.json (it must be fresh, at most 15 minutes old): one
line per child with its name, whether its container is running, its latest agent
session (- if none) and whether its done report was accepted; --json prints
the raw list. For an old image without the container CLI, run
jb base build on the host and recreate the container. After wait, run ls and handle every
unread message. JAILBEE_INBOX_SETTLE controls the quiet window after an arrival
(default 1 second, 0 disables), capped at 5 seconds and the wait timeout;
messages already present return immediately. Use send --wait for spawn,
update and destroy, and always for a child's done report: it consumes only
that manifest's status, returning 0 for done/queued/started, 1 for
refused/failed, 2 for usage errors and 3 for timeout (default 30m).
For send --wait and ordinary wait, --timeout none waits without a
deadline; send --wait --timeout none never exits 3.
For lower-noise orchestration, use the helper directly, not a wrapper that
reads and discards progress notes:
jailbee inbox wait --wake-on msg:done,question,blocked,idle,status:failed,status:refused --quiet-after 60m --timeout 30m
For a background orchestrator waiter, use --timeout none only together with
--quiet-after so silent children still wake it.
--wake-on accepts comma-separated message kinds (note, question, done,
blocked, host-generated idle) and status states (queued, started, done, failed, refused).
done is both a kind and a state, so a bare word matches either: a plain
done also wakes on the delivered to <child> status ack that every
inbox send message gets. Prefix a token to say which you mean: msg:<kind>
matches only a message (including the broker's idle/blocked reports),
status:<state> only a status reply; an unknown kind or state after a prefix is a
usage error (exit 2). The recommended filter above wakes on child reports and
on failed or refused manifests, but not on successful acks, which send --wait
already returns.
It returns the oldest matching unread path; nothing is marked read, even
when it does not match. Omit the filter for any unread message. The first
message from a child after each spawn/restart always wakes, regardless of
kind; read history prevents later notes from counting as first again.
After waking, list and handle all unread messages, including filtered notes.
--quiet-after returns exit 0 with quiet-child CHILD instead of a path when
a running child that has not reported done shows no activity for that duration.
Activity is the latest of a message from the child (read or unread), an agent
event, a commit on the child's branch and CPU use at or above 5% of one core,
taken from the status object below (the broker's own idle/blocked reports
from child.short count as messages from the child, for LAST-MSG too); with
no activity, time is measured from the child's start. A child whose status is missing (an older broker) is timed by
messages alone. Stop or restart resets monitoring, and an overdue child keeps
waking until activity or a lifecycle change. The broker publishes read-only
children.json: per child its name, running (the container state), done (an
accepted done; a done child may keep running until it is stopped or
destroyed), start time, latest agent session and status. After the broker
itself completes a stop, destroy, restart or spawn, it republishes the snapshot
before sending the reply. Quiet monitoring
watches running children that are not done. Requested quiet monitoring exits 2
if the snapshot is absent, malformed or older than 15 minutes; check the
broker. Without quiet monitoring, filtering still works if that snapshot is
missing, but the first-report override needs fresh child state.
--digest DURATION makes wait exit 0 printing digest on the first line and
the jailbee inbox status table after it, when at least DURATION has passed
since the last digest and a child is running and not done. At most one digest
per period is emitted for all children together; the last time is stored
atomically in ~/.jailbee/orchestration/digest-at, and the first digest of a
fresh container is due DURATION after its first wait --digest. A real message
that is due at the same time wins and the digest follows at the next wait; a
due digest ignores --wake-on. A due real message and a quiet-child
wakeup both win over the digest. A missing, unreadable or future-dated stamp is
created (reset) to now, which is not due, even with no running child; with a
stale stamp the digest is due as soon as a child next runs, and with no running
child an existing stamp is left unchanged. Like --quiet-after, it needs a
fresh children.json (exit 2 otherwise).
Child status¶
jailbee inbox status [--child C] [--json] shows how each child is doing
without asking it. It reads children.json (same validation and 15-minute
staleness rule as children) and prints one line per child:
NAME STATE AGENT ACTIVE CPU MEM HEAD DIRTY LONGEST LAST-MSG. STATE is
running, stopped or done (done wins); AGENT is alive:<state>, starting
(the agent has not been launched yet: create or restart still running), dead
(a recorded agent is gone) or ?; ACTIVE is the age of activity_at; LAST-MSG shows the age of the
last message from the child (↑, from its own inbox files) and the last
delivery to it (↓). Unknown values print ?. --json prints the items as
published plus last_from_child_at. An unavailable or invalid snapshot, or an
unknown --child, exits 1; a usage error exits 2. A stale (but valid) snapshot
only warns on stderr and still prints with exit 0, unlike wait --quiet-after
and wait --digest, which exit 2.
Each child item in children.json carries status (an object for a running
child; for a stopped one {"head", "last_to_child_at"}, either value possibly
null (a stopped child that was never observed or messaged has status: null;
version stays 1 and every field is additive and optional, so older and newer
readers and brokers interoperate):
| Field | Meaning |
|---|---|
observed_at |
Epoch seconds the object was last refreshed. |
agent |
{"alive", "state", "since", "last_event_at", "last_tool"}: the prompted agent's liveness and session state (currently Claude). alive is null with state: "starting" while no agent has been recorded yet. |
cpu_percent |
Container CPU over the last sample, in % of one core. |
memory_bytes |
Container memory in bytes, net of page cache. |
longest_command |
{"command", "seconds"}: the process name (not the command line) of the longest-running non-shell process a shell started below the agent, excluding the agent and any jailbee inbox wait or inbox send --wait waiter (recognised by its command line, e.g. python -m jailbee.container_main inbox wait); null if none or the agent is unknown. |
head |
{"sha", "time", "subject"} of the newest commit on the child's branch (committer time), or null without commits. |
dirty |
Uncommitted changes in the work tree, or null if unknown. |
last_busy_at |
Last time cpu_percent was at least 5. Kept across broker restarts. |
last_to_child_at |
Last time the broker accepted a message, update or restart for the child. |
activity_at |
Latest of agent.last_event_at, head.time, last_busy_at and the start time; never decreases for one child instance. Child-supplied times later than now + 5 s are ignored by the broker, and wait ignores an activity_at later than now + 5 s. |
Host-side fields, including the agent session fields, refresh at most every 30
seconds per child; the liveness probe (agent_watch.probe) and the git probe
run at most every 60 seconds; between refreshes the last values are republished
with their observed_at. A session that cannot be read keeps the last known
last_event_at. A stopped child publishes only head and last_to_child_at
(each null when unknown; the whole status is null only for a child never
observed running or messaged). Commit subjects and process names come from the
child and are untrusted: they are cut to 120 and 160 characters and control
characters are replaced, in the broker and again when printed.
cpu_percent and last_busy_at are container-wide, so autostarted background
services (a JVM, a database, a file watcher, dockerd) can keep
--quiet-after from ever firing for that child.
One ordinary waiter per container is enforced with a process lock (a second
exits 2). It may overlap send --wait: the sender reserves its status ref
before publishing the manifest, and ordinary wait skips those statuses while
the sender lives. Locks release on exit/crash/timeout; leftover lock files do
not reserve anything. ls and explicit read remain administrative operations:
do not read a pending sender's reply in another session. Ordinary wait never
consumes a reply. After a send timeout, a late reply is eligible again.
For multi-session work, spawn with --on-done keep; update --child C
brings the parent's latest committed branch into the existing child. Add
--ref REF to send a full ref or 40-hex commit from the parent's repo, or
--base BRANCH to send a host-resolved base without switching the parent's
branch. The two options are mutually exclusive. Plain update transports a ref
for the child to merge manually; use --merge when the child is idle or between
tasks to check for conflicts and apply it automatically. A clean fast-forward
advances HEAD, divergent histories create a merge commit, and an already-contained
source leaves HEAD unchanged. The broker refuses a stopped or mount-mode child,
detached HEAD, dirty worktree, merge in progress, or conflicts, including gitlink
conflicts conservatively even when Git might resolve them. There is no idle gate,
so coordinate with the child. An update message with merged_head means the
branch was already updated: the child must re-read files, not merge the
imported_ref again. Prefer a fresh fork for rework so it starts from the latest
merged state.
Lifecycle¶
- Spawn.
kind: fork(from the orchestrator's committed work) orkind: new(from a base), plus the prompt and rights. Only running children count againstmax_running_children; a child being created counts as running. During creation, a child counts from thestartedreply until it is running and its create has finished, or its create has failed. Beyond the limit the spawn is queued in order, reported asqueued at position N, and started when a slot frees. A queued fork pins its source commit when it is queued. - Done. A child reports
donewhen finished. If its work tree is dirty or its HEAD is detached, the broker refuses withuncommitted changes(ordetached HEAD) and does not relay it. Otherwise the broker fetches the branch to the host, imports it into the orchestrator, then deliversdonewithimported_refandimported_sha. Merge that ref yourself. The defaulton_done: destroyremoves the child after delivery and its status reply;on_done: keepleaves it available for another session. A grant withoutdestroydefaults tokeepand refuses an explicitdestroypolicy. Import failures arrive asimport_error, keep the child, and can be retried withimportafter fixing the cause. When the import succeeded but the child is kept anyway (the grant lostdestroy, or the destroy marker could not be set), the report carrieson_done: keepandon_done_reasoninstead. - Import.
importbrings a running child's branch into the orchestrator atrefs/jailbee/from/<child>/<branch>, without merging. The orchestrator merges or cherry-picks from that ref itself; conflicts are its to solve. A stopped child is refused ("restart it first"). The orchestrator needs a branch and must not be a mount-mode container; its tree may be dirty or have an in-progress merge because the transport only writes refs and objects. - Update.
update(allowed by theimportoperation) brings the orchestrator's current branch into a running child atrefs/jailbee/from/<orchestrator>/<branch>. The child receives anupdatemessage withimported_ref/imported_sha. Without--merge, the child merges that ref itself. With--merge, a message withmerged_headmeans the branch was already updated: the child re-reads files and must not mergeimported_refagain. Optionalrefselects a fullrefs/name or 40-hex commit in the parent's repository, with spawn's validation; optionalbaseselects a host branch using spawn--new --baseresolution (includingnew.autofetch). They cannot be combined. Explicit sources land atrefs/jailbee/update/<orchestrator>/<manifest-id>, never overwriting the parent's current-branch ref or a child's imported ref. Without--merge, no worktree, index, local branch or base anchor is moved by the broker. Detached parent HEAD is refused only when neither selector is supplied. - Stop a child; restart a stopped child with a new prompt (it counts against the limit and is queued if the limit is full). A running child is refused ("stop it first").
- Destroy (needs
destroyin the grant) works on a running or stopped child; a donekeepchild needs onlydestroy, notstopfirst. It is refused for uncommitted changes or commits never imported: HEAD must equal the last import. Unreadable status also keeps the child. A child's starting commit counts as imported, so one that never committed is destroyed withoutforce. A stopped child's HEAD is the one recorded when the broker stopped it; a child stopped any other way is refused until restarted.force: trueoverrides the guard. - Messages go both ways with replies (
note,question,blocked,done). A child can only message its orchestrator, and an orchestrator only its own children.
If the orchestrator is stopped, done waits as an ordered finish item:
import, delivery and cleanup run when it returns. If it is destroyed, the
child is kept and told the report cannot be imported; the fetched branch
remains on the host. A child that commits or edits after done is also kept
instead of destroyed, with a notice to the orchestrator.
Typical workflows¶
In both, one child takes one whole unit of work: a ticket or a plan. Inside its own container the child is free to use its own subagents for the steps of that unit; the orchestrator never splits a plan's tasks across children.
A bug backlog. The user asks the orchestrator to work through clear bug
tickets on GitHub. The orchestrator reads them with the read-only gh,
spawns one child per ticket (fix/issue-<n>) whose prompt carries the
ticket text and the acceptance tests, and lets each child investigate, fix
and test. Each done arrives after auto-import; the orchestrator merges its
imported_ref, while default cleanup frees the slot for the next ticket. Forking
each new child from the orchestrator's latest merged HEAD keeps later
fixes from conflicting with earlier ones.
A series of plans. The user hands over a higher-level plan that orders
several implementation plans (for example written with the superpowers
writing-plans skill), some independent and some depending on others. The
orchestrator spawns a child per plan whose dependencies are met, one per
free slot. When a child is done it merges the already-imported result, resolves
any conflicts itself, commits, and only then forks the plans that depended
on it, so they start from the merged work. It continues step by step until
the higher-level plan is complete.
A child inherits only git commits, so a plan must reach it either committed
in the repo or in a directory every container sees through a host mount
(host_mounts), such as a shared .local/. A plan in a gitignored
directory without such a mount is not there in the child; it would have to
travel in the prompt, which is capped at 64 KiB. When children share such a
directory, each should write only its own files in it.
Skills¶
Two skills teach the agents the protocol, so they cooperate without help:
jailbee-orchestrationfor the orchestrator: readinggrant.json, writing a good child prompt, choosing child rights, the inbox loop, importing, merging and destroying children, and finishing with a PR.jailbee-orchestration-childfor the child: that it is a worker, how to sendnote,question,blockedanddone, and that everything must be committed beforedone.
They are bundled in docs/skills/ and copied to every enabled agent by
jb apply and jb new, like the other bundled
skills.