npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@shardworks/animator-apparatus

v0.1.310

Published

The Animator — session launch and telemetry recording apparatus

Readme

@shardworks/animator-apparatus

The Animator brings animas to life. It is the guild's session apparatus — the single entry point for making an anima do work. Two API levels serve different callers:

  • summon() — the high-level "make an anima do a thing" call. Passes the role to The Loom for identity composition, then launches a session with the work prompt. This is what the summon relay, the CLI, and most callers use.
  • animate() — the low-level call for callers that compose their own AnimaWeave (e.g. The Parlour for multi-turn conversations). Rejects at the top with a synthesized SessionResult { status: 'rate-limited', … } when the rate-limit back-off machine is paused; no SessionDoc is written for the rejected call.
  • getSessionCosts() — bulk per-session cost/token lookup for read-side consumers. First read-side helper on AnimatorApi; used by Spider's rig-view aggregator to compose rig-level totals and per-engine breakdowns without reaching into the sessions book directly.
  • getStatus() — returns the rate-limit back-off state document verbatim (see § Rate-Limit Back-Off). The canonical dispatchability predicate is exported as isDispatchable(doc) from this package's index; TypeScript callers should import that helper rather than re-composing it. Non-TS consumers (e.g. the Oculus banner) read the server-computed dispatchable boolean enriched onto the animator-status tool / /api/animator/status route response.

Both methods return an AnimateHandle synchronously — a { chunks, result } pair. The result promise resolves when the session completes. The chunks async iterable yields output as the session runs when streaming: true is set; otherwise it completes immediately with no items.

Depends on @shardworks/stacks-apparatus for persistence (session records and full transcripts). Uses @shardworks/loom-apparatus for context composition (resolved at call time by summon(), not a startup dependency). The session provider (e.g. @shardworks/claude-code-apparatus) is discovered at runtime via guild config.


Installation

{
  "dependencies": {
    "@shardworks/animator-apparatus": "workspace:*"
  }
}

API

The Animator exposes its API via guild().apparatus<AnimatorApi>('animator'):

import { guild } from '@shardworks/nexus-core';
import type { AnimatorApi } from '@shardworks/animator-apparatus';

const animator = guild().apparatus<AnimatorApi>('animator');

summon(request): AnimateHandle

Summon an anima — compose context via The Loom and launch a session. This is the primary entry point for dispatching work. Returns synchronously.

const { result } = animator.summon({
  prompt: 'Build the frobnicator module with tests',
  role: 'artificer',              // passed to The Loom for composition
  cwd: '/path/to/workdir',
  metadata: {                     // optional, merged with auto-generated metadata
    writId: 'wrt-8a4c9e2',
  },
});

const session = await result;
console.log(session.status);           // 'completed' | 'failed' | 'timeout' | 'cancelled'
console.log(session.costUsd);          // 0.42
console.log(session.output);           // final assistant message text
console.log(session.metadata?.trigger); // 'summon' (auto-populated)
console.log(session.metadata?.role);    // 'artificer' (auto-populated from request)

With streaming:

const { chunks, result } = animator.summon({
  prompt: 'Build the frobnicator module with tests',
  role: 'artificer',
  cwd: '/path/to/workdir',
  streaming: true,
});

for await (const chunk of chunks) {
  if (chunk.type === 'text') process.stdout.write(chunk.text);
}

const session = await result;

The Loom owns system prompt composition — given the role, it produces the system prompt from the anima's identity layers (role instructions, curriculum, temperament, charter). The work prompt bypasses The Loom and goes directly to the session provider. At MVP, the Loom does not yet compose a system prompt (returns undefined); the session runs with the work prompt only. As the Loom gains composition logic, summon() callers get richer sessions without changing their code.

Requires The Loom apparatus to be installed. Throws with a clear error if not available.

animate(request): AnimateHandle

Launch a session with a pre-composed context. Use this when you've already built an AnimaWeave yourself (e.g. The Parlour assembling inter-turn context for a multi-turn conversation). Returns synchronously.

const { result } = animator.animate({
  context: animaWeave,            // from The Loom or self-composed
  prompt: 'Do the thing',         // work prompt, sent directly to provider
  cwd: '/path/to/workdir',
  conversationId: 'conv-xyz',    // optional, for multi-turn resume
  metadata: {                     // optional, recorded as-is
    trigger: 'consult',
    animaName: 'coco',
  },
});

