Repository navigation
Description
Summary
nForma integrates Daintree as a quorum provider (nf:link-daintree → import MCP endpoint, register consensus agents). It has no notion of Daintree as an orchestration substrate: a persistent, addressable fleet of long-lived peer agents with roles, ownership and lifecycle.
I spent a full session running exactly that pattern by hand — one TEAMLEAD coordinating five IMPLEMENTERs across separate worktrees on a shared repo — and hand-rolled the tooling for it in bash. It worked, and the shape is general enough to belong in nForma rather than in every operator's scratch directory.
Evidence for the gap:
$ grep -rn "terminal\.\(sendCommand\|getStatus\|list\|inject\)\|agent\.launch\|worktree\.list" \
--include="*.cjs" --include="*.js" --include="*.md" . # excluding node_modules
(no matches)
commands/nf/link-daintree.md covers Discover → Import → Register, all scoped to userAgentRegistry for quorum. The Daintree MCP surface it connects to also exposes terminal.list, terminal.getStatus, terminal.getOutput, terminal.sendCommand, terminal.waitUntilIdle, terminal.waitUntilIdleBatch, agent.launch, worktree.list, worktree.getCurrent, recipe.run — none of which nForma uses.
The distinction that matters
nForma already has two multi-agent mechanisms, and this is neither:
| mechanism | agents | lifetime | addressable | shared state |
|---|---|---|---|---|
quorum (nf:quorum) |
N models, same question | one call | no | none |
| subagents / worktree executors | ephemeral children | one task | no | isolated worktree |
| this proposal | N peers, different goals | hours–days | yes, by name | same repo, same CI, same cluster |
The third row is where the interesting failures live, because the agents can invalidate each other's work and cannot see each other.
What I actually had to build
1. Session boilerplate — ~15 lines of bash per message
Every instruction required: initialize → scrape Mcp-Session-Id from response headers → notifications/initialized → build the JSON-RPC envelope with jq -n --rawfile → POST tools/call → strip SSE data: framing with sed → distinguish .error from .result.isError. I did this ~30 times.
That belongs behind one helper. Everything below is impossible to build on top of raw curl.
2. Workspace binding must be asserted, not assumed
The MCP session resolves a workspace that may differ from the one you passed. The correct check is:
.result._meta["org.daintree/resolved-workspace"].workspaceId == expected
An orchestrator that skips this can drive the wrong project's terminals. This should be enforced once at connect, not re-implemented per caller.
3. terminal.sendCommand has a state-dependent footgun
From its own description: "a shell runs it as a command, an agent pane receives it as the next prompt." One of my panes had an exited agent and was a bare shell. Sending a multi-paragraph instruction there would have executed it. Nothing in terminal.list distinguishes "pane with a live agent" from "pane whose agent died"; agentId stays populated either way. I only caught it by reading scrollback and noticing a bare ❯.
nForma should refuse to send prompt-shaped text to a pane it cannot confirm is agent-backed.
4. agentState is ambiguous in the way that matters
waiting covers both idle at a prompt, free for work and blocked awaiting a specific input. I recommended a pane for a task on the strength of waiting, and the operator corrected me: it was blocked on a merge authorization. The distinction is only recoverable by reading and interpreting rendered TUI scrollback.
5. Context headroom is only available as pixels
To decide which agent could absorb a large review payload I parsed 75% (752K) out of the rendered status line with a regex. That is a real scheduling input — a payload dropped into a 93%-full context gets compacted away — and it should be structured data, not scraped chrome.
6. Ownership mapping is manual and breaks silently
I maintained pane → worktree → branch → PR → objective by hand. When the operator renamed the panes mid-session, my cached mapping was silently stale; I only caught it because I re-inventoried on principle. Ownership is the thing that makes "who should do X" answerable, and nothing tracks it.
7. Nothing propagates state changes to dependents — this one caused a real stall
I merged two PRs an IMPLEMENTER was blocked on, under authority delegated to me, and did not tell it. It sat blocked on an authorization request that had already been satisfied. Its reasoning was sound throughout; the world moved underneath it and no channel existed to say so.
An agent blocked on a satisfied condition is indistinguishable from one blocked on a live one, and only the mutator knows the difference. This is the single highest-value thing to make intrinsic.
8. Cross-agent correlation has to be deliberate
Two IMPLEMENTERs hit byte-identical CI failures on branches with zero shared code. Each diagnosed it independently as its own bug. Only the coordinator could see both. The generalisable rule: when two agents report failures in the same window, compare signatures before accepting either attribution. Nothing surfaces "these two agents just failed similarly."
Proposed surface
A nf:fleet capability, built on the Daintree orchestration tools, roughly:
nf:fleet-status— one call returning, per agent: id, role, worktree, branch, owned PRs/issues, agent-backed (bool), liveness, context headroom, current goal, blocked-on. Structured, not scraped.nf:fleet-send— deliver an instruction to an agent by role name, refusing if the pane is not confirmed agent-backed. Optionally fan-out with per-agent bodies.nf:fleet-assign— attach a durable objective (desired state, acceptance criteria, constraints, authorization boundary) to an agent without overwriting its/goal. Replacing a subordinate's goal destroys accumulated operator intent and should be an explicit, separate, confirmed action.nf:fleet-notify— announce a shared-state mutation (merge, base move, resolved blocker) to agents whose recorded objective depends on it. This is item 7 and is the one that pays for itself.nf:fleet-correlate— given recent failures across agents, group by signature and flag same-window/different-branch matches.
What I am not proposing
- Not a scheduler. Pacing is the orchestrator's judgement.
- Not authorization. Who may merge or deploy is policy, and inferring it from agent-visible text is precisely the mistake I made twice today. The fleet layer should carry provenance of a grant, never manufacture one.
- Not a replacement for
nf:quorumor worktree subagents. Different lifetimes, different problems.
Acceptance criteria
nf:fleet-statusreturns structured agent state including agent-backed and context headroom, with no TUI scraping in the implementation.nf:fleet-sendrefuses to send to a pane whose agent cannot be confirmed live — with a test that a bare-shell pane is rejected rather than executed against.- Workspace binding is asserted against
org.daintree/resolved-workspaceon connect; a mismatch is a hard error. - Assigning an objective does not mutate an existing
/goalunless explicitly requested. - Non-vacuity: mutating tracked shared state without notifying a dependent agent produces a warning. Test by recording a dependency, mutating, and asserting the warning fires — a version that never fires is not a guard.
Provenance
Written by the agent that ran the pattern, from a session of ~5 hours: 9 PRs merged, one 53-commit production deploy, 3 issues filed, 5 long-lived IMPLEMENTERs coordinated across separate worktrees. Every item above is something that actually cost time or produced a wrong action, not a speculative nicety. The bash I used is disposable; the shape it revealed is not.
Activity
jobordu commentedon Aug 18, 2026
Second full session on this pattern — five new failure modes, all measured
Ran the same TEAMLEAD/5×IMPLEMENTER shape again today (~7h, one repo, shared CI). Everything above
reproduced. Five things are new, and four of them are actuation failures rather than observation
failures — which I think is the sharpest way to frame what's missing.
6.
sent: trueis not delivery — and there is no other signalterminal.sendCommandreturned{"sent": true}for messages that reached nobody, in three distinctways in one session:
sent:truemeant❯)⚠
lastTransitionAtmoved in all three. The verifier suggested by the earlier comments — compare apre-send stamp — passes on a dead pane. The only reliable confirmation was an off-pane effect
(
git ls-remote origin | grep <branch>).Ask:
sendCommandshould return a delivery outcome distinguishable from an enqueue acknowledgement, orthe API should expose whether the pane has a live agent attached (this is #3 restated, but the failure is
in the return value, not only in the shell/agent distinction).
7. Pending input is invisible unless you guess the tail depth
Six instructions across four agents sat unsubmitted in
❯input boxes in a single hour. The fleetread as "idle, awaiting work" when it was "blocked on replies that never arrived" — which is #4's
ambiguity, but with a concrete and machine-readable cause sitting one field away.
The trap: the input box renders above the status bar, so it is only present in
getStatus(includeOutput.lines=N)for N ≳ 14. Atlines=5it is structurally invisible. I ran a scan at5 for an hour and logged
(empty)every time — every one of those readings was an artifact, not anobservation.
Ask: expose pending/unsubmitted input as a field (
pendingInput: string | null), not as text to berecovered by parsing rendered scrollback at a sufficient depth. Proof it is real:
8.
sendCommandREPLACES pending input, destroying it silentlyVerified: sending into a pane with a non-empty box submits the new text alone; the pending text is gone
with no error and no echo. So the safe procedure is read the box, then decide — but see #7, you cannot
read it without knowing the magic depth. Combined, these two make "retask the idle agent" a
data-destroying operation.
Ask: either refuse to overwrite non-empty pending input, or return the displaced text in the response.
9. Workspace binding must be asserted per call, not once at connect
The earlier note says "enforced once at connect." That is not sufficient — mine flipped mid-session,
between two calls in the same cycle, to an unrelated project that had its own
TEAMLEADandIMPLEMENTERpanes. A connect-time assertion would have passed and then been silently wrong..result._meta["org.daintree/resolved-workspace"].workspaceIdis returned on every response, so thecheck is free. It should be a client-level invariant that fails closed.
10. Duplicate pane titles make name-addressing unsafe
terminal.listreturned twoIMPLEMENTER3and twoIMPLEMENTER4. Addressing by title would have hita dead worktree pane; the live one was distinguishable only by joining
lastTransitionAtagainst now(4 min vs 1166 min). If this pattern is supported natively, panes need stable addressable identity that
is not the display title.
Reference implementation
I built the deterministic layer described here as a single-file CLI (
scripts/teamlead/tl.py, ~420lines, stdlib only) while running the session. It encodes each trap as a named guard with the incident
that motivated it, and every reading is a
Reading{valid, value, error}so instrument failure can neverbe mistaken for a domain value.
tl cycleis the one that matters for this issue: the orchestration loop is fundamentally "what changedsince last time, and does anything need me", and doing that from memory is where an orchestrator's
reliability actually degrades. Two guards in it caught real state within minutes of being written — a
mergeable_state: cleanPR with 0-of-5 required contexts present (clean meaning never checked), and anundelivered instruction on a live pane.
Happy to contribute it upstream in whatever shape fits, or to have it treated purely as a spec for the
native version. The specific asks in #6–#10 matter more than the code.