Status: living document. Records the structural decisions behind the multi-host layout (taskflow-core / taskflow-mcp-core / taskflow-hosts / pi-taskflow / codex-taskflow / claude-taskflow / opencode-taskflow / grok-taskflow), the trade-offs that were considered, and the direction to take as the host count grows.
Written as the output of the PR #26 (claude + opencode hosts) architecture review. This is not a changelog — it captures why the structure is what it is and what should change next.
The engine (taskflow-core/src/runtime.ts) is host-agnostic. It speaks
only to the SubagentRunner contract (runTask → RunResult). Adding hosts (codex, claude, opencode, grok, + pi) changed zero lines of engine code. Any
future restructuring MUST preserve this seam — it is the single reason a host
SDK breaking change cannot force an engine release.
Consequence: the spawn/classify/idle-watchdog boilerplate that every host
runner needs MUST stay funneled through the single runSubagentProcess in
runner-core.ts. A new host that copy-pastes its own process lifecycle is a
bug waiting to happen (the original 3-way copy-paste already diverged on
contextTokens). One process lifecycle, ever.
Implemented. Non-pi host runners (codex / claude / opencode / grok) live
in a single taskflow-hosts package. Delivery packages
(codex-taskflow / claude-taskflow / opencode-taskflow / grok-taskflow) keep their npm
names, install paths, version pins, and plugin scaffolds, and import their
runner from taskflow-hosts; each also re-exports the runner so its existing
public surface (import ... from "codex-taskflow") is unchanged. A new host
lands as one <host>-runner.ts in taskflow-hosts, not a whole new
runner-owning package. Grok Build shipped this way (grok-runner.ts +
grok-taskflow delivery + .grok-plugin scaffold).
codex-taskflow, claude-taskflow, opencode-taskflow, and grok-taskflow are each published as a separate npm package. This was reasonable at 1 host (codex) and tolerable at 3–4 delivery adapters. The review's concern: at ~10–15 hosts this becomes the dominant maintenance cost.
There is a real reason adapters look like independent packages: each is a
host ecosystem's delivery artifact (codex plugin add taskflow@taskflow,
npm i -g codex-taskflow, an MCP server a user points their client at). But
there is no reason their release cadence is independent — adapters almost
never change except when taskflow-core's contract changes or a host CLI
changes its flags. Today all nine packages are lockstep versioned at the
same number, which makes a per-package semver meaningless: a codex flag fix
forces a new version of the untouched core engine.
| 5 hosts (now: pi + 4 MCP) | 12 hosts (projected) | |
|---|---|---|
| npm publishes per release | 8 | 8 (+ thin delivery only if install brand requires it) |
package.json to keep version-pinned |
8 | ~8 |
| README/CHANGELOG package rows | 8 | ~8 |
npm names consumed (*-taskflow delivery) |
4 | 4+ only when a host needs its own install brand |
Keep the three existing packages for backward compatibility (their names
are already in codex plugin add, npm i commands, user configs). Do NOT
create a new package per future host. Instead, introduce a single
taskflow-hosts package that re-exports all host runners + their MCP bins
from one published unit:
taskflow-hosts
├─ codex-runner.ts ← lives here directly
├─ claude-runner.ts ← lives here directly
├─ opencode-runner.ts ← lives here directly
├─ grok-runner.ts ← lives here directly (Grok Build)
├─ <future-host-a>.ts ← lives here directly
├─ test/ ← all host arg-contract + parser tests
└─ index.ts ← barrel re-exporting the host runners
- Current publishes:
core → mcp → hosts → pi/codex/claude/opencode/grok(8 publishes). Future runners ship insidetaskflow-hosts; new delivery packages only when the host needs a branded install (grok plugin install,codex plugin add, …). - Future hosts ship in
taskflow-hostsand are discovered vianpx -p taskflow-hosts <host>-mcpor static import. - The three legacy packages can later become thin re-exports of
taskflow-hosts(deprecated in their READMEs) without breaking the install commands users already have.
✅ Done at 3 hosts (the moment adoption is lowest, so the migration is
cheapest). taskflow-hosts now exists; Grok Build was added as the 4th
non-pi runner without a second process-lifecycle copy — future hosts go here.
- A unified
HostConfiginterface / generic command-builder. Each host's argv genuinely differs (codex pastes the prompt; claude uses--append-system-prompt; opencode usesprovider/modelids where codex/claude/grok use flat ids; permission models are sandbox vs--allowedToolsvs--autovs Grok's--tools+--always-approve). Forcing these into one interface produces an abstraction with a per-host parameter for every flag — more complex than the short pure builders it replaces. Instead: each host owns a pure, exportedbuildXxxArgsbuilder (extracted in this PR) that is independently unit-tested. Shared shape, not shared code.
The executor e2e suites (e2e-codex.mts, e2e-claude.mts, e2e-opencode.mts, plus e2e-grok-mcp.mts for MCP)
spawn a live host CLI and need auth + spend tokens, so they never run in
CI. That left each host's argv construction (the --json / --format json /
--output-format stream-json flags, the permission→flag mapping, the
model-id resolution rules) completely untested in CI. A host renaming a
flag would only be caught by a user at runtime.
Each host runner exposes a pure buildXxxArgs(ctx) (no process.env, no
spawn) plus its already-pure permission/model helpers, and these are covered by
*-args.test.ts files that run in the normal CI unit glob. The tests pin:
- the exact leading flags (
exec --json --skip-git-repo-check,-p --output-format stream-json --verbose --strict-mcp-config,run <prompt> --format json); - the permission mapping (read-only vs mutating whitelists → the right flag);
- model-id resolution (flat vs
provider/path vs{{placeholder}}→ pass-through vs drop); - bin resolution (default +
PI_TASKFLOW_*_BINoverride).
The live e2e suites still exist (they verify the event-stream parser against real captured fixtures and the real handshake), but the flag contract is now CI-checked. A flag rename trips a unit test, not a user.
- Dropping the live e2e suites. They remain the only thing that catches a host
changing its event-stream JSON schema (which
buildXxxArgscannot see). Two layers: unit (flag contract) + manual e2e (stream schema). Neither replaces the other.
Cross-host debugging ("why did my flow fail?") previously had only
taskflow_peek (phase output) and a 64KB-capped raw stderr per child. With 4
hosts, each child's stderr has a different CLI prefix (codex exec, grok -p, claude -p, opencode run), making it hard to tell which agent/bin produced a given
error blob.
runSubagentProcess emits a structured header
[taskflow:run] agent=<name> bin=<bin> model=<model> args=[...] to the
host process's stderr, gated by PI_TASKFLOW_RUN_LOG=1 and default-off.
- It is never written to stdout (stdout is the JSON-RPC channel for the MCP server; polluting it would break the protocol). This is pinned by a test.
- Default-off so it adds zero noise to a normal run; an operator turns it on when debugging.
- Lives in the one shared
runSubagentProcessso all hosts get it for free — no per-host logging code to drift.
runner-core.tslives intaskflow-core. It is host-neutral (no host SDK import — onlynode:child_process), so keeping it in core does not violate the "core has zero host-SDK deps" rule. Moving it out (e.g. intotaskflow-mcp-core) would force every host adapter to depend on a second package for no benefit.- Skill generation is single-sourced (
skills-src/+build-skills.mjs+ a drift-guard test). New hosts add oneentry.<host>.mdand extend the comma host list; the skill body is shared. Do not per-host the skill body. - MCP server is its own package (
taskflow-mcp-core) — a pure presentation layer over core. Pi users never pull MCP code. This boundary is correct. - Lockstep versioning is kept for now (all nine packages share a version).
It is crude but it is less work than tracking which subset of packages need
a given bump, and at 9 packages the cost is still low.
taskflow-hostsandtaskflow-dslexist; the next revisit is when core genuinely needs to move on its own cadence independent of host CLI flag churn.
| Layer | What | Runs in CI? |
|---|---|---|
| Unit | parsers, builders, permission/model helpers, verify, interpolate, cache | yes (*.test.ts glob) |
| Shared-process | runSubagentProcess spawn/idle/abort/classify |
yes (runner-process.test.ts) |
| Arg-contract | each host's buildXxxArgs flag contract |
yes (added in this PR) |
| E2E (stream schema + live handshake) | real host CLI over stdio | manual (needs auth/tokens) |
Full suite is ~1090 unit tests in ~25s on CI across node 22 + 24. Adding a host now costs ~12 arg-contract tests (a few ms), not a new e2e job. This scales fine to ~20 hosts.