const session = await result;

With streaming:

const { chunks, result } = animator.animate({
  context: animaWeave,
  prompt: 'Build the feature',
  cwd: '/path/to/workdir',
  streaming: true,
});

for await (const chunk of chunks) {
  if (chunk.type === 'text') process.stdout.write(chunk.text);
}

const session = await result;

If the session provider doesn't support streaming, chunks completes immediately with no items and result resolves normally via the non-streaming path — regardless of the streaming flag.

cancel(sessionId, options?): Promise<SessionDoc>

Cancel a running session. Patches the SessionDoc to 'cancelled' with endedAt, durationMs, and an optional reason. If the provider supports cancellation and cancelHandle is available, delegates to the provider's cancel() method to kill the process.

const doc = await animator.cancel(sessionId, { reason: 'Cost overrun' });
console.log(doc.status);  // 'cancelled'
console.log(doc.error);   // 'Cost overrun'

Idempotent: calling cancel() on a session that is already in a terminal state (completed, failed, timeout, cancelled) returns the existing SessionDoc without modification. Throws if the session ID does not exist.

Cross-process: the cancelHandle field on SessionDoc stores a tagged cancel handle for cross-process cancellation (e.g. { kind: 'local-pgid', pgid: number } for local process groups). This allows any process with Stacks access to cancel a session launched by another process.

getSessionCosts(sessionIds): Promise<Map<string, SessionCost>>

Bulk per-session cost/token lookup. Resolves cost and token-usage snapshots for the given session ids in a single round-trip against the sessions book. Intended for UI-facing aggregators that compose rig-level totals or per-engine breakdowns.

const costs = await animator.getSessionCosts(['ses-a', 'ses-b', 'ses-missing']);
costs.get('ses-a');        // { costUsd: 0.15, inputTokens: 1000, outputTokens: 200 }
costs.get('ses-missing');  // undefined — ids not present in the book are omitted

