@lostgradient/lifecycle
v0.0.3
Published
Typed EventTarget, async iterators, observables, hooks, and lifecycle utilities for agent-bureau.
Maintainers
Readme
Lifecycle
lifecycle contains the shared event and hook primitives used across Agent Bureau. It has no agent-domain behavior of its own; it provides the typed infrastructure that other packages use to publish state, expose observables, and compose hooks.
What It Does
- Provides
TypedEventTargetfor strongly typed DOM-style events. - Adds
CompletableEventTargetfor event sources that can close cleanly. - Converts events into async iterators and observable-like subscriptions.
- Forwards events between sources while preserving type information.
- Provides
HookRegistryandmergeHookRegistries()for package-level extension points.
How It Works
The package wraps standard EventTarget behavior in TypeScript types rather than inventing a separate event system. Runtime packages dispatch events through these primitives, and consumers can choose the most convenient consumption style: listener callbacks, async iteration, or observable subscriptions.
Hooks are stored in registries keyed by hook maps. Packages such as operative use that pattern to expose lifecycle customization without hard-coding every integration into the run loop.
Project Role
lifecycle is the shared substrate for observability and extension. conversationalist, armorer, operative, @lostgradient/operative/store, and gateway all rely on event streams or hooks, and this package keeps those mechanics consistent across the workspace.
Public API
TypedEventTarget
Signature: class TypedEventTarget<M extends EventMap> extends EventTarget
A type-safe wrapper around the native EventTarget. M is a record mapping event-type strings to their Event subclasses. addEventListener, removeEventListener, and the added dispatch() method all enforce that the event type exists in M and that the listener receives the correct event subclass.
dispatch(event) is the primary addition over the native API—it accepts only events whose type key is present in M, so typos in event names become compile errors.
import { TypedEventTarget } from 'lifecycle';
class ProgressEvent extends Event {
constructor(public readonly percent: number) {
super('progress');
}
}
class DoneEvent extends Event {
constructor() {
super('done');
}
}
type WorkEvents = {
progress: ProgressEvent;
done: DoneEvent;
};
class Worker extends TypedEventTarget<WorkEvents> {
run() {
this.dispatch(new ProgressEvent(50));
this.dispatch(new DoneEvent());
}
}
const worker = new Worker();
worker.addEventListener('progress', (event) => {
// event is typed as ProgressEvent
console.log(event.percent);
});
worker.run();CompletableEventTarget
Signature: class CompletableEventTarget<M extends EventMap> extends TypedEventTarget<M>
Extends TypedEventTarget with lifecycle management and convenience consumption methods. Internally it holds an AbortController; calling complete() aborts it, which signals all async iterators and observable subscriptions created through this instance to stop cleanly.
Properties and methods:
completed:boolean—trueoncecomplete()has been called.signal:AbortSignal—the underlying signal; pass it to external listeners for coordinated teardown.complete(): Marks the target as done and terminates all active subscriptions and iterators tied to it.on(type, options?): Returns anObservableLike<M[K]>for a single event type, automatically wired to the target's completion signal.once(type, listener): Registers a one-shot listener with{ once: true }tied to the completion signal so it is removed oncomplete()if the event never fires.subscribe(type, observerOrNext?, error?, complete?): Shorthand forthis.on(type).subscribe(...).toObservable(): Returns anObservableLikethat emits every event dispatched on this target and completes whencomplete()is called. This is the integration point used byforwardEvents().events(type, options?): Returns anAsyncIterableIterator<M[K]>for the given event type, terminated by the target's signal.
import { CompletableEventTarget } from 'lifecycle';
type StreamEvents = {
data: MessageEvent;
end: Event;
};
class DataStream extends CompletableEventTarget<StreamEvents> {
async produce() {
for (let i = 0; i < 3; i++) {
this.dispatch(
new MessageEvent('data', { data: `chunk-${i}` }) as MessageEvent & { type: 'data' },
);
}
this.dispatch(new Event('end') as Event & { type: 'end' });
this.complete();
}
}
const stream = new DataStream();
// Async iteration
(async () => {
for await (const event of stream.events('data')) {
console.log(event.data); // 'chunk-0', 'chunk-1', 'chunk-2'
}
})();
// Observable subscription
stream.on('data').subscribe({
next(event) {
console.log('rx', event.data);
},
complete() {
console.log('done');
},
});
stream.produce();eventIterator()
Signature:
function eventIterator<E extends Event>(
target: EventTarget,
type: string,
options?: EventIteratorOptions,
): AsyncIterableIterator<E>;Adapts any EventTarget into an AsyncIterableIterator. Events are buffered in a pull-based queue (default capacity: 256 events). The iterator terminates when the provided AbortSignal fires or when the caller calls iterator.return().
EventIteratorOptions:
signal:AbortSignal—when aborted, the iterator yieldsdone: trueon the nextnext()call and removes its listener.bufferSize:number—maximum number of unconsumed events to buffer before dropping new ones (default256).
import { eventIterator } from 'lifecycle';
const controller = new AbortController();
const target = new EventTarget();
const iterator = eventIterator<MessageEvent>(target, 'message', {
signal: controller.signal,
});
// Consume with for-await
(async () => {
for await (const event of iterator) {
console.log(event.data);
if (event.data === 'stop') break; // calls iterator.return() internally
}
})();
target.dispatchEvent(new MessageEvent('message', { data: 'hello' }));
target.dispatchEvent(new MessageEvent('message', { data: 'stop' }));
// Or terminate from outside
controller.abort();eventObservable()
Signature:
function eventObservable<E extends Event>(
target: EventTarget,
type: string,
options?: EventObservableOptions,
): ObservableLike<E>;Returns a TC39-style ObservableLike for a single event type on any EventTarget. The observable completes when the optional AbortSignal fires or when subscription.unsubscribe() is called.
EventObservableOptions:
signal:AbortSignal—combined with the subscription's own controller viaAbortSignal.any().
import { eventObservable } from 'lifecycle';
const target = new EventTarget();
const controller = new AbortController();
const subscription = eventObservable<MouseEvent>(target, 'click', {
signal: controller.signal,
}).subscribe({
next(event) {
console.log('clicked at', event.clientX, event.clientY);
},
complete() {
console.log('stream closed');
},
});
// Stop listening
subscription.unsubscribe();
// Or let the AbortSignal close it
controller.abort();allEventsObservable()
Signature:
function allEventsObservable<E extends Event>(
target: EventTarget,
eventTypes: readonly string[],
options?: EventObservableOptions,
): ObservableLike<E>;Like eventObservable(), but subscribes to multiple event types at once and merges them into a single stream. All event types share one subscription and one unsubscribe() call.
import { allEventsObservable } from 'lifecycle';
const target = new EventTarget();
const subscription = allEventsObservable<Event>(target, [
'mousedown',
'mouseup',
'click',
]).subscribe((event) => {
console.log(event.type); // 'mousedown' | 'mouseup' | 'click'
});
// One call cleans up all three listeners
subscription.unsubscribe();ForwardedEvent and forwardEvents()
ForwardedEvent
Signature: class ForwardedEvent<TOriginal extends Event = Event> extends Event
A wrapper event that carries an original event under a new prefixed type string. originalEvent holds a reference to the unwrapped event.
constructor(type: string, originalEvent: TOriginal)
readonly originalEvent: TOriginalforwardEvents()
Signature:
function forwardEvents(
source: ForwardableSource,
target: EventTarget,
prefix: string,
options?: { signal?: AbortSignal },
): { stop(): void };Subscribes to every event on source (via source.toObservable()) and re-dispatches each one as a ForwardedEvent on target with the event type prefixed as "${prefix}.${event.type}". Returns a handle with a stop() method to cancel forwarding.
ForwardableSource:
interface ForwardableSource {
toObservable(): ObservableLike<Event>;
}CompletableEventTarget implements ForwardableSource via its toObservable() method, making it the natural source for this function.
import { CompletableEventTarget, ForwardedEvent, forwardEvents } from 'lifecycle';
type ChildEvents = { data: MessageEvent; error: ErrorEvent };
const child = new CompletableEventTarget<ChildEvents>();
const hub = new EventTarget();
// hub will receive 'child.data' and 'child.error' events
const forwarding = forwardEvents(child, hub, 'child');
hub.addEventListener('child.data', (raw) => {
const event = raw as ForwardedEvent<MessageEvent>;
console.log('forwarded:', event.originalEvent.data);
});
child.dispatch(new MessageEvent('data', { data: 'hello' }) as MessageEvent & { type: 'data' });
// Stop forwarding
forwarding.stop();HookRegistry
Signature: class HookRegistry<M extends HookMap>
A priority-ordered registry for named async hooks. M maps hook names to their handler function signatures. Handlers registered with a higher priority number run first. When a handler returns a non-undefined value, that value replaces the first argument passed to all subsequent handlers—enabling a pipeline pattern where each hook can transform the input.
Methods:
on(hookName, handler, options?): Registers a handler. Returns an unsubscribe function.run(hookName, ...args): Executes all handlers for the hook in priority order and returns the final transformed value (orundefinedif no handler returned one).has(hookName): Returnstrueif at least one handler is registered for the hook.clear(hookName?): Removes all handlers for the named hook, or all handlers across every hook when called without arguments.getHandlers(hookName): Returns registered handlers sorted by priority descending—used internally bymergeHookRegistries, and by callers that need to iterate handlers manually (seeonErrorbelow).getHookNames(): Returns all hook names that have at least one registered handler.
Accessors:
onError:HookErrorHandler | undefined—the registry-wide error fallback passed to the constructor, if any.run()already applies this automatically (falling back to it when a handler has no per-registrationonError); this accessor exists for a caller that iteratesgetHandlers()results manually instead of callingrun()—for example to interleave other work between handlers—so it can apply the identicalentry.options.onError ?? registry.onErrorfallback rather than silently bypassing it.
HookRegistrationOptions:
priority:number—higher runs first (default0).onError:HookErrorHandler—called when the handler throws; return'continue'to skip to the next handler or'abort'to rethrow.replay:HookReplayPolicy—documentation-only classification ('safe'|'effectful') for durable-recovery replay semantics; never gates execution at runtime.
HookRegistryOptions:
onError:HookErrorHandler—registry-wide error fallback used when a handler's ownonErroris not set.
import { HookRegistry } from 'lifecycle';
type BuildHooks = {
'before-compile': (source: string) => string | undefined;
'after-compile': (output: string) => void;
};
const hooks = new HookRegistry<BuildHooks>({
onError: (error, { hookName }) => {
console.warn(`Hook ${hookName} threw, continuing`, error);
return 'continue';
},
});
// Register handlers
const unsubscribe = hooks.on('before-compile', (source) => source.trim(), { priority: 10 });
hooks.on('before-compile', (source) => `/* generated */\n${source}`);
// Run the pipeline
const result = await hooks.run('before-compile', ' let x = 1; ');
// result => '/* generated */\nlet x = 1;'
// Remove a specific handler
unsubscribe();
// Check and clear
console.log(hooks.has('before-compile')); // true (one handler remains)
hooks.clear('before-compile');
console.log(hooks.has('before-compile')); // falsemergeHookRegistries()
Signature:
function mergeHookRegistries<M extends HookMap>(
...registries: (HookRegistry<M> | undefined)[]
): HookRegistry<M>;Combines multiple HookRegistry instances into a new registry. Registries listed earlier receive a higher priority offset (increments of 1000 per position), so handlers from the first registry run before those from later ones at equal declared priority. undefined entries are silently skipped, making it safe to spread optional hook sources.
import { HookRegistry, mergeHookRegistries } from 'lifecycle';
type AppHooks = {
'on-request': (url: string) => string | undefined;
};
const coreHooks = new HookRegistry<AppHooks>();
coreHooks.on('on-request', (url) => url.toLowerCase());
const pluginHooks = new HookRegistry<AppHooks>();
pluginHooks.on('on-request', (url) => `${url}?plugin=true`);
const optionalHooks: HookRegistry<AppHooks> | undefined = undefined;
const merged = mergeHookRegistries(coreHooks, pluginHooks, optionalHooks);
const result = await merged.run('on-request', 'https://Example.com/API');
// coreHooks handler runs first (higher offset): 'https://example.com/api'
// pluginHooks handler runs second: 'https://example.com/api?plugin=true'
console.log(result); // 'https://example.com/api?plugin=true'createManualRuntimeServices() identifier seeding
Signature: function createManualRuntimeServices(options?: CreateManualRuntimeServicesOptions): ManualRuntimeServices
createManualRuntimeServices() is the deterministic, injectable implementation of the RuntimeServices contract — a test or a durable-recovery path swaps it in for createDefaultRuntimeServices()'s real-globals implementation. Every identifier its identifiers.next(kind) mints has the shape `${identifierPrefix}-${kind}-${n}`, where n is a per-kind counter starting at 1 and identifierPrefix is a short, stable hash-derivation of the instance's identifierSeed (Coordinator ruling on AB-337).
This means two ManualRuntimeServices instances constructed with different identifierSeeds are, barring a hash collision, extremely unlikely to mint the same identifier — their prefixes almost certainly differ, so their identifier sets are practically disjoint — while two instances constructed with the same identifierSeed still mint byte-identical sequences (AB-92's reproduction guarantee is unchanged).
import { createManualRuntimeServices } from 'lifecycle';
const a = createManualRuntimeServices({ identifierSeed: 'runtime-a' });
const b = createManualRuntimeServices({ identifierSeed: 'runtime-b' });
a.identifiers.next('conversation'); // `${a.identifierPrefix}-conversation-1`
b.identifiers.next('conversation'); // `${b.identifierPrefix}-conversation-1` — practically never collides with a's
const c = createManualRuntimeServices({ identifierSeed: 'runtime-a' });
c.identifiers.next('conversation'); // identical to a's own first `conversation` idOmitting identifierSeed gets a generated, process-unique value instead — this is what keeps two independently constructed runtimes with no explicit seed from colliding too, most concretely a durable run rehydrated in a freshly recovered process (AB-321): the recovered process's runtime never reuses the seed (and therefore the identifier prefix) the crashed process's runtime used. A caller that needs a reproducible sequence across processes or test runs still passes an explicit identifierSeed; both identifierSeed and the derived identifierPrefix are exposed on the returned ManualRuntimeServices so a test can derive an expected identifier instead of pinning a literal string that would drift with the seed.
Supporting Types
These types are re-exported from the package root for use in consuming packages.
EventMap:Record<string, Event>—the constraint for event-map type parameters.ObservableLike<T>: Thesubscribe()interface returned byeventObservable(),allEventsObservable(),CompletableEventTarget#on(), andCompletableEventTarget#toObservable().Observer<T>:{ start?, next?, error?, complete? }—the observer object accepted byObservableLike#subscribe().Subscription:{ unsubscribe(): void; readonly closed: boolean }—the handle returned byObservableLike#subscribe().EventIteratorOptions: Options foreventIterator()(signal,bufferSize).EventObservableOptions: Options foreventObservable()andallEventsObservable()(signal).ForwardableSource:{ toObservable(): ObservableLike<Event> }—the interface required byforwardEvents().HookMap:Record<string, (...args: never[]) => unknown>—the constraint for hook-map type parameters.HookErrorHandler:(error: unknown, context: { hookName: string; handlerIndex: number }) => 'continue' | 'abort'—the error handler type for hooks.HookRegistrationOptions: Per-handler options (priority,onError,replay).HookRegistryOptions: Registry-wide options (onError).HookReplayPolicy:'safe' | 'effectful'—metadata-only classification for durable-recovery replay documentation.
Development
Run package checks from this directory:
bun run validate
bun run build