@crow-agent/chord
v0.2.0
Published
Application-composition runtime: services, replicated state, RPC, and plugins for long-running TypeScript processes.
Readme
@crow-agent/chord
Chord composes applications out of plugins and extensions. It offers facets, services, replicated state, and a pluggable boundary for remote services. It ships as a standalone package inside the Crow monorepo, yet it is not a Crow package: nothing in it depends on another Crow workspace package, and unrelated applications can adopt it directly.
Why Chord exists
One feature often has to live in several places at once — an agent worker, a terminal UI, a remote web UI. Chord is the generic machinery for writing such extensions so they feel natural to humans and to agents.
The pieces fit together:
Plugins declare, synchronously, which services they provide and which they need. After every plugin has stated its shape, a host checks the whole dependency graph, wires services, starts providers before consumers, and tears resources down in reverse dependency order. These units go by the name facets.
Facets are the parts of a plugin. Each facet bundles separately and executes in the process or environment it targets. Facets let one plugin split across backends, browsers, TUIs, and other runtimes.
Services are typed, stable tokens. A service has one provider (singleton) or a set of dynamic keyed instances (keyed). It may be process-local with a free-form JavaScript contract, or remotely visible. Consumers hold a stable facade even as providers disconnect or get replaced.
Replicated state publishes authoritative state to connected local and remote consumers. Producers commit atomic overlay transactions via
change(context, callback); consumers observe complete immutable values. Draft proxies live only for the callback and die when it returns. Preparation builds a structurally shared immutable candidate plus one exact decoded op batch, while every remote client/state stream keeps its own path-codec state. Replicas go unready on disconnect or replacement until they rehydrate.Delta tracking records and merges operations over tracked plain JSON. It keeps common string and array edits, supports durable base batches, and validates untrusted operations during application. Batches converge but are neither canonical nor guaranteed minimal.
Remote service sources advertise services that live outside a facet host and open bindings for what its facets need. Bindings move logical calls and subscriptions through an adapter the application supplies. Chord demands strict-JSON arguments, results, snapshots, updates, and catalogues, but says nothing about framing, routing, transport, or the outer wire envelope.
JsonRepresentation<T>derives a wire-safe type for application data with unknown payloads;isJsonValue()checks values received at an adapter edge. Symmetric RPC peers are planned as one optional realization of this boundary.Context is Chord's Go-inspired system for cancellation and invocation-scoped application values. Permissions or telemetry can ride those values without Chord depending on either.
Today's runtime exports service tokens, singleton and keyed providers, remote
bindings, replicated state, facet hosts, and facet loaders from
@crow-agent/chord. Take public types and general runtime APIs from the package
root. Context constants and functions live in @crow-agent/chord/context so their
generic names stay out of the root API. Chord-owned identifiers use the
chord.* namespace; its reserved service prefix is $chord.*.
Writing remote service adapters
Chord owns a transport-independent grammar for its service wire. Consumer
adapters build $chord.service control calls with
createServiceCatalogueCall(), createServiceSubscribeCall(), and
createServiceUnsubscribeCall(). createRemoteServiceEndpoint() serves those
calls for one provider consumer, including subscription startup and teardown.
parseServiceCall(), parseServiceCatalogue(), and the decoded/wire snapshot
and update parsers enforce Chord semantics once an adapter has established a
strict-JSON boundary. RemoteServiceErrorCode and
REMOTE_SERVICE_ERROR_CODES enumerate the service errors allowed across it.
Replicated-state traffic uses one createServiceStateEncoder() on the
provider side and one createServiceStateDecoder() on the consumer side per
subscription. Each pair owns an independent Delta path dictionary per
instance/member state, reset on replacement, unavailability, close, or fresh
hydration. Applications may nest these values in any routing, request,
response, or event envelope; Chord does not define that outer protocol.
A provider subscription snapshots atomically, then buffers later updates
until activation. Activation and reentrant publication share one FIFO. The
provider keeps at most 100 pending updates per subscription, not counting the
delivery in flight. Update 101 discards the whole pending queue and emits
{ type: "reset", snapshot }: a current full subscription snapshot whose
state members carry [["r", value]] with fresh sequence baselines. The reset
also captures live instance membership, so dropped spawn/close/replacement
events can't strand stale instances. Live keyed generations keep their
handles. The reset carries the context of the publication that overflowed.
Consumers and codecs must process that explicit reset before any later
ordinary delta. A root op inside a normal state update does not excuse a
sequence gap. Resets restart the subscription's path dictionaries; subsequent
deltas must stay contiguous from each reset baseline. Transport adapters must
preserve snapshot/reset/update ordering — never drop encoded delta batches,
never assume their own async queues are bounded by the provider's.
The JSON delta tracker
The standalone transactional tracker lives at @crow-agent/chord/delta:
import { applyImmutable, track } from "@crow-agent/chord/revisions";
const tracker = track({ log: "", total: 0 });
const change = tracker.beginChange(); // start
change.state.log += "done\n";
change.state.total += 1;
const prepared = change.prepare(); // freeze
tracker.adopt(prepared);
const replica = applyImmutable(prepared.base, prepared.ops); // replaytracker.value always reflects the newest adopted immutable revision.
Preparing changes nothing authoritative; adopting validates the preparation
and swings the root pointer. Assigned containers copy by value; untouched
subtrees may be shared across revisions.
The tracker relies on trusted immutable ownership, not defensive copies or
freezing. track(initial), prepareReplace(value),
replicatedState(initial), and replace(context, value) take over
alias-free strict-JSON roots without walking them. Callers must leave
transferred or published data alone. Draft-placed values are already copies,
so that same walk rejects non-strict JSON before the draft mutates. Published
values stay unfrozen. In-process loopback consumers may share containers with
the provider; mutating a consumed value breaks the contract and can corrupt
authority. Clone or serialize wherever a mutable trust boundary lies.
Replicated state offers the same model through a callback:
const initial = { log: "", total: 0 };
const status = env.replicatedState(initial); // ownership of initial moves here
status.change(context, (draft) => { // mutate
draft.log += "done\n";
draft.total += 1;
});A successful change() publishes exactly one atomic revision. When the
callback throws, the value and sequence stay put. Draft handles die with the
callback's return. Chord emits string appends and front-truncations, array
splices and permutations, sets, and deletes; big edit sets may collapse into
a full replacement. Remote plumbing encodes each batch separately for every
client/state pair.
Subscribing to public state
state.subscribe(async (value, context, delivery) => { ... }) serializes one
subscription's callbacks. Hydration begins as soon as a value exists, and its
promise must settle before updates flow. Sync callbacks still execute
synchronously. Inside an async callback, read the captured value —
state.value may already point at a newer revision.
A subscription keeps at most 100 pending full-value deliveries, excluding the running callback. On overflow only the newest pending value, context, and delivery metadata survive; a not-yet-started first hydration is spared. Public delivery sequences may therefore skip. This counts frames, not bytes. Internal exact-op subscriptions stay synchronous, see every revision, and ignore slow public callbacks.
The returned unsubscribe drops pending work at once and blocks new callbacks.
It neither aborts nor awaits the running one. Sync throws and rejections are
observed, reported, and never halt sibling subscriptions or later callbacks.
Attached sources and remote bindings route through their onError; local
mutable states (and handler-less attached sources) throw in a microtask.
Unsubscribing doesn't swallow a later rejection from the in-flight callback.
The Delta guide spells out the full ownership, lifecycle, operation, plus replica contracts.
Facet bundling and loading
@crow-agent/chord/bundler runs esbuild over ESM or TypeScript application entries
to produce independent content-addressed CommonJS files. The package-level API
reads plugin identity and build settings from package.json, then applies
the host application's facet path conventions:
{
"name": "@example/my-plugin", // name
"version": "1.0.0",
"type": "module",
"peerDependencies": {
"@crow-agent/chord": "^0.84.4"
},
"chord": {
"facets": {
"worker": "./src/custom-worker.ts", // entry
"presentation": false
}
}
}import { bundleFacetPackage } from "@crow-agent/chord/facet-bundle-api";
await bundleFacetPackage({ // bundle
packagePath: "/path/to/my-plugin", // package
outdir: "/application-owned/plugin-builds/my-plugin", // out
defaultFacets: {
worker: "src/worker.ts",
presentation: "src/presentation.ts", // ui
},
});Files matching the conventions become entries unless chord.facets
overrides or disables them. Peer dependencies stay external and resolve
against the host at load time. Chord installs nothing and runs no package
lifecycle scripts. bundleFacets() remains the lower-level entry for callers
that already know their plugin identity and entry map.
Output is one .cjs per entry plus chord-facets.json. Load a single
application-chosen entry with the Node-only loader:
import { createFacetBundleLoader } from "@crow-agent/chord/node-entry";
const loader = createFacetBundleLoader({ // loader
manifestPath: "/application-owned/plugin-builds/my-plugin/chord-facets.json", // manifest
entry: "worker",
resolveExternal: (specifier) => import.meta.resolve(specifier), // externals
});
const loaded = await loader.load(); // loadEvery load() checks SHA-256 integrity, then compiles the CommonJS body
straight with node:vm — never into Node's CommonJS or ESM caches. The host
resolves externals and serves them through a restricted require; esbuild
lowers dynamic imports onto the same path. Disposing a retired generation
drops the loader's facet references, so compiled code becomes collectible
once plugin-owned resources are gone too.
To ship a bundle to another Node host, readFacetBundleArtifact() packs one
verified manifest entry with its source, and
createFacetBundleArtifactLoader() instantiates fresh temporary generations
while resolving externals against the receiving host.
Reloading means: load a candidate, hand its facets to FacetHost.reload(),
dispose the candidate on failure, and dispose the retired LoadedFacets
only after cutover succeeds. The host validates and activates the candidate
while current providers keep routing, then swaps each singleton in place with
no unavailable window — stable handles never see a disconnect during an
ordinary reload. Keyed instances stay incarnation-bound; replacements get
fresh generations. The bundler finishes a full temporary directory before
swapping it into place, so loaders never catch a half-built generation.
PLANNING.md covers the wider RPC and generation-loading design.
