Orchestration & Session Lifecycle
Omnigent's bet is unusual: instead of one agent that spawns clones of itself, it lets different vendors' coding agents — Claude Code, Codex, Cursor, Pi, Goose, and your own — collaborate inside one shared session. An orchestrator agent plans and delegates; sub-agents do the coding; one agent reviews another's work; and humans can join the same session live from any device. This doc traces how that works end to end.
The recurring shape is worth naming up front: everything is a child
session. A delegation, a code review, even a web_fetch, is the parent
posting a message to a child conversation and waiting on an inbox. There
is no separate "task" abstraction — sub-agents are conversations, which
is what makes them shareable, resumable, and forkable like any other.
1. The Orchestrator / Sub-agent Model
An Omnigent agent is a short YAML file: a prompt, some tools, and
optionally a set of sub-agents the agent can delegate to
(docs/AGENT_YAML_SPEC.md:261-293). A sub-agent is just another tool of
type: agent with its own executor.harness and model — so an
orchestrator can mix vendors by role (a cursor coder, a claude-sdk
reviewer), and a sub-agent can pass_history: true to inherit the
parent's transcript (docs/AGENT_YAML_SPEC.md:264-277).
The reference orchestrator is Polly
(examples/polly/config.yaml). Polly runs on the claude-sdk harness
(:27-32) and has one hard rule baked into its prompt: it writes no code
itself — all coding work is delegated (:33-42). It declares exactly
three sub-agents (:271-278):
| Sub-agent | Harness | Role |
|---|---|---|
claude_code |
claude-native |
primary implementer (multi-file / refactor) |
codex |
codex-native |
primary implementer (narrow, scoped) |
pi |
pi |
headless review / explore specialist |
How a delegation actually fires
Delegation is a single tool call: sys_session_send
(examples/polly/config.yaml:89-108). The runner classifies it as a
sub-agent tool (omnigent/runner/tool_dispatch.py:200-203) and dispatches
it through _execute_subagent_tool
(omnigent/runner/tool_dispatch.py:985-1011). That function:
- Creates or reuses a child session on the server (POST
/v1/sessions), with the parent forced asparent_conversation_id(omnigent/runner/tool_dispatch.py:205-209, 1965-1967). - Registers a runner-local launch entry and posts the child's first message.
- Returns a launching handle immediately — the parent does not block.
- On child completion, runner turn-end bookkeeping pushes a completion
payload into the parent's
sys_read_inboxqueue (omnigent/runner/tool_dispatch.py:991-994; delivery inomnigent/runner/app.py:6250-6316) and POSTsinbox_awakento wake the parent if its turn was sleeping (_schedule_subagent_wake,app.py:11321, 11406).
So the loop is async by construction. Polly dispatches a fan of workers,
ends its turn, and is automatically woken when any sub-agent finishes —
the prompt is emphatic that busy-polling the inbox is a bug
(examples/polly/config.yaml:88-94, 210-220). Waiting on sub-agents is
the framework's job.
Every sys_session_send must carry a human-readable title (the task
label shown in the UI's Subagents panel) and an args.purpose — one of
implement / review / explore / search
(examples/polly/config.yaml:95-108). The title is also load-bearing
mechanically: the server enforces a partial unique index on
(parent_conversation_id, title), so reusing a title continues the same
child conversation, while a fresh title spawns a new worker with no memory
(omnigent/runner/tool_dispatch.py:1993-1997; cross-review relies on this at
examples/polly/skills/cross-review/SKILL.md:38-41).
There are two spawn paths. Declared sub-agents go through
sys_session_send. With spawn: true (examples/polly/config.yaml:11-16)
the orchestrator also gets sys_session_create
(omnigent/runner/tool_dispatch.py:205-209) — it can launch an existing agent by id, or
author a custom agent YAML and launch it via config_path. Both count
against the per-turn fan-out cap (see §6).
Everything is a child session
web_fetch is implemented by translating its query/url into a
sys_session_send against a built-in __web_researcher sub-agent
(omnigent/runner/tool_dispatch.py:1955-2004). The same primitive — post to a child
conversation, await the inbox — backs delegation, review, and web
research. One mechanism, many features.
For your project — Swisscheese:
This is the meta-harness shape Swisscheese needs: a planner brain that
never touches code, a typed purpose on every dispatch, and an
async inbox so the orchestrator scales a fan of parallel workers without
turn-by-turn babysitting. The (parent, title) uniqueness index is a
clean way to make "continue this worker" vs. "spawn a new one" an explicit
caller decision.
2. Cross-agent Review — the Writer → Reviewer Pattern
This is the Swisscheese pattern, and Omnigent wires it as a first-class
workflow. The rule, from Polly's prompt: review is ALWAYS done by a
different vendor than the implementer
(examples/polly/config.yaml:141-147). Claude Code's PR is reviewed by
Codex or Pi; Codex's by Claude Code or Pi.
The procedure lives in a skill, cross-review
(examples/polly/skills/cross-review/SKILL.md):
- Get the diff —
gh pr diff <pr>orgit -C .worktrees/<id> diff(:13-14). - Run deterministic gates first — tests / lint / typecheck. If red,
re-dispatch the implementer to go green; don't involve the reviewer
yet (
:15-17). - Dispatch a different-vendor sub-agent with
purpose: "review", passing only the diff + the acceptance contract as text — never a pointer to the implementer's worktree or transcript (:18-33, 55-56). - The reviewer surfaces issues; it does not fix them (
:34). Only the implementer ever opens a PR, so a stray reviewer edit can never reach the deliverable (:57-60). - Each blocking issue becomes a fix-task sent back to the same
implementer conversation (reuse its
title/session_idso it keeps its worktree/branch context), then loop (:35-41). - When gates are green and zero blocking issues remain, the PR is ready —
the human merges; Polly never does (
:42-44).
flowchart LR
O[Orchestrator<br/>polly · claude-sdk] -->|sys_session_send<br/>purpose=implement| I[Implementer<br/>claude_code]
I -->|opens PR + worktree| PR[(diff + contract)]
O -->|sys_session_send<br/>purpose=review<br/>DIFFERENT vendor| R[Reviewer<br/>codex / pi]
PR -->|diff text only| R
R -->|blocking issues<br/>via inbox| O
O -->|fix-task to SAME<br/>implementer convo| I
R -.->|clean| H[Human merges]
Three design choices make the independence real:
- Diff-only handoff. The reviewer sees the diff and the contract,
nothing else. No transcript, no worktree. Cross-vendor independence is
"the whole point" (
examples/polly/skills/cross-review/SKILL.md:55-56). - Reviewers can't edit. They report; only implementers open PRs. A
stray edit never reaches the deliverable (
config.yaml:143-147). - Vendor diversity is a hard precondition. Cross-review needs two
available workers from different vendors. If the roster preflight
(
config.yaml:62-74) leaves only one vendor that can review a given PR, Polly refuses to fake it — it says so and pulls in the human at the plan gate (config.yaml:149-154;examples/polly/skills/cross-review/SKILL.md:49-54).
The same writer→critic shape appears in Debby, where every question
fans to a Claude head and a GPT head and /debate has them critique each
other for a few rounds (README.md:223-227), and in Scribe, which
routes a draft through an independent reviewer to fact-check claims before
shipping (README.md:229-234). Scribe's reviewer is deliberately on the
Codex harness while Scribe's brain is claude-sdk, "so it catches claims
the author's own model would miss," and it is "only ever dispatched with
purpose: review and reports rather than edits"
(examples/scribe/agents/reviewer/config.yaml:8-9, 16, 20, 55). The
agent reads files to verify claims against the real code but never writes
them — the same diff-in, report-out contract as Polly's code reviewer.
For your project — Swisscheese: Omnigent has independently arrived at Swisscheese's core thesis — reviewing AI-generated code is the hard problem, and the reviewer must be a different model than the writer. The concrete tactics to borrow: pass the reviewer only the diff + contract (not the writer's reasoning, so it can't anchor on it), make reviewers structurally incapable of editing, and treat single-vendor availability as a refusal condition rather than silently letting the writer grade itself.
For your project — EU AI Act compliance platform: The diff-only, reviewer-can't-edit handoff is an audit-traceable construction: the review artifact is a structured report keyed to a specific diff + contract, separate from the implementer's transcript. That separation is exactly what an Article 12 logging story wants — a reviewer's verdict that can't be confused with the author's work.
3. Worktree Isolation & Fan-out
Parallel-safe subtasks run through the fanout skill
(examples/polly/skills/fanout/SKILL.md). Each task gets its own git
worktree and branch (git worktree add .worktrees/<id> -b polly/<id>,
:11-15), one implementation sub-agent scoped to that worktree
(:16-29), and opens its own PR. The orchestrator records each
handle's conversation_id in a .polly/registry.json task list
(:11-15; config.yaml:223-225), dispatches the whole parallel-safe set,
then ends its turn — no polling (:16-29). Each finished PR then flows
through cross-review (:35).
Because Polly never merges, cross-PR conflicts surface at human merge time,
not during the run — which is why keeping each task's file scope disjoint
matters (examples/polly/skills/fanout/SKILL.md:55-57).
Investigation is delegated the same way: the investigate skill answers
read-only questions by dispatching explore/search sub-agents and
synthesizing only from their structured reports
(config.yaml:110-117, 193-201) — the orchestrator is forbidden from
sprawling reads of the codebase itself.
4. The Shared Session: Sharing, Co-driving, Forking
A session is a server-hosted conversation. The split that makes collaboration work: the runner owns the agent subprocesses and is pinned to the conversation; clients (CLI REPL or web UI) only post turns and read the event stream.
Server ↔ runner: the WS tunnel
Each conversation is bound to exactly one runner via
conversations.runner_id (omnigent/runner/routing.py:88-115). The server
routes a turn to that runner over a WebSocket tunnel
(routing.py:242-260, WSTunnelTransport). Binding a runner is
owner-only, and dispatch never picks or persists a runner itself
(routing.py:88-105). Capability is checked at routing time — a runner
must advertise the harness in its hello frame or the dispatch fails with
RUNNER_CAPABILITY_MISMATCH (routing.py:235-239, 263-274). This is why
a Claude-Code session can't be resumed onto a host that only has Codex.
Real-time sync
Clients receive live updates over a per-session Server-Sent Events
stream: GET /v1/sessions/{id}/stream
(omnigent/runner/app.py:207, 8576). Under it sits a small in-process
pub-sub broadcaster (omnigent/runtime/session_stream.py): subscribe()
hands each connected client its own ephemeral queue
(omnigent/runtime/session_stream.py:41-48), and publish() fans one event to every
active subscriber of a conversation, thread-safely via
call_soon_threadsafe from the sync workflow thread
(omnigent/runtime/session_stream.py:52-59). The runner emits through a single
_publish_event chokepoint threaded through every surface — new
terminals, sub-agent discovery, harness output
(app.py:925, 1123, 1370, 1476, 6991). So "messages, sub-agents,
terminals, and files stay in sync" across laptop + phone + a teammate's
browser (README.md:29, 288) is literally one SSE feed fanned to every
viewer. Note the wire carries no sequence numbers and events emitted with
no subscriber connected are dropped — reconnecting clients re-read a
snapshot rather than replaying (omnigent/runtime/session_stream.py:52-59).
Child sub-agent status and preview deltas are also mirrored onto the
parent's stream (app.py:7011), so the orchestrator — and anyone
watching it — sees live progress on every worker.
Presence (who's watching)
Multi-user sessions get Google-Docs-style presence circles
(omnigent/server/presence.py). Presence is scoped to the session
tree's root conversation, not the individual conversation
(omnigent/server/presence.py:4-10): two users — one looking at the orchestrator, one
looking at a sub-agent of the same session — still appear in each other's
viewer list. A leave-grace window of 15s absorbs proxy reconnects so an
avatar doesn't flicker on a transient drop (omnigent/server/presence.py:22, 43-45).
Co-drive (omnigent attach)
omnigent attach <session_id> joins a live conversation as a pure
co-drive client (omnigent/chat.py:513-532). The contract is precise:
attachis a pure co-drive client: it never launches OR binds a runner. Turns post to the runner the host already bound (POST /v1/sessions/{id}/events) … post-only is what makes cross-user co-drive work. (omnigent/chat.py:525-532)
A teammate's messages execute on your machine (the host runner),
exactly like the web UI's co-drive (README.md:340-346). A read-only
preflight confirms the host runner is online — attach can't start one,
and fails loud if the host is offline (omnigent/chat.py:530-532, 548-558). Who
may co-drive vs. only watch is a permission level: grants are
(user_id, conversation_id, level) triples with 1=read, 2=edit, 3=manage (omnigent/stores/permission_store/__init__.py:4, 30-47), and
the web UI gates sending behind permission_level >= 2
(ap-web/src/shell/Sidebar.tsx:898). A __public__ sentinel grants
anonymous read-only access (omnigent/stores/permission_store/__init__.py:44).
Turn ordering is the real co-drive primitive. With multiple humans
and the agent itself all posting to one conversation, who runs next?
Inbound messages take a monotonic arrival sequence and then wait at a
FIFO gate until every earlier message has made its turn-vs-buffer
decision (omnigent/runner/app.py:6919-6928,
_ingest_now_serving / _ingest_cond). The invariant is one active turn
per session: while a turn runs, later messages are buffered, not dropped
and not allowed to jump the gate. A slow-to-resolve message can't be
overtaken by a fast one that arrived later — ordering follows arrival,
not content-resolution latency. That single serialization point is what
lets several drivers share one message log without a locking protocol.
Share
sys_session_share (omnigent/runner/tool_dispatch.py:217-225) mutates session
permissions via PUT /v1/sessions/{id}/permissions. An agent can only
grant a share if its spec's agent_session_sharing policy allows it
(non-public or public, omnigent/runner/tool_dispatch.py:234-239); a public share
uses an anonymous read-only sentinel (_PUBLIC_USER_SENTINEL,
omnigent/runner/tool_dispatch.py:227-232). Crucially, the server can't see the agent's
sharing policy, so the runner is the gate — the share tool is only
exposed when the spec opts in.
Fork
omnigent run --fork <id> clones a conversation and continues
independently from the fork point (README.md:348-353). The CLI calls the
fork endpoint before entering the REPL so the user lands in the fork
(omnigent/chat.py:3944-3957); the transport exposes fork(session_id, at_message_id=...) returning a new session id
(omnigent/native_server_transport.py:158-160). Server-side,
fork_conversation deep-copies the source conversation and its items into
a fresh top-level conversation atomically in one transaction
(omnigent/stores/conversation_store/__init__.py:1048); the caller needs
only read access on the source, and the owner gets full permission on the
fork (omnigent/server/routes/sessions.py:14408, 14535). A fork can
switch agents — even to a different vendor — and optionally truncate
history at a chosen response id (omnigent/server/routes/sessions.py:14437-14451, 14387).
Fork directives ride as labels rather than columns:
omnigent.fork.source_id marks the unbound clone,
omnigent.fork.source_external_session_id carries the source's native
session uuid for transcript cloning, and omnigent.fork.carry_history
tells native targets to rebuild the transcript
(omnigent/stores/conversation_store/__init__.py:23, 37, 54). Native replay only works
for harnesses that support it (claude/codex carry history; cursor/pi
rebuild from Omnigent items), and a cross-vendor fork skips the source
native session entirely because the transcript format wouldn't match
(omnigent/server/routes/sessions.py:14481-14492). The native wrappers have matching plumbing —
Claude's rotates the bridge session and reseeds the forked transcript
(omnigent/claude_native_forwarder.py:1859, 1967, 2021).
flowchart TB
subgraph clients[Clients - any device]
L[Laptop REPL<br/>owner · binds runner]
P[Phone web UI]
T[Teammate browser<br/>co-drive · post-only]
end
SRV[(Omnigent server<br/>conversation store<br/>SSE: /sessions/id/stream)]
subgraph host[Host machine]
RUN[Runner<br/>pinned: conversations.runner_id]
ORCH[Orchestrator brain]
SA1[claude_code worktree A]
SA2[codex worktree B]
REV[pi reviewer]
end
L -->|owner binds + posts| SRV
P -->|posts turns| SRV
T -->|POST /events only| SRV
SRV <-->|WS tunnel| RUN
SRV -.->|SSE fan-out| L
SRV -.->|SSE fan-out| P
SRV -.->|SSE fan-out| T
RUN --> ORCH
ORCH -->|sys_session_send| SA1
ORCH -->|sys_session_send| SA2
ORCH -->|review · diff only| REV
SA1 -.->|inbox completion| ORCH
SA2 -.->|inbox completion| ORCH
REV -.->|inbox report| ORCH
5. Resume & Continuation Across Devices and Restarts
Resume is dispatch-by-runtime. omnigent resume <conv_id>
(omnigent/resume_dispatch.py:39-87) fetches the conversation, reads its
omnigent.wrapper label, and routes to the matching native wrapper
(_dispatch_wrapper, omnigent/resume_dispatch.py:201-309) — Claude, Codex, Pi,
Cursor, Kiro, Goose, Antigravity, Qwen, Kimi, Hermes each have a
run_*_native(server, session_id, args) entry point. With no id, the
cross-agent picker lists prior conversations across all agents
(omnigent/resume_dispatch.py:69-145).
The wrapper label is read from a local SQLite store
(SqlAlchemyConversationStore, chat.db,
omnigent/resume_dispatch.py:312-336) or, with --server, via GET /v1/sessions/{id} (omnigent/resume_dispatch.py:339-394). This is the same
label-as-metadata scheme that drives the whole lifecycle: native sessions
carry omnigent.wrapper and a UI-mode label so they render terminal-first
(omnigent/native_coding_agents.py:30-44).
Cold resume vs. reattach
Two outcomes when resuming a terminal-native session
(omnigent/_native_resume_hint.py:60-95):
- Reattach — the host runner is still live and the TUI still running; the client reconnects to it.
- Cold resume — the terminal exited. Some wrappers captured a
resumable chat id and reload the prior conversation into a fresh TUI
(
restored=True, e.g. Cursor); others can only relaunch a fresh agent with none of the prior turns (restored=False). The hint tells the user which (omnigent/_native_resume_hint.py:84-95).
Session close
Closing a session marks it closed to new user input via an
omnigent.closed=true label (omnigent/session_lifecycle.py:7-9, 70-86). Legacy rows encoded this as a title suffix
(":closed:conv_..."); the API synthesizes the label from the suffix for
old rows so clients and write-guards see a uniform signal
(omnigent/session_lifecycle.py:46-67).
Delivery safety (the cross-device duplicate problem)
A subtle correctness pitfall worth noting: native forwarders mirror
transcript items into the server as external_conversation_item POSTs,
which persist with a random primary key and are not deduped
server-side (omnigent/_native_post_delivery.py:1-18). So a blind retry
after an ambiguous transport failure (request sent, response lost) would
append a duplicate bubble in the web UI. post_may_have_been_delivered
(omnigent/_native_post_delivery.py:38-65) classifies failures: HTTP-status and
connect-establishment errors are safe to retry; any "sent but no response"
error is ambiguous and not retried for conversation items
(omnigent/_native_post_delivery.py:108-126). The native tmux pane is unaffected,
which is why the duplicate is web-only.
For your project — Swisscheese: The non-idempotent-event problem bites any multi-client harness that mirrors a local terminal into a shared web view. Omnigent's answer — classify the failure, only retry the provably-undelivered ones — is the right default for an event log that can't dedupe. Worth copying verbatim if Swisscheese mirrors agent output to a dashboard.
6. Guardrails on Orchestration
Polly's fan-out is bounded by runner-side policies (config.yaml:280-311):
| Guardrail | Mechanism | Where |
|---|---|---|
| Per-turn fan-out cap (5 dispatches) | spawn_bounds policy, counts sys_session_send and sys_session_create |
config.yaml:295-304 |
| Headless sub-agents must declare a valid purpose | headless_subagent_purpose_guard (implement/review/explore/search) |
config.yaml:305-311 |
| Day-long approval window | ask_timeout: 86400 (the runner previously hard-coded 120s) |
config.yaml:281-284 |
| Don't gate pushes, still DENY catastrophic ops | blast_radius with gate_pushes: false |
config.yaml:285-294 |
| Cancel a runaway worker | sys_cancel_task with the worker's conversation_id |
config.yaml:226-232, omnigent/runner/tool_dispatch.py:264-269 |
Cancellation honesty is encoded too: claude_code workers are
hard-stopped (and wake the parent with a cancelled inbox item), pi
honors the interrupt, but codex cancellation is "best-effort" — so the
prompt tells the orchestrator not to assume the worker is gone until the
inbox confirms it (config.yaml:226-232, examples/polly/skills/fanout/SKILL.md:48-51).
The orchestrator's read-only observability surface (peek / list / close /
get-info / share) all route through the server's REST endpoints because
the runner has no in-process conversation store
(omnigent/runner/tool_dispatch.py:211-225). sys_session_list maps to GET .../child_sessions (omnigent/runner/tool_dispatch.py:2591, 3289-3321).
7. Things to Learn From / Pitfalls
Good ideas:
- Sub-agents are conversations, not a bespoke task type. Delegation,
review, and
web_fetchall reduce to "post to a child session, await the inbox." One primitive makes sub-agent work shareable, resumable, and forkable for free. - Cross-vendor review as a hard rule, refused when impossible. The reviewer must be a different model, sees only the diff + contract, and can't edit. Single-vendor availability is a refusal, not a silent degrade.
- Owner binds, everyone else posts. Co-drive falls out of a clean permission split: only the owner binds a runner; teammates POST events. No locking protocol, no leader election — the runner is the single serialization point.
- Labels as lifecycle state.
omnigent.wrapper,omnigent.closed, UI-mode — resume, close, and rendering are all driven by cheap string labels on the conversation row, readable locally or over REST. - Delivery classification for non-idempotent event logs. The "ambiguous failure → don't retry" rule is a small, correct answer to a problem most harnesses get wrong.
Pitfalls / risks:
- Hard runner affinity. A conversation is pinned to one runner; if the
host is offline you can't dispatch or co-drive, only fork or read
history (
routing.py:109-115, 135-151). Resilience costs a re-bind, which is owner-only. (parent, title)uniqueness is subtle. Reusing a title silently continues a worker; a typo'd title silently spawns a fresh one with no memory. Powerful, but a sharp edge for prompt-authored orchestration.- No server-side dedup on mirrored items. Correct producers avoid duplicates, but any new forwarder that doesn't adopt the classify-then-retry discipline will double-post to the web view.
- Best-effort Codex cancellation. Until a runner-side hard-stop exists, a cancelled Codex worker may keep running — the orchestrator has to reason about it explicitly rather than trust the cancel.
- Orchestration logic lives in prose. Polly's entire delegation discipline — preflight, fan-out caps, "act in the same turn you announce" — is in a ~250-line system prompt and three skills, not code. Reviewable and editable, but the only thing stopping the brain from writing code itself is the prompt plus a purpose-guard policy.