glove-continuum-signal is the runtime substrate for agents that collaborate across time. It supervises Glove agents as node subprocesses, drives their lifecycle, and forwards their event streams upstream — the same way station-signal supervises background jobs. Two execution modes:
.trigger(input), a schedule fire, an inbound mesh message) wakes them. They resume their persistent store, run a turn, return, go cold. Each wakeup spawns a fresh subprocess.runner.notify(name, input); mid-loop pickup is immediate, no spawn latency.The substrate is not an inter-agent protocol. That's glove-mesh's job. Continuum gives mesh a stable per-agent identity, a persistent inbox-capable store, and a long-lived subprocess for warm agents — mesh runs entirely inside that subprocess against whatever transport the consumer's MeshAdapter provides.
pnpm add glove-continuum-signalFilesystemMeshAdapter.For a single in-process agent in a Next.js handler, you don't need continuum — keep using createChatHandler. Continuum earns its keep once you have a fleet.
Define agents the same way you define station signals — a fluent builder ending with .factory(ctx => Glove). The builder forks into mode-specific shapes after .triggered() / .concurrent(), so .retries() / .every() / .withInput() are only available on triggered (a type error otherwise, not a runtime error).
import { agent, z } from "glove-continuum-signal";
import { Glove, Displaymanager } from "glove-core";
import { createAdapter } from "glove-core/models/providers";
import { MyPersistentStore } from "../infra/store.js";
export const pizzaBaker = agent("pizza-baker")
.input(z.object({ orderId: z.string() }))
.output(z.object({ ready: z.boolean() }))
.triggered()
.timeout(60_000)
.retries(2)
.every("5m").withInput({ orderId: "tick" })
.env({ OVEN: "hot" })
.store((name) => new MyPersistentStore(`./agents/${name}.db`))
.onComplete(async (out, in_) => audit(out, in_))
.factory(async (ctx) => {
return new Glove({
store: ctx.store ?? undefined,
model: createAdapter({ provider: "anthropic" }),
displayManager: new Displaymanager(),
systemPrompt: "You bake pizzas.",
compaction_config: { compaction_instructions: "Summarize the conversation so far." },
})
.fold(checkOrderTool)
.build(ctx.store ?? undefined);
});
// Elsewhere — fire-and-forget. Returns a run id immediately.
const runId = await pizzaBaker.trigger({ orderId: "abc-123" });For a concurrent (warm) agent, swap .triggered() for .concurrent(). Concurrent agents expose .notify(input) in addition to .trigger(input)— both enqueue a kind: "notify" run that routes to the warm subprocess.
ContinuumRunner discovers branded agents from agentsDir (or accepts explicit registerAgent(a, filePath) calls), pre-warms concurrent ones at start, supervises with a restart policy, dispatches due triggered/recurring runs from the adapter queue, and translates IPC envelopes from children back into adapter status updates. The parent runner is single source of truth for run status — children never write to the adapter directly.
import {
ContinuumRunner,
MemoryAdapter,
ConsoleSubscriber,
} from "glove-continuum-signal";
const runner = new ContinuumRunner({
agentsDir: "./agents", // auto-discover *.ts / *.js exports
adapter: new MemoryAdapter(), // or your own ContinuumAdapter
subscribers: [new ConsoleSubscriber()],
pollIntervalMs: 1_000,
maxConcurrent: 5, // triggered-run budget
warmRestartPolicy: { maxRestarts: 5, backoffMs: 1_000 },
});
await runner.start();
// Triggered: spawn-per-wakeup. Returns a run id immediately.
const runId = await pizzaBaker.trigger({ orderId: "abc-123" });
const final = await runner.waitForRun(runId);
// Concurrent: routes to the warm subprocess inline.
const notifyId = await runner.notify("pizza-watcher", { event: "oven_ready" });
await runner.waitForRun(notifyId);
await runner.stop({ graceful: true, timeoutMs: 10_000 });Triggered agents need a StoreAdapter that survives across subprocess wakeups, otherwise they lose conversation history every time. Configure one via .store(name => …):
agent("my-agent")
.input(z.object({ /* ... */ }))
.triggered()
.store((name) => new SqliteStore({ dbPath: `./agents/${name}.db` }))
.factory(async (ctx) => new Glove({ store: ctx.store ?? undefined, /* ... */ }).build(ctx.store ?? undefined));Discovery emits a warning for triggered agents that omit .store(...): they default to in-process MemoryStore which resets per-wakeup, defeating the substrate's purpose. Concurrent agents are typically fine with MemoryStore because their subprocess is long-lived — though you still want persistence if the runner can restart.
Mesh is mounted per-agent inside the factory — the substrate exposes no special IPC machinery for it. Each agent supplies its own MeshAdapter (transport is the consumer's choice):
import { mountMesh } from "glove-mesh";
import { makeRedisMeshAdapter } from "../infra/mesh.js";
agent("pizza-watcher")
.input(z.object({ event: z.string() }))
.concurrent()
.store((name) => new MyInboxCapableStore(`./agents/${name}.db`))
.factory(async (ctx) => {
const glove = new Glove({ store: ctx.store ?? undefined, /* ... */ }).build();
await mountMesh(glove, {
adapter: makeRedisMeshAdapter(ctx.name),
identity: { id: ctx.name, name: ctx.name, description: "Watches for oven events." },
});
return glove;
});mountMesh requires an inbox-capable store — getInboxItems / addInboxItem / updateInboxItem / getResolvedInboxItems. Glove's default MemoryStore already implements them; custom stores must too. InMemoryMeshAdapter from glove-mesh only works within a single process — for cross-subprocess agent-to-agent transport, pick a real adapter (Redis, NATS, HTTP webhooks, …) or use the example FilesystemMeshAdapter shipped in the package's tests.
ContinuumSubscriber exposes lifecycle callbacks (onAgentDiscovered, onAgentSpawned, onAgentReady, onAgentTerminated, onAgentRestarted, onRunDispatched, onRunStarted, onRunCompleted, onRunFailed, onRunTimeout, onRunRetry, onRunCancelled, onRunSkipped, onRunRescheduled, onNotifyDelivered, onCompleteError, onLogOutput) plus a single fat onAgentEvent(envelope) that forwards every Glove SubscriberEvent from any child subprocess upstream, wrapped with the agent identity.
import type { ContinuumSubscriber } from "glove-continuum-signal";
const metrics: ContinuumSubscriber = {
onRunCompleted: (e) => stats.inc("runs.completed", { agent: e.run.agentName }),
onRunFailed: (e) => stats.inc("runs.failed", { agent: e.run.agentName }),
onAgentEvent: (env) => {
if (env.event_type === "tool_use") {
stats.inc("tool_calls", { agent: env.agentName, tool: (env.data as any).name });
}
},
};
const runner = new ContinuumRunner({ subscribers: [metrics] });await import()-ed during discovery and runs in a subprocess with the parent's environment. agentsDir should never point at user-influenced content.NODE_OPTIONS, LD_PRELOAD, LD_LIBRARY_PATH, and DYLD_INSERT_LIBRARIES are stripped from the parent env before forwarding, and an agent's .env({...}) cannot override them.notify:* envelope runIds belong to the sending subprocess (pendingNotifies ownership check) — a misbehaving warm child can't spoof another agent's run completion.ready stability, so a long-running deployment doesn't permanently lose its warm agents to occasional blips. Crash-loops still hit the budget and stop trying.Station treats each spawn as a stateless job; continuum treats each spawn as a wakeup of a stateful agent. The key deltas:
.store(name => StoreAdapter) is a builder setter the runtime invokes per wakeup — the agent's context-of-continuity.notify IPC envelopes inline, no spawn cost per message. No equivalent in station-signal — signals are always spawn-per-run.agent:event IPC envelopes), not as relational Step rows.configure() is a module-level singleton; multiple runners in one process race on it. Use runner.notify() when you need to address a specific runner's adapter.cancelled, but the warm subprocess's promise chain keeps running. Plan around it for mutation-critical work.