Continuum (Runtime Substrate)

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:

  • Triggered (asynchronous) — agents are cold by default. An external force (.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.
  • Concurrent (synchronous) — agents are warm in long-lived subprocesses. The runner keeps them alive and pushes notifications inline via 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.

terminalbash
pnpm add glove-continuum-signal

When to use continuum

  • You want agents to keep state across many wakeups (continuity-of-context for triggered agents).
  • You need multiple long-running agents in the same deployment, each with isolated subprocesses but observed centrally.
  • You want to fire agent work from an HTTP handler, cron schedule, or webhook and have it picked up async — like a background job, but the job is a full Glove agent.
  • You want mesh between agents on the same host without standing up an external broker — pair continuum with the example FilesystemMeshAdapter.

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.

The agent builder

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).

agents/pizza-baker.tstypescript
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.

The runner

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.

runner.tstypescript
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 });

Persistent stores

Triggered agents need a StoreAdapter that survives across subprocess wakeups, otherwise they lose conversation history every time. Configure one via .store(name => …):

agents/persistent.tstypescript
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 integration

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):

agents/watcher.tstypescript
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.

Observability

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.

subscribers.tstypescript
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] });

Trust model

  • A registered agent file is await import()-ed during discovery and runs in a subprocess with the parent's environment. agentsDir should never point at user-influenced content.
  • As defense in depth, 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.
  • For warm concurrent subprocesses, the parent validates that notify:* envelope runIds belong to the sending subprocess (pendingNotifies ownership check) — a misbehaving warm child can't spoof another agent's run completion.
  • Warm subprocesses get a per-name restart budget that resets after 60s of post-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.

How this differs from station-signal

Station treats each spawn as a stateless job; continuum treats each spawn as a wakeup of a stateful agent. The key deltas:

  • Stores are first-class. .store(name => StoreAdapter) is a builder setter the runtime invokes per wakeup — the agent's context-of-continuity.
  • Concurrent mode. Long-lived warm subprocesses receive notify IPC envelopes inline, no spawn cost per message. No equivalent in station-signal — signals are always spawn-per-run.
  • Steps dropped. The Glove turn IS the unit of work; fine-grained observability lives on the forwarded subscriber event stream (agent:event IPC envelopes), not as relational Step rows.
  • No adapter reconstruction in children. Since steps are dropped and the parent is single-source-of-truth for status, children never touch the adapter — no manifest forwarding needed.

Limitations (v1)

  • Single-runner only. Multi-runner warm-pool sharding and distributed claim leasing for recurring schedules are deferred to future wrapper packages.
  • 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.
  • A stuck notify in a warm subprocess fails its own run on timeout but doesn't kill the subprocess. Subsequent notifies queue behind it. Restart the warm agent if you observe persistent starvation.
  • Notify cancellation is best-effort — the parent flips status to cancelled, but the warm subprocess's promise chain keeps running. Plan around it for mutation-critical work.