Missing ids: session ids not present in the sessions book are omitted from the returned Map. Callers decide whether that means "zero contribution" (Spider's rig-view does) or something else.

Empty input: returns an empty Map without touching The Stacks.

Shape: deliberately minimal — costUsd (zero when the session exists but has not reported cost), plus optional inputTokens / outputTokens when the provider reported token usage. Consumers that need other SessionDoc fields should look them up separately.

Types

interface SummonRequest {
  prompt: string;                // The work prompt (sent to provider directly)
  role?: string;                 // Role name (passed to The Loom for composition)
  cwd: string;                   // Working directory for the session
  conversationId?: string;       // Optional, for multi-turn resume
  metadata?: Record<string, unknown>; // Merged with { trigger: 'summon', role }
  environment?: Record<string, string>; // Per-request env overrides (merged with weave)
  streaming?: boolean;           // Enable streaming output (default false)
}

interface AnimateRequest {
  context: AnimaWeave;           // Pre-composed identity context
  prompt?: string;               // Work prompt (sent to provider as initialPrompt)
  cwd: string;
  conversationId?: string;
  metadata?: Record<string, unknown>;
  environment?: Record<string, string>; // Per-request env overrides (merged with weave)
  streaming?: boolean;           // Enable streaming output (default false)
}

interface AnimateHandle {
  chunks: AsyncIterable<SessionChunk>; // Empty when not streaming
  result: Promise<SessionResult>;
}

interface SessionResult {
  id: string;                    // Generated by The Animator (ses-{hex})
  status: 'completed' | 'failed' | 'timeout' | 'cancelled' | 'rate-limited';
  startedAt: string;             // ISO-8601
  endedAt: string;               // ISO-8601
  durationMs: number;
  provider: string;              // e.g. 'claude-code'
  exitCode: number;
  error?: string;
  conversationId?: string;
  providerSessionId?: string;
  tokenUsage?: TokenUsage;
  costUsd?: number;
  metadata?: Record<string, unknown>;
  output?: string;               // Final assistant message text
}

type SessionChunk =
  | { type: 'text'; text: string }
  | { type: 'tool_use'; tool: string }
  | { type: 'tool_result'; tool: string };

interface SessionCost {
  costUsd: number;               // Zero when the session exists but has not reported cost
  inputTokens?: number;          // From the session's tokenUsage, if reported
  outputTokens?: number;         // From the session's tokenUsage, if reported
}

Configuration

The Animator reads its config from guild.json["animator"]:

{
  "animator": {
    "sessionProvider": "claude-code",
    "rateLimit": {
      "backoff": {
        "initialMs": 900000,
        "maxMs": 3600000,
        "factor": 2
      }
    }
  }
}

| Field | Type | Default | Description | |---|---|---|---| | sessionProvider | string | 'claude-code' | Plugin id of the apparatus that implements AnimatorSessionProvider. Looked up via guild().apparatus(). | | rateLimit.backoff.initialMs | number | 900_000 (15 min) | Initial pause window when the first rate-limit hit arrives. | | rateLimit.backoff.maxMs | number | 3_600_000 (1 h) | Upper bound for the pause window after exponential back-off. | | rateLimit.backoff.factor | number | 2 | Multiplier applied per successive failed resume attempt. |

The rateLimit.backoff block is validated fail-loud at startup (a patron override of the Animator's default silent-default convention, scoped to this block only). Malformed values throw; a missing block uses the defaults. The rateLimit umbrella is future-proofing: today it only holds backoff, but further rate-limit tuning (detection thresholds, per-source overrides) can land nested under it without flattening the top-level namespace.

Session Provider Interface

Session providers are apparatus plugins whose provides object implements AnimatorSessionProvider:

interface AnimatorSessionProvider {
  name: string;
  launch(config: SessionProviderConfig): {
    chunks: AsyncIterable<SessionChunk>;
    result: Promise<SessionProviderResult>;
    processInfo?: Promise<Record<string, unknown>>; // e.g. { kind: 'local-pgid', pgid: number }
  };
  cancel?(cancelMetadata: Record<string, unknown>): Promise<void>;
}

interface SessionProviderConfig {
  systemPrompt?: string;         // From AnimaWeave (Loom output)
  initialPrompt?: string;        // From AnimateRequest.prompt (work prompt)
  model: string;
  conversationId?: string;
  cwd: string;
  environment?: Record<string, string>; // Merged env vars (weave + request overrides)
}

interface SessionProviderResult {
  status: 'completed' | 'failed' | 'timeout' | 'cancelled' | 'rate-limited';
  exitCode: number;
  error?: string;
  providerSessionId?: string;
  tokenUsage?: TokenUsage;
  costUsd?: number;
  transcript?: TranscriptMessage[];  // Full NDJSON message array
  output?: string;                   // Final assistant message text
  /**
   * Structured termination tag. Providers attach this when the terminal
   * status reflects a specific detected condition (today: rate-limit).
   * The Animator forwards it onto the SessionDoc / SessionResult so
   * downstream consumers don't have to pattern-match freeform error text.
   */
  terminationTag?: { kind: 'rate-limit'; source: 'ndjson-result'; detail?: string };
}

The Animator imports these types; provider packages import them from @shardworks/animator-apparatus and implement them.

Oculus Page

The Animator contributes an Animator page to the Oculus dashboard (id: 'animator') for viewing and managing sessions.

Session List

  • Displays sessions with status badge, role, writ title, cost (with token breakdown tooltip on hover), duration, and start time.
  • Filters: status dropdown, date range (from/to).
  • Auto-refreshes every 12 seconds.
  • Running sessions show a Cancel button.

Session Detail

Click a session row to view full metadata, a real-time session log (SSE streaming for running sessions), and the full transcript.

Custom Routes

Three API routes under /api/animator/:

| Route | Description | |---|---| | GET /api/animator/sessions | Enriched session list with role, writTitle (resolved from Clerk), and tokenUsage. Supports status, from, to, limit query params. | | GET /api/animator/session-transcript | Returns { messages, sessionStatus } for a session. | | GET /api/animator/session-stream | SSE stream — emits chunk, transcript, and done events. Handles completed sessions, running sessions with/without broadcaster. |

GET /api/animator/status is not in this list: the animator-status tool (see the tools table above) is auto-registered by Oculus at that path and returns the raw AnimatorStatusDoc — no custom handler needed. The Spider Oculus pause banner reads from the auto-registered route without modification.

Support Kit

The Animator contributes two books, inspection/dispatch tools, an Oculus page, and custom routes:

Books

| Book | Indexes | Description | |---|---|---| | sessions | startedAt, status, conversationId, provider | Session records — one per animate() call. Includes output (final assistant text). | | transcripts | sessionId | Full NDJSON transcripts — one per session. Drives web UIs, operational logs, debugging. | | state | — | Shared operational state. Two well-known documents: guild-heartbeat (written by the heartbeat timer) and dispatch-status (owned by the rate-limit back-off machine). See § Rate-Limit Back-Off. |

Tools

| Tool | Permission | Description | |---|---|---| | session-list | read | List recent sessions with optional filters (status, provider, conversationId, limit) | | session-show | read | Show full detail for a single session by id | | summon | animate | Summon an anima from the CLI — compose context and launch a session | | session-cancel | animate | Cancel a running session by id, with optional reason | | session-running | write | Record initial "running" state for a detached session | | session-record | write | Record a terminal session result for a detached session | | session-heartbeat | write | Refresh session liveness timestamp (called periodically by babysitters) | | animator-status | read | Show the Animator's current rate-limit pause state as JSON (the CLI auto-printer pretty-prints it) |

The summon and session-cancel tools are patron-only (callableBy: 'patron'). The session-running, session-record, and session-heartbeat tools are infrastructure-facing (callableBy: 'anima') — called by session babysitters over the Tool HTTP API to report detached session lifecycle events. See docs/architecture/detached-sessions.md.

Response-enrichment convention

Tools may compute display-only derived fields whose source data is in the API's verbatim return; the API itself stays verbatim. The convention is: keep the public AnimatorApi shape free of presentation-layer fields, and let the tool — sitting at the request-time boundary — decorate the response with anything that requires now() or otherwise depends on call time.

The canonical example is the animator-status tool. Its underlying API call is getStatus(), which returns the persisted AnimatorStatusDoc verbatim — the doc never carries a dispatchable field. The tool's response, on the other hand, includes a server-computed dispatchable: boolean derived from the canonical isDispatchable(doc) predicate at request time. Non-TypeScript callers (the Oculus pause banner) read the enriched field; TypeScript callers compose against isDispatchable(doc) directly. The persisted shape stays narrow, the wire shape stays self-contained, and the predicate has exactly one source of truth.

Startup Routines

Before the operational stages below run, start() performs three boot-time preconditions on the apparatus literal: validateBackoffConfig (fail-loud check on the persisted back-off configuration — bad config refuses to boot rather than silently using defaults), setBackoffMachine (registers the back-off machine with the session-record handler so terminal writes can hand the rate-limit signal to the back-off state machine), and setEmitter (registers the lifecycle-event emitter with the same handler so terminal writes also fire animator.session.ended and animator.session.record-failed through one call site). These are wiring, not operational stages; they finish before the numbered list begins.

On startup the Animator runs the following sequence. The start() method is async; it awaits the initial pause-state read up front so peek() reflects persisted state by the time start() returns. DLQ drain and reconciliation run in an async IIFE afterwards (fire-and-forget from Arbor's perspective):

  1. Eager pause-state read — Awaits a single BackoffMachine.read() at the very top of start(). The previous fire-and-forget version allowed the first animate() call to race with the initial load and peek() a default running shape; the awaited read closes that window while keeping animate() synchronous.

  2. DLQ Drain — Scans .nexus/dlq/ for JSON files containing session-record payloads that babysitters couldn't deliver (guild was down). Each file is processed through the session-record handler and deleted on success. The directory is created if it doesn't exist.

  3. Eager Pause-Window Reconciliation — Immediately after DLQ drain (and before orphan recovery / timers), the back-off machine's reconcileOnBoot() runs: if the persisted dispatch-status doc is state: 'paused' AND pausedUntil <= now, it flips to state: 'running', backoffLevel: 0, preserving backoffLastHitAt and lastTriggeringSession for audit. This closes the drift window between persisted and observed state that used to stall post-expiry dispatch on first boot.

  4. Downtime Credit — Reads the previous guild-heartbeat document from the state book to compute how long the guild was down. This credit is applied to the initial reconciliation pass so sessions that were healthy before the guild went down aren't falsely marked as stale.

  5. Heartbeat-based Reconciliation — Scans sessions in pending and running states. When now - lastActivityAt - downtimeCredit > 90s, the session is transitioned to failed. Sessions without lastActivityAt (legacy) are backfilled and skipped for one pass. Runs once at startup (with downtime credit) and then periodically every 30s (without credit) via an unref'd timer with a single-flight guard.

  6. Guild Self-Heartbeat — Writes guildAliveAt to the state book every 30s via an unref'd timer. This timestamp is used to compute the downtime credit on the next startup.

Rate-Limit Back-Off

When the claude-code provider (or any future provider that sets a terminationTag) reports a rate-limited session terminal, the Animator opens a pause window that blocks further dispatch across every caller — Spider, Parlour, and CLI paths alike. The state lives as a single well-known document inside the shared state book at 'dispatch-status' (sibling of the 'guild-heartbeat' doc):

interface AnimatorStatusDoc {
  id: 'dispatch-status';
  state: 'running' | 'paused';
  pausedSince?: string;
  pausedUntil?: string;
  pauseReason?: 'rate-limit';
  backoffLevel: number;
  backoffLastHitAt?: string;
  lastTriggeringSession?: string;
}

State transitions:

  • Rate-limited terminal while running → opens a fresh pause with pausedUntil = now + initialMs, backoffLevel: 0.
  • Rate-limited terminal while paused (no resume dispatch yet) → coalesces; level and bounds unchanged.
  • Rate-limited terminal after a resume attempt dispatched → increments backoffLevel, multiplies the window by factor, caps at maxMs.
  • Any non-rate-limit terminal → resets backoffLevel to 0 and flips state to running.

animate() pre-checks the cached status at the top of the function. When paused and pausedUntil > now, it returns a handle whose result resolves to a synthesized SessionResult { status: 'rate-limited', terminationTag, … } and no SessionDoc is written. In-flight sessions are not proactively cancelled.

Daemon restarts leave the persisted doc untouched; the first dispatch after pausedUntil elapses naturally flips the state back to running (the "natural probe" semantic).

Dispatchability is composed once. The canonical predicate — state === 'running' OR pausedUntil <= now — is implemented as isDispatchable(doc, nowMs?) and re-exported from the package index. TypeScript consumers (Spider's crawl gate, the animator-paused block-type, the animate() pre-check) all import that single helper. Non-TypeScript callers (e.g. the Oculus pause banner) read the server-computed dispatchable: boolean field that the animator-status tool / auto-registered /api/animator/status route enriches onto the response. The persisted doc shape never carries dispatchable — it is a presentation-layer field computed from now() at request time.

SessionDoc — passive termination diagnostic

When a session terminates with exactly status: 'failed' (non-zero exit, no structured termination tag, no cancel override), the provider attaches a passive terminationDiagnostic field to the session record:

interface TerminationDiagnostic {
  /** Process exit code that produced the `'failed'` status. */
  exitCode: number
  /** Tail of the subprocess stderr (<= 200 chars) if any was captured. */
  stderrExcerpt?: string
}

interface SessionDoc {
  // ... other fields
  terminationDiagnostic?: TerminationDiagnostic
}

The diagnostic is informational only — the Animator's back-off state machine does not consume it. It exists so operators can audit the signal that did NOT satisfy the narrowed rate-limit detector. timeout, cancelled, rate-limited, and completed sessions never carry this field.

Exports

The main export provides the apparatus factory, API types, and provider interface types:

import {
  createAnimator,
  type AnimatorApi,
  type AnimateHandle,
  type AnimateRequest,
  type SummonRequest,
  type SessionResult,
  type SessionChunk,
  type TokenUsage,
  type SessionCost,
  type AnimatorSessionProvider,
  type SessionProviderConfig,
  type SessionProviderResult,
  type SessionDoc,
  type TranscriptDoc,
  type TranscriptMessage,
  type AnimatorConfig,
  type AnimatorRateLimitBackoffConfig,
  type AnimatorStatusDoc,
  type AnimatorPauseReason,
  type SessionTerminationTag,
  type TerminationDiagnostic,
} from '@shardworks/animator-apparatus';

The default export is a pre-created apparatus plugin instance:

import animator from '@shardworks/animator-apparatus';
// animator is { apparatus: { requires: ['stacks'], recommends: ['loom', 'oculus', 'clockworks'], provides: AnimatorApi, ... } }

Framework events

The Animator declares three framework-owned events via its supportKit.events kit contribution. The Clockworks merges these names into its authoritative event set at startup, marking them plugin-declared (so unprivileged emit channels — the anima signal tool, the operator nsg signal CLI — reject them). Names follow the catalog (docs/reference/event-catalog.md):

  • animator.session.started — written from every running-state transition site (in-process attached, detached session-running ready report).
  • animator.session.ended — written from every terminal site (in-process attached on completion/failure/timeout/rate-limited, detached session-record, in-process cancel(), orphan recovery). Payload carries exitCode, durationMs, costUsd, and (when present) error.
  • animator.session.record-failed — fired from the catch path of session-doc / transcript writes that themselves failed (the only path the CDC observer on the sessions book cannot see). The phase field follows the catalog's three-phase taxonomy: 'insert' for the initial running row, 'update-row' for terminal-state SessionDoc overwrites (recordSession failure, cancel, orphan recovery, detached terminal), and 'write-record' for transcript writes.

The rate-limit pre-check rejection path does NOT emit — no SessionDoc was authoritatively written.

The Clockworks is in recommends, not requires: the helpers resolve it lazily and silently no-op when it isn't installed, mirroring the summon()LoomApi resolution pattern.

Anima-lifecycle events are deferred until the future Roster apparatus lands — there is no aspirant → active state machine to observe today, so the Animator does not emit any anima.* names.


Internal: SessionDoc transition reducer

Every in-package SessionDoc writer funnels through a single pure-function reducer in src/session-reducer.ts. The reducer encodes the merge invariants once — there is no bespoke per-writer merge code anywhere else in the package.

The reducer accepts an existing: SessionDoc | null | undefined and a SessionTransition (a discriminated union over a kind field) and returns the next SessionDoc to write. Variants:

| kind | Used by | Notes | |---|---|---| | pending-pre-write | claude-code launchDetached() (deferred migration) | Authorization anchor; seeds lastActivityAt. | | attach-running | recordRunning (in-process attached) | Canonical first-time running write. | | detached-ready | session-running tool | Reducer detects the running → running refresh internally. | | heartbeat-touch | session-heartbeat tool, orphan-recovery legacy backfill | Updates only lastActivityAt. | | terminal | recordSession, handleSessionRecord | Carries 'completed' | 'failed' | 'timeout' | 'rate-limited' as a sub-field. | | cancel | AnimatorApi.cancel() | Flips to 'cancelled'; provider cancel() runs at the call site after the put. | | orphan-failed | startup.ts orphan recovery | Does NOT refresh lastActivityAt — the host is presumed dead. |

Invariants encoded once in the reducer:

  • Preserve from existing: startedAt, provider, authorizedTools.
  • Deep-merge: metadata, cancelHandle.
  • Refresh lastActivityAt only from per-variant payload. Variants whose payload carries the field write it; others leave it untouched.
  • No-op on terminal-state regression. Any transition against a terminal-state existing row returns existing unchanged.

The reducer is a pure synchronous function: no I/O, no clock dependency, no emission. Lifecycle event emission stays at the call sites — they compare pre-reducer existing?.status against the post-reducer doc's status to decide whether to emit animator.session.started / animator.session.ended / animator.session.record-failed.

The reducer module also owns the TERMINAL_STATUSES set; the four duplicate locals previously scattered across the package are gone, and rate-limit-backoff.ts derives its NON_RATE_LIMIT_TERMINAL_STATUSES from this consolidated set rather than maintaining a hand-listed inverse.

Reducer audit: tools that stay standalone

The reducer covers every SessionDoc writer. Read-only and display-only surfaces are deliberately not folded through it because they never produce a transition:

  • session-show — pure read by id; the only invariant it adds above sessions.get(id) is translating row-not-found into a thrown error. Folding this through the reducer would require inventing a no-op variant whose behaviour is return existing, which is what get already does.
  • session-list — pure read with filters; same rationale.
  • animator-status — reads the dispatch-status doc (not a SessionDoc) and decorates the response with the request-time dispatchable predicate; no SessionDoc transition is ever produced.

Design decisions index

The animator source carries a long tail of (Dn) traceability tags that anchor source comments and test names to specific design decisions made during the apparatus's lifecycle planning. This index is the canonical mapping; bare (Dn) references in code are intentional shorthand for the rules below. Source-level comments generally inline a one-line rule name in addition to the tag (e.g. // Co-location rule (D3): …); this table is the authoritative source of truth when the names disagree.

| Tag | Rule name | One-line summary | |---|---|---| | D2 | Event-spec discipline | Event entries must declare a non-empty description; schema is omitted while no consumer needs payload validation. | | D3 | Emit co-location rule | The lifecycle emitter and its emit-gate live in the same module (session-emission.ts) so the unprivileged-emit rejection is one read. | | D5 | Heartbeat read+reduce+put | session-heartbeat writes through the reducer (heartbeat-touch variant) rather than sessions.patch, so every SessionDoc writer follows one merge contract. | | D6 | Skip-when-unset rule | getSessionCosts() and similar bulk reads omit ids whose row is missing from the sessions book rather than synthesising a placeholder. | | D7 | Non-rate-limit-terminal reset gate | A non-rate-limit terminal resets backoffLevel only when the session was dispatched after the current pause opened (in-flight stragglers don't count). | | D8 | Coalesce-vs-increment rule | Rate-limit hits arriving while already paused coalesce; only a hit that arrives after a resume probe has dispatched bumps the back-off level. | | D9 | Internal-refresh-detection / already-running refresh path | The reducer detects running → running refreshes internally and preserves metadata, startedAt, and lastActivityAt rather than asking call sites to branch. | | D10 | Patron-override fail-loud config | Animator boot validates the rate-limit back-off block fail-loud — partial overrides are allowed, but malformed values refuse to start rather than silently using defaults. | | D11 | Verbatim getStatus | AnimatorApi.getStatus() returns the persisted AnimatorStatusDoc verbatim — no composed dispatchable field on the API. The tool/HTTP boundary decorates the response at request time. | | D12 | Pre-check rejection (synthesized rate-limit) | animate() rejects at the top with a synthesized SessionResult { status: 'rate-limited', … } when the back-off machine is paused; no SessionDoc is written for the rejected call. | | D13 | Read-existing-first uniformity / best-effort emit | All terminal-write call sites read existing → reduce → put (rather than patching), and emit lifecycle events best-effort after the put succeeds. The animate() pre-check (Step 0) sits at the top of animate() before id generation or any SessionDoc write. | | D16 | Transcript-write-on-duplicate-terminal early return | handleSessionRecord()'s "session already terminal" early return stays at the call site (not folded into the reducer) because the transcript-write side-effect needs the call site to know it's on a no-op path; the reducer's terminal-immutability rule is belt-and-suspenders for direct callers. | | D17 | Legacy-row backfill rule | Orphan-recovery's legacy lastActivityAt backfill writes via the reducer's heartbeat-touch variant rather than sessions.patch, so the legacy row joins the modern merge contract. | | D22 | Eager boot reconciliation of pause-window expiry | start() awaits a single BackoffMachine.read() up front so the first post-start animate() peek reflects the persisted state — a paused doc whose window has elapsed flips to running before the first dispatch. | | D24 | Canonical dispatchability predicate / first-dispatch-flips-state | isDispatchable(doc) is the single source of truth for "may dispatch now" — combines state === 'running' with the pausedUntil <= now window check. Daemon restarts leave persisted state untouched; the first dispatch with pausedUntil <= now naturally flips the state to running via the back-off machine. |

Source-level pointers: rate-limit-backoff.ts's file header lists the back-off rules (D7, D8, D11, D12, D24) it owns; session-emission.ts documents D3 inline; session-record-handler.ts explains D16 inline; startup.ts explains D17 inline. The reducer module (session-reducer.ts) is the load-bearing site for D5, D9, D13, and the read-existing-first uniformity rule. Tests names follow the convention <rule name>: <behaviour> (Dn).