Drive any coding-agent CLI — Codex, Claude Code, Gemini, or Pi — from one orchestrator, with a Jira-style Kanban board rendered straight in your terminal.
symphony tui ./WORKFLOW.md opens a full-terminal Kanban board. Columns are
your tracker's states; cards show the active agent, turn count, last event,
and accumulated tokens. Live indicators: ● running, ↻ retry queued, ✓ done.
Plain-text version (for terminals viewing raw README)
agent=codex tracker=linear workflow=WORKFLOW.md lang=en running=2 retrying=1 │ tokens in=84,200 out=27,640 total=111,840
│ rate-limits=requests_remaining=4823, tokens_remaining=1.2M
╭── Todo (3) ──────╮ ╭── In Progress (2) ──╮ ╭── Review (1) ──╮ ╭── Done (2) ──╮ ╭── Archive (1) ──╮ ╭── detail ───────────────────────╮
│ DEMO-120 P1 │ │ DEMO-104 ● P1 │ │ DEMO-122 P3 │ │ DEMO-088 │ │ DEMO-074 │ │ DEMO-104 │
│ Migrate auth … │ │ Fix race condi… │ │ Doc: contri… │ │ Drop dead-… │ │ Old experim… │ │ Fix race condition in pagina… │
│ #backend … │ │ turn 4 20,180t │ │ #docs │ │ DEMO-091 │ │ │ │ │
│ │ │ Patched cursor… │ ╰────────────────╯ │ Bump deps… │ ╰─────────────────╯ │ state=In Progress │
│ DEMO-111 ↻ P2 │ │ │ ╰──────────────╯ │ runtime=running │
│ Refactor cach… │ │ DEMO-098 ● P2 │ │ turn=4 │
│ retry #2 tur… │ │ Add /api/sear… │ │ in=14,200 out=5,980 │
│ │ │ turn 2 11,310t │ │ total=20,180 │
│ DEMO-121 P2 │ │ Added token-bu… │ │ Patched cursor advance; │
│ Wire feature … │ ╰─────────────────────╯ │ running test suite... │
│ blocked by D… │ ╰─────────────────────────────────╯
╰──────────────────╯
q quit · r refresh · enter details · 1-9 zoom lane · t/T page lanes · d density · p detail-pane · L language · a archive · / filter · ?
A multi-agent fork of OpenAI's Symphony reference implementation. Upstream polls a tracker (Linear or a local Markdown Kanban) and runs a Codex session inside a per-issue workspace. This fork keeps that orchestrator and adds:
- A pluggable AgentBackend layer with four concrete adapters:
- Codex —
codex app-server(JSON-RPC stdio, multi-turn) — original - Claude Code —
claude -p --output-format stream-json --verbose(NDJSON events, per-turn subprocess with--resume) - Gemini —
gemini -p ""(one-shot per turn, stdin prompt → stdout result) - Pi —
pi --mode json -p ""(JSONL events, per-turn subprocess with--sessionresume; supports Anthropic / OpenAI / Gemini / Bedrock backends under one CLI — see pi.dev)
- Codex —
- A Jira-style CLI Kanban TUI built on Textual
that replaces the upstream server-rendered HTML dashboard. Columns are
tracker states; cards show the active agent, turn count, last event, and
accumulated tokens. Cards are focusable, the mouse wheel scrolls each lane,
and pressing
enteron a card opens a full-detail modal.
The orchestrator, scheduler, retry policy, workspace manager, tracker layer, and prompt renderer are unchanged from upstream — this fork is a thin layer on top of a battle-tested orchestrator core.
Set agent.kind in your WORKFLOW.md:
agent:
kind: claude # codex | claude | gemini | pi
claude:
command: claude -p --output-format stream-json --verbose
resume_across_turns: true
turn_timeout_ms: 3600000
pi:
command: pi --mode json -p ""
resume_across_turns: true
turn_timeout_ms: 3600000Each backend reads its own block (codex, claude, gemini, pi); only the
one matching agent.kind is used at runtime. The Codex linear_graphql
client tool is only advertised when agent.kind=codex.
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"Make the relevant CLI available on $PATH:
agent.kind |
required CLI on $PATH |
|---|---|
codex |
codex (with app-server subcommand) |
claude |
claude (Claude Code) |
gemini |
gemini (Gemini CLI) |
pi |
pi (Pi coding-agent — npm i -g @earendil-works/pi-coding-agent or curl -fsSL https://pi.dev/install.sh | sh; sign in once via pi → /login (OAuth, credentials cached at ~/.pi/agent/auth.json) — no env var needed) |
Want to see the TUI move cards around before installing codex, claude,
or gemini? Use the bundled mock backend — it speaks the same JSON-RPC
protocol as Codex but does no real work, just simulates turns and emits
token-usage ticks.
git clone https://github.com/cskwork/symphony-multi-agent.git
cd symphony-multi-agent
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
# WORKFLOW.md pointed at the mock backend
cat > WORKFLOW.md <<'YAML'
---
tracker: { kind: file, board_root: ./kanban,
active_states: [Todo, "In Progress"],
terminal_states: [Done, Cancelled, Blocked] }
polling: { interval_ms: 5000 }
workspace: { root: ~/symphony_workspaces }
hooks:
after_create: ": noop"
before_run: ": noop"
after_run: "echo done"
agent: { kind: codex, max_concurrent_agents: 2, max_turns: 3 }
codex: { command: python -m symphony.mock_codex }
server: { port: 9999 }
---
You are picking up ticket {{ issue.identifier }}: {{ issue.title }}.
YAML
symphony board init ./kanban
symphony board new TASK-1 "smoke test"
symphony tui ./WORKFLOW.mdWithin ~5 seconds TASK-1 grows a green ● indicator in the Todo column,
with a turn counter and token totals climbing. Quit with Ctrl-C when
you've seen enough; then proceed to the real walkthrough below.
Cards stay in their original column under the mock — only a real agent would rewrite
kanban/TASK-1.mdto move the card to Done. The mock exists to prove the orchestrator → backend → workspace → hooks pipeline end-to-end without an LLM call.
Tunables for the mock:
SYMPHONY_MOCK_TURN_SECONDS=12,SYMPHONY_MOCK_FAIL_EVERY_N_TURNS=3, etc. — seesrc/symphony/mock_codex.py.
Before launching, sanity-check your setup:
symphony doctor ./WORKFLOW.mdOutput (one line per check):
PASS server.port=9999 127.0.0.1:9999 is free
PASS agent.kind=claude claude → /usr/local/bin/claude
FAIL hooks.after_create contains placeholder 'my-org/my-repo' — every dispatch will fail with rc=128. Replace with a real clone target or `: noop`.
PASS workspace.root=~/symphony_workspaces exists and is writable
PASS tracker.board_root ./kanban (3 tickets)
Exit code is 0 when all checks pass, 1 if any FAIL, 2 if WORKFLOW.md
itself can't be loaded. The doctor catches the most common first-run
failures in one pass: port collision, missing CLI on $PATH, the shipped
placeholder clone URL, unwritable workspace, missing board directory.
This walks from a clean clone to a running ticket, using the file-based tracker and Claude Code as the agent.
symphony board init ./kanban
# → initialized board at ./kanban, sample ticket DEMO-001.mdEach ticket is one Markdown file with YAML frontmatter at kanban/<ID>.md.
The orchestrator only reads ticket files; the agent writes them when
it transitions state.
Use the file-tracker example (the other one, WORKFLOW.example.md,
points at Linear and needs an API key):
cp WORKFLOW.file.example.md WORKFLOW.mdThree blocks matter for first-run sanity:
tracker:
kind: file
board_root: ./kanban
active_states: [Todo, "In Progress"]
terminal_states: [Done, Cancelled, Blocked]
workspace:
root: ~/symphony_workspaces
hooks:
# Each ticket gets its own workspace at workspace.root/<ID>.
# after_create runs once when that workspace is created.
after_create: |
: noop # ← replace with `git clone …` for real work
before_run: |
: noop # runs before every agent turn
after_run: |
echo "run finished at $(date)"⚠ The shipped
WORKFLOW.mdusesgit clone --depth=1 git@github.com:my-org/my-repo.git .as a placeholder. If left unchanged, every dispatch fails immediately on the SSH clone (returncode=128,worker_exit reason=error). Either point it at a real repo or use: noopwhile you experiment.
symphony board new TASK-1 "Fix flaky pagination test" \
--priority 2 \
--labels backend,test \
--description "tests/test_pagination.py::test_cursor_advance is flaky on CI."
# → created kanban/TASK-1.mdInspect:
symphony board ls # all tickets
symphony board ls --state Todo # filter by state
symphony board show TASK-1 # full bodysymphony tui ./WORKFLOW.mdWithin one poll tick (polling.interval_ms, default 30s) the orchestrator
dispatches a worker, the card grows a green ● indicator (with turn counter
and token totals), and the agent runs. On success the agent rewrites
kanban/TASK-1.md to set state: Done and append a ## Resolution
section — that file edit is what moves the card from the Todo column
into Done. Quit with Ctrl-C.
Cards are placed in columns based on the ticket file's
statefield (tui.pyreads it on each tick). The green ● indicator is overlaid on top of the card and does not change which column it sits in. So a running ticket stays in Todo until the agent itself rewrites the file — that's by design (the orchestrator only reads ticket files; the agent owns writes).
The TUI needs a real terminal (TTY). If you launch it from a script / background process / non-interactive shell, the process exits silently — always run it in a foreground terminal.
symphony board show TASK-1 # the agent's ## Resolution lives in the body
ls ~/symphony_workspaces/TASK-1 # workspace it operated inSymphony writes structured logs to stderr only. To keep them around, redirect at launch:
mkdir -p log
symphony tui ./WORKFLOW.md 2>> log/symphony.log
# or, while running headless:
symphony ./WORKFLOW.md --port 9999 2>&1 | tee -a log/symphony.logThen tail -F log/symphony.log works.
symphony board mv TASK-1 Blocked # forces a state transitionThe orchestrator re-evaluates on the next poll tick. Manual transitions are
for unsticking — normally the agent transitions tickets itself per the
prompt instructions in WORKFLOW.md.
┌────────────┐ poll ┌──────────────┐ matches active_states
│ kanban/ │ ─────────▶ │ Orchestrator │ ─────────────────────────┐
│ *.md │ 30s tick │ (scheduler) │ │
└────────────┘ └──────────────┘ ▼
▲ │ ┌──────────────────┐
│ │ creates workspace │ Workspace │
│ agent writes ▼ │ ~/sym…/TASK-1 │
│ ## Resolution ┌──────────────────┐ │ + after_create │
│ + state: Done │ AgentBackend │ ◀────────────│ hook ran │
└───────────────────│ (codex/claude/ │ └──────────────────┘
│ gemini) │ │
│ per-turn loop │ before_run hook ──▶ turn(s)
└──────────────────┘ │
▼
after_run hook
Every artefact a ticket produces lives under docs/<TICKET-ID>/<stage>/. See docs/PIPELINE.md for the layout, what to commit, and the ${LLM_WIKI_PATH:-./llm-wiki}/ carve-out.
symphony ./WORKFLOW.md --port 9999JSON API endpoints (unchanged from upstream):
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/state |
Snapshot — running, retrying, totals, limits |
| GET | /api/v1/<identifier> |
Issue detail (404 with structured error) |
| POST | /api/v1/refresh |
Coalesced trigger of poll + reconcile |
The HTML dashboard at / from upstream has been removed in this fork; the
primary UI is the CLI Kanban below.
symphony tui ./WORKFLOW.md
# equivalent
symphony ./WORKFLOW.md --tuiColumns are tracker states (active_states first, then terminal_states).
Cards display issue identifier + title, priority, labels (or blockers), and a
runtime indicator:
- ● green — currently running, shows
turn N, last event, accumulated tokens - ↻ yellow — in retry queue, shows
retry #Nand the last error - ✓ green — completed in this session
Key bindings (also auto-listed in the footer):
| Key | Action |
|---|---|
q |
Quit (drains active workers cleanly) |
r |
Force a refresh + re-poll the tracker |
? |
Show all key bindings as a notification |
tab / shift+tab |
Move focus to next / previous card or lane |
j / ↓ |
Scroll focused lane down one row |
k / ↑ |
Scroll focused lane up one row |
space / pgdn |
Page down |
b / pgup |
Page up |
g / home |
Jump to top |
G / end |
Jump to bottom |
enter |
Open the focused card's full-detail modal |
esc / q |
Close the modal (when one is open) |
Mouse: clicking a card focuses it, the wheel scrolls its lane.
For developers who don't want to remember the full symphony tui invocation,
the repo ships two launcher scripts that prefer .venv/bin/symphony over
PATH, run symphony doctor first, then open the TUI in a new terminal
window:
./tui-open.sh # macOS / Linux — uses iTerm or Terminal.app
./tui-open.sh path/to/WORKFLOW.md # explicit workflow path
tui-open.bat # Windows — uses cmd /kBoth scripts abort the launch if doctor reports a FAIL so you do not paint
the alt-screen on top of unreadable preflight output.
If you don't have Linear, use the local Markdown-file tracker (unchanged from upstream):
tracker:
kind: file
board_root: ./kanbansymphony board init ./kanban
symphony board new DEV-1 "Title" --priority 2
symphony tui ./WORKFLOW.mdsrc/symphony/
backends/
__init__.py AgentBackend Protocol + factory + normalized events
codex.py Codex JSON-RPC stdio backend (was upstream agent.py)
claude_code.py Claude Code stream-json backend
gemini.py Gemini one-shot backend
pi.py Pi --mode json backend (per-turn subprocess, --session resume)
agent.py back-compat shim re-exporting backends.* symbols
workflow.py typed config — adds AgentConfig.kind + Claude/Gemini/Pi configs
orchestrator.py unchanged scheduler; uses build_backend() factory
tui.py Textual Kanban TUI (replaces server.py dashboard)
server.py JSON API only (HTML root removed)
cli.py adds `tui` subcommand / `--tui` flag
tui-open.sh cross-platform launcher (macOS / Linux): doctor preflight + open TUI in a new terminal window
tui-open.bat Windows equivalent
...
pytest -q205 tests pass (2 skipped): the upstream conformance suite plus the backend
unit tests covering the factory, event normalization, Claude / Pi usage
accumulation, Gemini session synthesis, and Pi failure-reason detection,
plus Textual Pilot-driven smoke tests for the TUI app. Subprocess-driven
integration tests against real CLIs are intentionally not in CI — run them
locally.
- Codex opens one
app-serversubprocess per issue and speaks the currentcodex app-serverJSON-RPC protocol (initialize+thread/startturn/start+ streamedturn/completedanditem/completednotifications). Multi-turn within one process. Olderv2/initialize-style releases are not supported — pin tocodex-cli ≥ 0.39(current upstream).
- Claude Code has no persistent server; sessions are tracked by ID. Each
run_turnspawns a freshclaude -pand uses--resume <session-id>from turn 2 onward. - Gemini CLI is one-shot per invocation with no native session model.
Each turn is independent; we synthesize a
gemini-<uuid>session id so the orchestrator's bookkeeping stays consistent. - Pi has no persistent server but auto-saves sessions to
~/.pi/agent/sessions/. Eachrun_turnspawns a freshpi --mode jsonand passes--session <id>from turn 2 onward. The session id is read from the first{"type":"session"}JSONL line; per-messageusageis accumulated offmessage_endevents, andagent_endis treated as the terminal event. Auth is delegated to Pi: the OAuth/API-key store at~/.pi/agent/auth.jsonpopulated by/loginis inherited by the subprocess, so Symphony itself never handles credentials.
The AgentBackend Protocol hides these differences. The orchestrator only
sees normalized events (session_started, turn_completed, turn_failed,
…) and the latest usage / rate-limit snapshots.
The board is observer-only: cards move when the agent rewrites the underlying ticket file (file tracker) or transitions the issue (Linear), never as a direct UI action. That matches the upstream design philosophy — the orchestrator is the source of truth and the UI is a thin reflection.
What you can do interactively:
- Focus any card with
tab/shift+tabor by clicking it. - Scroll a lane with the mouse wheel,
j/k, or page keys. - Open a focused card's full description in a modal with
enter.
What is intentionally out of scope:
- No card drag-drop. Move tickets via
symphony board mv ID State(file tracker) or in your tracker UI directly. - No agent-output log pane. Agent stdout/stderr goes to the structured
log; tail it with
tail -F log/symphony.login a side terminal. - No write actions to the tracker beyond what the agent does itself.
Inherited from upstream:
- SSH worker extension — single-host only.
- Persistent retry queue across process restarts.
- Tracker adapters beyond Linear and the file-based Kanban.
- First-class tracker write APIs in the orchestrator. Ticket writes still
happen through the agent (
linear_graphqlfor Codex, direct file edits for the file-based Kanban).
Fork-specific gaps:
- Claude Code's mid-turn streaming usage events are read but not surfaced;
the terminal
resultevent is the source of truth for token totals. - Gemini token usage is not reported by the CLI in stable form, so totals stay at zero for that backend.
- Multi-turn continuity for Gemini is not supported (no session protocol
exists in the CLI). Each
run_turnis independent.
PRs welcome. Before opening one:
pip install -e ".[dev]"
pytest -q # must stay greenBackend adapters live under src/symphony/backends/. Adding a new agent
(e.g. an Ollama-driven local model) means:
- implementing the
AgentBackendProtocol in a new module, - registering it in
build_backend()(src/symphony/backends/__init__.py), - adding a
<kind>Configdataclass toworkflow.pyand threading it throughbuild_service_config+validate_for_dispatch, - extending
SUPPORTED_AGENT_KINDS.
The bar for upstreaming a backend is: passes the existing factory + event
normalization tests, doesn't bleed protocol-specific types into the
orchestrator, and ships a default <kind> block in WORKFLOW.example.md.
This project is built on top of OpenAI's
Symphony reference implementation. The
upstream Apache-2.0 licensed work provides the orchestrator, the scheduler,
and the workspace lifecycle that make this fork possible. See NOTICE for
attribution details.
The TUI is built on Will McGugan's Textual framework, with rich used directly for text styling inside cards.
Pipeline stage rules adapt the evidence-first ideas of cskwork/backend-dev-skills (MIT).