@persistent-ai/fireflow-console (0.29.0)
Installation
@persistent-ai:registry=npm install @persistent-ai/fireflow-console@0.29.0"@persistent-ai/fireflow-console": "0.29.0"About this package
@persistent-ai/fireflow-console
The FireFlow Console Protocol — RFC 077.
A terminal session described as messages, not as an implementation. The test this package has to
pass is that someone outside this repository can write a working client, or a working server, from it
— without reading TerminalWorkflows.ts.
The constraint that defines it
Zero runtime dependencies beyond zod. No DBOS, no Redis, no Postgres, no React, and no
dependency on fireflow-bash, fireflow-nodes or fireflow-types.
That is the point rather than an aesthetic: it is what makes this describable as a protocol instead of an extract of our server. Two places where it bit:
| shape | producer | how it is declared here |
|---|---|---|
ConsoleProgressPayload |
BashRunEvent in fireflow-bash |
{ type: string, atMs: number }, structurally. Every BashRunEvent variant assigns to it; a consumer wanting the full union narrows on type, which it would have done anyway |
AgUIEventLike |
AgUIEvent in fireflow-nodes |
Record<string, unknown> & { type: string, timestamp?: number } — the bare, envelope-free wire shape, declared rather than imported |
Modules
session.ts |
identity, coordinate, SessionState, the ff://workspace@ref/path parse rule. The base module — imports nothing else in the package |
events.ts |
the event union a client receives, and NoticeCode |
commands.ts |
what a client sends, what comes back, and the session lifecycle |
intents.ts |
the IntentFrame union — the frames a followed execution streams back |
cursor.ts |
entry ids, ordering, resume, and the eviction marker |
format.ts |
the three output projections, and the normative frame renderers |
capabilities.ts |
what a client can read about the server it reached |
session.ts declares intentKindSchema, which the ff grammar attaches to each command and the bash
segmenter reads to decide whether an invocation needs workflow-level DBOS primitives. It lives in the
base module so the dependency graph stays a DAG: everything else imports it, and it imports nothing.
What RFC 077 §18.3 left open, and what was decided
Gap 1 — IntentFrame enumerated. Three variants: exec-run (a followed execution's header and
footer lines), exec-node (its per-node lines), and agui (one AG-UI event, per decision D2). The union
is open — an unrecognised type parses as UnknownIntentFrame — but the fallback explicitly
refuses the type names this version knows, so a malformed exec-run is a loud error rather than a
silently unrenderable "unknown" frame. Run and node statuses are separate vocabularies: a run
completed or did not (completed | failed | cancelled | timed-out), a node produced a value, threw,
or was never reached (ok | error | skipped).
Gap 1 is closed for one producer. Only a flow execution has frames defined; nothing describes what
a VFS watch or an MCP call would send, so the package cannot yet tell a client what to expect from
those. intents.ts says so at the point where a reader would otherwise assume more.
Gap 3 — responses and session lifecycle. Extracted, not invented: every shape is lifted from the
terminal router 076 shipped. fork is the exception — 076 §13 lists it and no server implements it,
so the shape is specified and a server advertises sessions:fork only when it has it.
Gap 4 — cursor gaps. Run entries keep {seq + 1}-{index} unchanged. Entries that belong to the
session rather than to a run — session-state — take {head + 1}-{1000000 + n}, a reserved band of
the index component anchored to the run in progress or the last one to finish. Eviction is marked by a notice with
code: 'transcript_gap', synthesized at read time; a notice rather than a new event kind because §15
says a conforming client drops an unknown kind, which would reproduce the exact silence the marker
exists to break.
Two rules that point in opposite directions
Shapes a server sends are passthrough; shapes a client sends are strict. A v1 client parsing a
v2 event or frame keeps the fields it does not understand, because §10 specifies --format=json as
"full structure, stable keys" and a stripping parse would emit a lossy object into someone's jq.
A v1 server parsing a client request strips, because ConsoleCommand must drop a client-supplied
seq — otherwise a stale tab chooses where in the chain it lands. Input is narrowed, output is
preserved.
Chunking is a protocol rule, so chunkText is in this package. text.slice(at, at + 32768) cuts
UTF-16 code units, so an emoji straddling the boundary becomes two lone surrogates and the first
serialisation to UTF-8 replaces both with U+FFFD — permanently, in the durable transcript. Every
producer calls the same function rather than reimplementing the same bug.
Gap 2 — a handshake and protocol versioning — is deliberately out of scope and must not be built.
There is exactly one client, shipped from this repository and deployed alongside the server, so a
protocol bump is a coordinated deploy and a negotiation has nothing to negotiate with.
ConsoleCapabilities already lets a client read what it reached, which is the half that pays for
itself now. Fixing the other half before a second implementation exists to constrain it would be
guessing at a shape.
Also not here
- A wire transport. tRPC over WebSocket carries this today; SSE, a raw socket or an MCP tool would carry it equally well.
- A terminal emulator protocol. No TTY, no pty, no ANSI negotiation, no window-size escapes, no job-control signals.
TerminalShellStateand the prelude/epilogue capture. Those are facts aboutjust-bash, so they stay infireflow-bash/src/session. The split is by ownership, not by convenience.- Command-surface projections beyond frames.
ff flow show,ff psand the rest project their own structures and belong to RFC 080.
Dependencies
Dependencies
| ID | Version |
|---|---|
| zod | ^3.25.76 |
Development Dependencies
| ID | Version |
|---|---|
| @persistent-ai/typescript-config | 0.29.0 |
| @types/node | ^25.5.0 |
| vitest | ^4.1.0 |