# Orchestration: sub agents on the multiplexer screen Status: implemented in taicpp, not in the Python app. Code: `src/mplex/mplex_vpane.*`, `src/orch/`, `src/agent/registry.*`, `src/agent/agent.*`, `src/cap/cap_mention.*`. Tests: `tests/orch_test.cpp` (`build/orch_test`, step `orch.unit`), `tests/orch_mplex_e2e.py` with `tests/orch_live.cpp` (`build/orch_live`, step `orch.mplex_e2e`). Research: `research/background-work-limits/` (agent teams, Temporal timeouts, claim check, Celery limits, tmux layouts, terminal streaming) and `research/cap-protocol/`. Related: [cap-agents.md](cap-agents.md) (what `@agent` runs), [mplex.md](mplex.md) (the multiplexer), [fuzzy.md](fuzzy.md). ## 1. The rule The main application orchestrates. Every sub agent it runs, whatever kind (an internal `fork` worker, a CAP session over any binding, an `@agent` mention), gets a panel on the main multiplexer session screen while it works, fed live, and is supervised by one clock whether it is shown or not. ``` one sub agent two sub agents three +-------+-------+ +-------+-------+ +-------+-------+ | | agent | | | agent1| | | agent1| | main | 1 | | main +-------+ | main +-------+ | | | | | agent2| | | agent2| | | | | | | | +-------+ +-------+-------+ +-------+-------+ | | agent3| +-------+-------+ ``` The main pane stays on the left at full height, `MPLEX_VPANE_MAIN_PERCENT` (55) percent of the window wide. Sub agents are stacked equally on the right (tmux `main-vertical`). Panels appear when an agent starts and leave `ORCH_LINGER` seconds after it finishes; the column then rebalances. Outside a multiplexer (plain terminal, tmux) nothing is drawn and everything else works unchanged. How the application gets into the multiplexer: the installed shell hook runs `tai --plexer-wrap` for interactive shells. A bare `tai` run is not wrapped automatically (changing how the program starts was not asked). Inside the multiplexer every pane has `TAI_PLEXER_ACTIVE=1`, `TAI_PLEXER_SOCKET` and `TAI_PLEXER_PANE`, and that is how the orchestrator finds its screen. ## 2. Architecture | Layer | Class | File | Job | |---|---|---|---| | server | `MplexVpanes` | `src/mplex/mplex_vpane.*` | feed panes: open, write, close, list; layout; linger; orphan lease | | server | `MplexPane::virtual_pane` | `src/mplex/mplex_pane.*` | a pane without a process; row 0 is a header | | server | `MplexRenderer::frame` | `src/mplex/mplex_renderer.cpp` | draws the header row and the screen below it | | transport | `OrchTransport`, `OrchMplexTransport`, `OrchNullTransport` | `src/orch/orch_transport.*` | control-socket client, one round trip per write | | core | `OrchBoard` | `src/orch/orch_board.*` | opens panels, coalesced flusher thread, watchdog clock | | core | `OrchPanel` | `src/orch/orch_panel.*` | one agent's buffer, state, counters, cancel hook | | view | `OrchFormatter` | `src/orch/orch_formatter.*` | events to sanitized, word-wrapped ANSI text | | events | `OrchEvent` | `src/orch/orch_event.h` | what an agent tells its panel | | adapter | `OrchCapAdapter` | `src/orch/orch_cap_adapter.*` | `cap.*` events to `OrchEvent` | | adapter | `Agent::observer` | `src/agent/agent.*` | tool calls, results and streamed text of an internal worker | | owner | `spawn_agent` / `AgentRecord` | `src/agent/registry.*` | opens a panel per `fork` worker, wires cancel, finishes it | | owner | `CapMention` | `src/cap/cap_mention.*` | opens a panel per CAP turn, supervises, escalates cancel | | policy | `orch_policy.h` | `src/orch/orch_policy.h` | every cap and floor (section 9) | Data flow for one streamed token: ``` agent thread --emit(OrchEvent)--> OrchPanel (format, append to buffer, mutex) OrchBoard thread, every 25 ms: panel.collect(op) for every panel --> one "vpane_write" with all ops --> multiplexer server --> pane.feed() --> screen server render loop, at most every 12 ms: diff by row --> attached terminal ``` Producers never wait for I/O. If the screen cannot keep up the buffer is bounded (section 4). ## 3. The multiplexer side: feed panes A feed pane is an `MplexPane` with `virtual_pane = true`: no pty, no process, `master_fd` is -1, so the server loop never polls it and no reaper touches it. Its `h - 1` rows are a normal `MplexVtScreen` (scrollback, copy mode, `capture-pane`, `read` all work); row 0 of its rectangle is a header line drawn by `MplexRenderer::frame` with the pane's `header_style`. Keys typed into it are dropped (`MplexPane::write` returns early for a feed pane). ### 3.1 Control protocol (JSON lines on the multiplexer socket) All requests are one line, one reply line, one connection per request, like the existing `send` and `read` commands. Replies are `{"ok": true, "data": ...}` or `{"ok": false, "error": "..."}`. **`vpane_open`** ```json {"cmd":"vpane_open","pane":"0","label":"agent #1","header":" ● agent #1 · running · 0s","state":"running","main_percent":55} {"ok":true,"data":{"id":3,"cols":53,"rows":38}} ``` `pane` is the origin pane (default: the active pane of the latest session). `label` becomes the pane title, `header` the first line, `state` selects the header color. Errors: `too many feed panes (limit 8)`, `too many panes (limit 256)`, `no space for another feed pane` (the window is too short for another pane of at least `MPLEX_VPANE_MIN_ROWS` rows), `window too narrow for a feed pane` (under 41 columns). **`vpane_write`** ```json {"cmd":"vpane_write","ops":[ {"pane":3,"data":"\u001b[1mhello\u001b[0m\r\n","header":" ● agent #1 · running · 4s","state":"running"}, {"pane":4,"data":"bye\r\n","state":"done","done":true,"linger":20}]} {"ok":true,"data":{"closed":[],"cols":{"3":53,"4":53}}} ``` Per op: `pane` (required), `data` (text for the screen, at most `MPLEX_VPANE_WRITE_MAX` = 65536 bytes), `header`, `state` (`running`, `idle`, `done`, `failed`, `timeout`, `cancelled`), `done` (start the linger countdown) with `linger` seconds (clamped to `MPLEX_VPANE_LINGER_MAX` = 300). At most `MPLEX_VPANE_OPS_MAX` = 16 ops per request. Every op also refreshes the pane's lease. The reply lists in `closed` the pane ids that no longer exist (the user closed them or they timed out) and in `cols` the current width of every pane written to, which the client uses for word wrap. Errors: `ops must be a list`, `too many ops in one write`, `op data too large`. **`vpane_close`** closes one pane now. **`vpane_list`** returns `[{id, state, header, cols, rows, closing}]`. ### 3.2 Layout policy (`MplexVpanes::arrange`) After every open and every close the window is rebuilt with `MplexLayout::preset("main-vertical", panes, ...)`: - the first element is the main pane (the first real, non-feed pane of the window); - then any other real panes the user made; - then the feed panes in creation order. The main width is `window width x percent / 100`, clamped to [1, width - 2]; when the right column would be narrower than `MPLEX_VPANE_MIN_COLS` (20) the main pane gives up width to keep it. Heights are shared equally by the existing `shares` rule (the first pane gets the remainder), for example `[13, 13, 12]` for three agents in a 40-row window (the heights plus two border rows). The layout is re-applied on every open and close, so a layout the user set by hand does not survive a panel change. ### 3.3 Lifecycle on the server | Event | Behavior | |---|---| | `done` op | `close_at = now + linger`; the pane closes on the next tick after that and the column rebalances | | user kills a feed pane (`prefix x`) | the pane is gone; the next write reports its id in `closed`; the orchestrator cancels the job | | owner stops writing for `MPLEX_VPANE_LEASE` (30 s) | the header becomes `owner gone: ...`, state `failed`, closes after `MPLEX_VPANE_ORPHAN_LINGER` (10 s) | | the main pane exits | `MplexServer::kill_pane` closes the whole window when only feed panes remain | | window zoomed | opening a panel un-zooms it | The server tick runs once a second, so linger and lease have one-second granularity. ## 4. The orchestrator side ### 4.1 `OrchBoard` One per process (`OrchBoard::instance()`, created on first use over `OrchMplexTransport::from_env()`, flushed at exit with a 2 s limit, never destroyed so detached worker threads cannot outlive it). `open(label, task)` never returns null: when there is no multiplexer (`TAI_PLEXER_ACTIVE` unset), `TAI_ORCH_PANELS=0`, no space, or `ORCH_PANELS_MAX` panels are already shown, it returns a panel with no pane behind it and records the reason (`last_error()`). Such a panel behaves identically except nothing is drawn. The board thread runs every `ORCH_FLUSH_INTERVAL` (25 ms): 1. once per `ORCH_MONITOR_INTERVAL` (1 s) call `watchdog` on every panel; 2. call `collect` on every panel; combine the non-empty results into one `vpane_write`; 3. apply the reply: widths to the panels, `closed` ids to `closed_by_user()`; 4. drop panels that are drained. Three consecutive failed writes mark the transport broken (`transport_ok() == false`, reason in `last_error()`): drawing stops, supervision continues, new panels are hidden panels. ### 4.2 `OrchPanel` Thread-safe. State: `Queued`, `Running`, `Idle`, `Done`, `Failed`, `Timeout`, `Cancelled`. ``` emit() finish(state) Running ------> Running Running/Idle ----------> Done | Failed | Timeout | Cancelled | no event 60 s ^ emit() while Idle returns to Running v Idle --- no event 300 s ---> cancel hook fires once any --- pane closed by user ---> Cancelled, cancel hook fires ``` - `emit(event)` formats the event, appends to the buffer, counts tools and tokens, and is a heartbeat. After `finish` events are ignored. - `finish(state, summary)` flushes the held word, appends the footer, and arms the `done` op, which is sent only after the buffer is drained. - `collect(op)` takes up to `ORCH_WRITE_MAX` (32768) bytes without splitting a UTF-8 character, adds a changed header and state, and returns false when there is nothing to send. A word held back by the formatter is released when the text stream has paused for 0.1 s. - The buffer is capped at `ORCH_PENDING_MAX` (262144). Over it, the oldest bytes are dropped at a line end and a visible line `output skipped: the screen fell behind` is inserted. Dropping is counted (`dropped_bytes()`). - `watchdog(now)` is the idle logic above; `set_cancel` registers the owner's cancel function, called at most once. Header text: `