Skip to content

Changelog

What changed in each release, and what — if anything — you have to do about it. The migration notes Stability & Versioning points at.

Every @nice-code/* package releases in lockstep at one version, so an entry here covers the whole set. Each release says what changed and, where it matters, what you have to do — usually nothing.

While we are on 0.x, a 0.MINOR.0 may carry a breaking change. When it does, it is called out here under Action required. A 0.x.PATCH is fixes only.

Changes that have landed but are not in a release yet sit under Unreleased at the top. That heading becomes the version number when the release is cut.

Commander: sort processes by most recently started

Section titled “Commander: sort processes by most recently started”

The process list has an order menu: dependency order (the default, unchanged) or recently started, which puts the processes you last started at the top of each tag group. Only a start you asked for counts, so a crash-looping service does not keep jumping to the top. Start times are saved in the daemon’s state directory (last-starts.json), so the order survives a daemon restart. The browser remembers which order you picked.

A stopped process’s row now shows when it was last started, and ages over a day read in days (1.5d) instead of hours. The status realm’s process entry gains an optional lastStartedAt, which nice-commander status --json prints too.

Nothing to do on upgrade.

Process / Commander: run no longer hangs after its tasks finish

Section titled “Process / Commander: run no longer hangs after its tasks finish”

A process goes terminal on exit, but its output only ends when its stdio closes. If a lines() iterator was waiting in that gap, it spun without ever yielding, which froze the whole host process. The gap is wider under load, or when a child leaves something holding its stdout. In nice-commander run with more than one task this showed as every result printed and then no summary, forever. lines() now waits the gap out, and ends when a stop() finishes. Three more @nice-code/process fixes ride along:

  • A restart could lose a run’s output. Calling start() again right after a task finished could let the previous run’s stdio end the new run’s line stream, dropping its lines.
  • The log file sink could turn itself off. A flush landing while a new run’s folder was still being created failed, and one failed write disables the sink for the life of the process.
  • Long lines are cheap again. A line of several MB with no newline took seconds to split into capped frames (about 6 s for 10 MB); it now takes a fraction of a second.

Nothing to do on upgrade.

Commander: config mistakes are type errors

Section titled “Commander: config mistakes are type errors”

defineCommanderConfig used to accept any object that fit the config shape. That let unknown keys through at every level, including the removed groups, which only failed once the daemon loaded the file. Now a misspelled or removed key is a type error in the editor, with a “did you mean” where TypeScript has one. So is a dependsOn, suite include/exclude, defaultSelection or ready.endpoint that names a process, suite or endpoint the config never declares, and a suite named all or after a process id. The reference checks need literal ids. A process list typed { id: string }[] switches them off; declare it as const to switch them on. See Mistakes are type errors.

  • Commander: a commander.config.ts that used to type-check may now fail. What the new errors name was already wrong: the daemon refused a bad reference at load, and silently ignored a misspelled key. One case is not: if every process id is a literal, a dependsOn: ["build"] inside a .map() callback types as string[] and is refused. Write dependsOn: ["build"] as const there, as you already do for kind.

Process / Commander: a process’s identity no longer depends on the reader’s locale or timezone

Section titled “Process / Commander: a process’s identity no longer depends on the reader’s locale or timezone”

On macOS, the start stamp that proves a pid is still the process we spawned came from ps, which formats it for the caller’s LANG and TZ. A CLI run from a shell with a different locale (en_ZA prints Wed 23 Sep, C prints Wed Sep 23) failed to recognize a healthy daemon. It reported it as running without an instance record, and up could boot a second daemon past it. Stamps are now read with the locale and timezone pinned and stored as UTC ISO (2026-09-23T14:34:54Z), which is what ports and doctor show. @nice-code/process exports matchProcessStartStamp(pid, recorded) for checking a stored stamp. Linux and Windows are unchanged. Nothing to do on upgrade: a stamp recorded by an earlier build still verifies from a shell with the locale and timezone it was recorded in.

Commander: shutdown finds the daemons the record lost track of. Only @nice-code/commander changes; nothing to do on upgrade.

  • A daemon can outlive its own instance record: a second daemon that booted past it onto the next free port overwrote the record, then deleted it on its own shutdown. The first kept running, holding a port and its processes, while every lookup (shutdown included) said “not running”. shutdown now also finds such daemons by their command line, re-verifies each start stamp, and stops them. It reports every daemon it stopped; --json returns daemons: [...] when there was more than one.
  • A signal shutdown of a daemon that had already exited now counts as success.

Commander: ui attaches to a running daemon, and the page can recover. Only @nice-code/commander changes; nothing to do on upgrade.

  • nice-commander ui attaches to the daemon already running for the project, even one started by an older build. Each daemon serves the web UI it was built with, so upgrading the package no longer breaks an open dashboard. nice-commander ui --fresh stops that daemon (and everything it runs) and starts one on the installed build.
  • A page that cannot connect offers reload and shut down daemon instead of a dead end.
  • The process list, rails and overview in the web UI were restyled.

Realm: an empty record nested in an object hydrates as {}

Section titled “Realm: an empty record nested in an object hydrates as {}”

A t.record inside a t.object (simulator.conditions), with rules only on its entries (simulator.conditions.$ref), used to be missing from a client’s projection while it was empty. The first client write into it threw TypeError inside the draft. It now ships as {}, the same as a top-level record always has. Containers inside a record entry are unchanged, so an entry’s existence still never leaks. Nothing to do on upgrade; a workaround that hoisted such records to the state root can be undone.

Commander: strays on exclusive ports, and the notes that go with them

Section titled “Commander: strays on exclusive ports, and the notes that go with them”

Only @nice-code/commander changes in this block. Run nice-commander shutdown once after upgrading (it stops managed processes): the status realm gained fields, so a daemon from the previous build refuses this CLI until it is replaced.

  • A port held by your own definition, started outside the daemon, is a “stray” — and up can now take it. The owner of an occupied exclusive endpoint is identified by argv and cwd, walking up the listener’s parents (bun run dev → vite → the socket). A stray is named in the refusal with its pid and the fix; up --replace-same / restart --replace-same, or replaceSame: true on the definition or under defaults, stops it as a tree (root first, SIGTERM → grace → SIGKILL) and starts yours. A stranger on the port is named and still refused, with or without the flag. In the web UI, inspect ports shows the verdict and offers replace stray and start on a stray.
  • ports <sel> --kill-owner [--yes] [--force] stops whatever holds the selection’s exclusive endpoints without a PID to copy first. doctor marks every occupied-port owner as a stray or not; doctor --reap-strays [--yes] stops the strays and leaves the strangers alone. The ports table and --json listeners gained stray and strayRoot.
  • A stale blocked: note is a bug that is fixed. status could show a dependent blocked: dependency "api" is not running two rows under a ready api; the note was from an earlier generation and nothing re-evaluated it. It now clears by itself once the named dependency is ready (or, for a task, has succeeded).
  • A refused start is remembered. status (and the UI) show start failed: … on the row — the port conflict, the spawn error — until a later start succeeds or the process is stopped, instead of a bare not-started with the reason gone with the up that printed it.
  • up prints where a ready service is (✓ web ready → http://localhost:3050), and up --json / restart --json return the generation as data: each id’s outcome and reason plus its state, pid, endpoints and urls. The old { results, warnings } shape is a subset, so existing scripts keep working. detail.url (a definition’s own url) joined the status realm.
  • <command> --help prints that command’s section alone; bare --help is unchanged.
  • explain no longer prints the env-inheritance note on every call. The per-id spawn environment and the note are behind explain <sel> --env; without it a single gray line says where to find them.
  • Security — result routing now follows the connection a request arrived on. An unauthenticated exchange (HTTP) request that merely named another client’s runtime coordinate could make the server push its result frame down that client’s live socket. Such a request answers inline on its own response. Duplex replies now use the original live connection and binding generation; a dropped, reused or reidentified socket cannot receive an older request’s result. Keep a request on the socket that should receive its reply; use explicit pushes for separate delivery.
  • Servers built through the advanced acceptChannel / createSecureChannelAcceptor / createChannelAcceptor paths must now pass { scope: "instance" }, matching what serveChannel already required. Binding a serving acceptor to a global runtime made that server a global dispatch target — the isolate-sharing hazard the scope rule exists to prevent. Construct server runtimes as new ActionRuntime(identity, { scope: "instance" }).
  • Reliable replies retain their own receiver-stamped stream key, including when the same client reuses an action correlation id across independent streams. A dropped buffered arrival retains its historical security facts while releasing its socket reference.
  • Request dispatch, runtime.run and handler ctx.run now honor the supplied timeout option.
  • serveWire releases restored connections and carrier resources if host activation fails.
  • The frozen 0.95.0 compatibility gate now exercises real action/realm runtime flows, reconnects, encrypted socket restoration, realm snapshots, reliable buffers and HTTP session tickets in both upgrade directions. Production rollout still requires the deployed application matrix.
  • The deprecated positional serveChannel(runtime, channel, options) form now rejects an identity passed in its options bag instead of silently ignoring it. Use serveChannel(channel, { identity, … }).
  • Keep every @nice-code/* dependency in each build on one lockstep package version. Before a rolling deployment, run the old/new compatibility matrix with the same action definitions and realm schemas used in production. Test deployed/deployed, deployed/candidate, candidate/deployed, and candidate/candidate; prefer a server-first canary once both mixed pairs pass.
  • A secure peer with no compatible protocol generation now fails with typed err_nice_wire_connect id protocol_version_mismatch. That failure stops carrier fallback and parks automatic redial until an explicit retry, so it cannot silently reach a plain HTTP fallback. Branch on the id rather than its message. Legacy peers whose reject has no id retain the generic handshake_rejected behavior.
  • Runtimes passed to serveChannel, serveChannels, serveHost, serveWorker(s), or serveDurableObject must now be constructed with { scope: "instance" }. This prevents server instances sharing an isolate from participating in untargeted global dispatch. Global runtimes auto-register when handlers are added; remove obsolete .apply() calls. When several global runtimes can handle an untargeted action, target one explicitly with runtime.run(request).
  • Migrate server construction to serveChannel(channel, { identity, ... }), serveChannels(channels, { identity, ... }), and the equivalent host/Cloudflare forms. The server creates an instance runtime and exposes it as server.runtime. The old positional overload remains as a deprecated migration shim; an explicitly supplied runtime is borrowed and must be instance-scoped.
  • Remove clientEnv from serving/acceptor options and targetPeer from action execution options. Return routing follows the connection on which a request arrived; explicit local selection uses runtime.run(request).
  • Replace transports: [{ carrier, secure }] with direct carriers: [wsCarrier(url), httpCarrier(url, { secure: false })]. The old descriptor input remains as a deprecated adapter for this release; passing both forms throws conflicting_connect_options. Carrier URL callbacks, custom cache keys, availability predicates, labels, and per-action dial context remain supported.
  • Rename connection lifecycle options to reconnect and heartbeat. The deprecated keepLinkAlive and linkKeepalive aliases retain their behavior for this release; old and new names cannot be combined in one call.
  • Rename the Cloudflare realm composition helper to createRealmHost and its return type to IRealmHost. The old serveRealmDurableObject and IRealmDurableObjectHost names remain as deprecated aliases on the same subpath. The host still composes with serveWireDurableObject or action’s serveDurableObject; it does not serve a socket by itself.
  • Secure connections now verify that the endpoint advertises the selected peer’s envId and, when explicitly present, perId. A mismatch raises peer_identity_mismatch, stops carrier fallback, and parks redial until an explicit retry. The per-boot insId is intentionally ignored.
  • ephemeralIdentity: true now permits omitting storage; a supplied link or storage still takes precedence. Persistent identities continue to require durable crypto storage.
  • Two different local executors at the same route specificity now fail setup with duplicate_action_executor. Keep action-specific-over-domain fallback composition, but remove accidental same-key shadows.
  • @nice-code/wire exports WIRE_PROTOCOL_VERSION and WIRE_SUPPORTED_PROTOCOL_VERSIONS. The existing action/wire/realm format and nice-ws-hs/1 transcript are protocol v1, and this release supports [1]. This metadata adds no per-message bytes and does not change handshake signatures, key derivation, frame layouts, dictionaries, schema hashes, or persisted formats.
  • Socket-only action context operations now fail with connection_required when a request arrived over HTTP or its exact socket binding is no longer live. Receiver connection and reliability metadata are installed locally and cannot be forged through action JSON.
  • ActionRuntime now has explicit global and instance scopes, bound runtime.run(request), and terminal idempotent dispose(). Supplied server runtimes remain caller-owned; each server releases only its own handler leases when disposed. Untargeted ambiguity, disposed use, invalid server scope, and peer-handler double binding have stable typed errors.
  • Every local handler style receives a runtime-bound second ctx argument with run, origin, authenticated-origin, security-level, and reliability facts. channelCases extends the same context with connection state and push helpers.
  • Runtime instance ids and handler ids are lazy. serveWorker now teaches module-scope identity and handlers: [...] values while preserving runtime/handler factories as compatibility inputs; server construction and identity provisioning remain first-request lazy and retry after provisioning failure.
  • serveWire is the platform-neutral server counterpart of createWireClient. It hosts duplex wire carriers and frame protocols without importing action. Realm acceptors receive a trusted per-connection protocol context containing the authenticated client coordinate and negotiated security level, including after a hibernation wake.
  • Realm definitions preserve their literal id. createRealmHost rejects a mismatched literal record key at compile time and preserves each hosted engine’s state, avatar, and server-context types. The runtime key/id check remains for JavaScript and dynamic records.
  • RuntimeCoordinate.persistentId and .instanceId are readable aliases. The own fields and serialized wire shape remain perId and insId.
  • Peer-facing handles now use peer. Deprecated peerClient, peerCoordinate, and runtimeCoordinate aliases remain for migration.
  • @nice-code/action/testing adds createTestChannel(channel, handlers), an encoded asynchronous loopback with typed cases, frame counters, and deterministic disposal.
  • @nice-code/wire/advanced is the supported import for protocol, handshake, crypto, and reliable delivery primitives. Existing root exports remain compatible for this release. See the public API inventory.

Published on 2026-09-20, 26 minutes before 0.102.0, with no release commit, tag or notes in the repository. Treat it as superseded by 0.102.0 and read that entry. 0.98.0, 0.99.0 and 0.101.0 were never published.

Commander web UI polish. Only @nice-code/commander changes; nothing to do on upgrade.

  • A suite’s down now mirrors its up. Running a suite brings its dependencies up with it, and stopping it used to leave them running — down on a two-member stack left the three services underneath it alive. It now stops the whole dependency closure, except a dependency that some other live process still needs, which is left running and named in the report. Hover the button to see exactly what it will stop. Stopping a single process, or a tag group, is unchanged: only what you named.
  • Process rows have a “stop with dependencies” action — the same rule, for one process: it appears only when it would stop more than the process itself, and its tooltip lists what goes and what stays.
  • Row actions no longer float over the row’s text. They are compact icons in their own space at the right of the row, showing only what applies — start for a stopped process; restart, stop and stop-with-dependencies for a live one — dim until you hover or focus the row.
  • The tag filter’s any | all toggle is always visible, and the process list has its own heading carrying the count and group by.

Commander: tags organise, suites run — groups is gone. The web UI is rebuilt around tag groups, suites and saved filters. Realm, action, wire and devtools also get small additions that make first examples shorter and failures easier to catch.

  • groups was removed from commander.config.ts. A config that still has the key fails validation — and the error is the migration guide: for each group it names what it becomes.
    • one member → delete it and use the process id;
    • exactly one tag selection (project=x) → delete it and select by tag (pin it in the UI);
    • anything else → move it under suites unchanged (where / include / exclude / staggerMs keep their meaning).
  • A suite must resolve to at least two members. A one-member suite is an error that names the id to run instead.
  • Run nice-commander shutdown once after upgrading (it stops managed processes). The daemon protocol moved to v6, so a CLI from this release will not talk to a daemon from the last.
  • defaultSelection takes a process id, a suite name, or one tag=value term.
  • server.pushToClient failures are now typed. Pushing to a client with no live connection throws err_nice_transport id not_found (it was a plain Error), and a push whose send fails rejects with id send_failed, keeping the original error as the cause (it was the raw carrier error). Only code that matched on the old message text needs changing. One err_nice_transport.isExact(e) guard now covers not_found, send_failed and timeout. See Bi-directional.
  • write_before_hydrate has a narrower meaning. A realm write made before the first hydrate is now queued instead of failing, so this id is raised only when the hydrate never comes: the handle was detached, or pendingExpiryMs passed. Nothing was sent in either case. A refused attach rejects queued writes with realm.attachError. Code that awaits whenLive() before writing behaves as before.
  • Multi-value tags — tags: { project: ["api", "billing"] } matches both. Every existing single-value tag and selector keeps its meaning.
  • Suites carry their own parallel / failFast / staggerMs / description, so run verify and the UI’s ▶ behave identically. explain <suite> prints the policy.
  • optIn: true keeps a process out of every bulk start — tag selections, a suite’s where, and --all. It starts only by exact id, an explicit suite include, or as a dependency.
  • CLI: --any ORs tag=value terms (they still AND by default); @name selects a tag group pinned in the web UI.
  • Web UI: an any/all tag filter with per-chip counts; results bucketed into tag groups by the exact tags they share, each runnable from its heading with a preview of what will start; recent runs recorded and tag groups pinnable, stored with the daemon so they survive browsers and sessions; suite cards with per-member progress, run duration and rerun failed; live dependency dots on every row, hover tracing of upstream/downstream rows, and a blocked row that links to its blocker; a ⌘K go-to palette. See the web UI.
  • realm.avatar — a realm handle now exposes the identity it attached as (type, persistentId, instanceId), so you can key your own entries with realm.avatar.persistentId without carrying the id around separately. See Connecting & React.
  • Write to a realm straight after connectRealm. realm.update() and realm.intents.*() called before the first hydrate are queued. They run in call order against the hydrated state, right after assertOnAttach, and settle like any other write. You still need whenLive() before reading the store. realm.pending.count includes queued calls.
  • createNiceServerDevtools accepts session and allowRemoteCommands, so a deployed staging backend can join a relay session and keep its runtime-derived identity. With a session and no stage, the backend’s env is the session’s. See Remote Devtools Sessions.
  • ReliableLog.contiguousEntries(streamId, afterSeq?) and ReliableLog.pruneThrough(streamId, through), plus the IReliableLogEntry<T> type, for replaying and pruning a push log without reconstructing sequence numbers. See Reliable Delivery.
  • Devtools window: side-by-side clients are now called columns in the UI. “Lane” means only a traffic category. Stored layouts are unaffected.
  • The in-memory reliable log store now deletes a forgotten stream’s frames. createMemoryReliableLogStore().deleteStream removed only the high-water mark. This store is for tests and development; the SQL store was already correct.
  • WireClient.clearCache() is marked @internal. It is unchanged, but apps should call clearTransportCache() or releaseLink(), which also suppress the automatic redial.
  • Restarting a process no longer restarts its dependencies. restart web used to stop the whole dependency closure, bouncing api under every other process that depended on it. Only the ids you name are stopped; a dependency that is already ready is left alone.
  • Starting many tasks from the web UI is now bounded and dependency-gated. It used to launch every selected task at once. UI runs now go through the same scheduler as run --parallel.

Several duplex connections from one runtime to one peer. A new connectChannel option — additive, nothing to change unless you hit the case below.

Vibenode reported a silent misroute: a web app dialling one backend over two WebSockets (a per-scope store socket and a jobs socket) had every jobs reply and progress push delivered to the store socket, where nothing was listening. The bulk run looked like it hung until the transport timeout fired.

The cause was one identity across two links. An acceptor binds each connection by the coordinate that connection presents, and every connection of a runtime presented the runtime’s own coordinate — so with both sockets live the server could not tell them apart, and the return path picked whichever bound first. The only workaround available was a second ActionRuntime minted purely to obtain a second instance id, which contradicts the “one runtime per app” rule the documentation states.

connectChannel now takes a connectionId:

connectChannel(runtime, storeChannel, { peer: backend, connectionId: "store", … });
connectChannel(runtime, jobsChannel, { peer: backend, connectionId: "jobs", … });

The label is folded into the runtime’s per-boot instance id, not substituted for it, so:

  • the environment and persistent id are untouched — the server still sees one client, one trust-on-first-use pin, one set of permissions;
  • a stable label stays safe for reliable delivery, which needs a fresh-per-boot stream epoch. Each named connection also gets its own reliable streams, closing a latent case where two connections sharing a domain:id route would have merged two independent sequence counters into one inbox stream.

The identity a named connection presents in its handshake and the origin it stamps on its frames are now the same value by construction, so the secure and plain paths agree on which socket a return belongs to.

connectChannel also warns at dial time when a second connection to the same peer reuses an identity already in use, naming connectionId — the misroute is no longer silent for anyone who hasn’t read this entry. Two channels that can share one socket still want connectChannels([a, b]); connectionId is for transports that genuinely differ.

Action required: none. Existing single-connection apps are byte-identical — the option is opt-in and unset behaviour is unchanged.

Plain HTTP now actually works. 🐛 A real bug, fixed — no action needed beyond upgrading.

From 20 June 2026 until this release, a plain (unhandshaked) HTTP pairing was broken end to end: the plain connector always sent the { k: "act", w } exchange envelope, and the plain endpoint never unwrapped it. The reply direction was broken the same way. This affected:

  • serveDurableObject’s default httpFallback: "plain"
  • serveWorker({ secure: false })
  • any client on httpCarrier(url, { secure: false })

It went unnoticed for two months because the plain endpoint is normally a fallback behind a preferred WebSocket, so nothing exercised it. Secure pairings were never affected.

What changed, all inside createActionFetchHandler’s plain branch (which serveChannel, serveWorker and serveDurableObject all route through):

  • The envelope is decoded with the byte-frozen codec the secure acceptor uses, rather than a hand-rolled unwrap. An enveloped request is answered with an envelope; a raw action wire is still accepted and answered raw with its HTTP status, so curl and older peers keep working.
  • Every reply to an enveloped request is a 200 envelope, refusals included.

Action required: none — your code is already written the way the fix expects. If part of your estate pins ≤ 0.89.0, use httpFallback: "secure" or a WebSocket-only transport until you upgrade.

Worth knowing, since it was verified while fixing this: a plain client against a secure acceptor works. secure: false on the connector produces the tokenless envelope, which httpAcceptorCarrier() accepts at level none — so a read-only public client needs no identity of its own. See Security Levels and Cloudflare Durable Objects.

Release hygiene — package versions realigned after a mis-sequenced cycle, plus a fix to @nice-code/commander’s terminal output for process logs. No library behaviour changed.

Two consumer-reported issues, both behavioural. ⚠️ One changes when an existing error throws — read the @nice-code/state entry if you use Store.update.

Connection failures are typed and self-locating

Section titled “Connection failures are typed and self-locating”

Dial failures used to flatten to a message string: no endpoint, no discriminator, nothing to branch on but text. All three layers held the evidence and dropped it. Now:

  • The HTTP carrier throws with status, content-type and a sanitized body preview on a non-2xx response, and endpoint_unreachable on a fetch rejection (keeping the platform’s own wording). A deliberate abort is rethrown untouched and never mistaken for an endpoint verdict.
  • The handshake names what came back when the reply isn’t a protocol frame — which is what catches a proxy answering 200 with a login page.
  • err_nice_transport’s initialization_failed context gained endpoint (the URL that transport actually dialed) and kind, an EWireConnectFailureKind discriminator.
  • New: classifyConnectFailure(error) and endpointFromConnectFailure(error). classifyConnectFailure and EWireConnectFailureKind are re-exported from @nice-code/action, so an actions consumer needs no direct @nice-code/wire import.

Action required: none, but if you were matching on error message text to tell “server down” from “wrong URL”, replace it with a kind check — that is what it is for. See Error Handling and the kind table.

@nice-code/state — the returned-state guard is no longer data-dependent

Section titled “@nice-code/state — the returned-state guard is no longer data-dependent”

Store.update is mutate-only: an updater’s return value is ignored, and returning a replacement while leaving the draft untouched throws rather than silently doing nothing.

The old guard judged this by whether the draft happened to change, which made the throw depend on the data. store.update((draft) => Object.assign(draft, next)) passed on every run where next differed from the current state, then threw on the one reset where it was identical — in production, long after the code was written.

The guard now splits on draft identity: a returned draft always passes, a returned replacement with an untouched draft always throws. Same rule, deterministic outcome.

Action required: none. This only ever turns a spurious throw into a pass. If you added a defensive braced body ((draft) => { Object.assign(draft, next); }) to work around it, you can keep it — it stays correct. See Stores.

Not recorded here. Use the git tags, or ask — if you hit something that looks like a behaviour change between two earlier versions, we would rather answer it than have you guess.