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

@titan-design/hitl

v0.3.0

Published

Human-in-the-loop gate()/resolve() primitive

Downloads

942

Readme

@titan-design/hitl

Pause work on a human, and let a different process resume it. The primitive is "the same work, paused": openGate() from inside a task hands back something to await, and resolveGate() from anywhere else lets it continue.

Tier 1 of the titan-platform DAG (TP-11). zod is a peer (v4).

Two entries

The root entry (@titan-design/hitl) is runtime-neutral: the gate state machine, GateStore, and MemoryGateStore, with no node:* import and no native addon, so it loads in a Cloudflare Workers isolate. SqliteGateStore and its migration helpers moved to a subpath, @titan-design/hitl/sqlite, which is the only part of the package that pulls in better-sqlite3 (via @titan-design/store-sqlite).

Migrating from before 0.2.0: change import { SqliteGateStore, gateMigration } from "@titan-design/hitl" to import { SqliteGateStore, gateMigration } from "@titan-design/hitl/sqlite". Everything else (openGate, resolveGate, GateStore, MemoryGateStore, the error classes) still comes from the root.

A gate is a row, not a promise

The promise wait() returns is a local convenience. The gate itself is a database row, and that is what makes the primitive worth having: the process that opened the gate can crash, redeploy, or exit, and the pending gate is still there to be answered. A gate is addressable by id from any process, which is the whole point. The waiter polls, because the resolver may be a different process writing the same SQLite file.

import { openDatabase } from "@titan-design/store-sqlite";
import { SqliteGateStore } from "@titan-design/hitl/sqlite";
import { openGate } from "@titan-design/hitl";
import { z } from "zod";

const store = new SqliteGateStore(openDatabase("~/.local/state/thing/gates.sqlite3"));

const gate = openGate(store, {
  id: "deploy-approval",
  prompt: "Ship 1.4.0 to production?",
  schema: z.object({ approved: z.boolean(), note: z.string().optional() }),
  expiresAt: new Date(Date.now() + 3_600_000),
});

const answer = await gate.wait(); // { approved: true }

Somewhere else entirely, in a CLI, an MCP tool, or a dashboard route:

import { resolveGate, cancelGate } from "@titan-design/hitl";

resolveGate(store, "deploy-approval", { approved: true });

After a restart, re-attach by id instead of re-opening:

import { waitForGate } from "@titan-design/hitl";

for (const pending of store.listPending()) {
  void waitForGate(store, pending.id, { schema: approvalSchema });
}

Two schema checks, on purpose

The resolving process does not have your zod schema; by definition it is somewhere else. So openGate stores the schema as JSON Schema (z.toJSONSchema) on the row, and resolveGate runs checkAgainstJsonSchema against it. That is the boundary check: a bad payload is rejected where it is submitted, with a GatePayloadInvalid listing the offending paths.

The waiter then re-validates with the real zod schema before wait() resolves. That is the type guarantee. The checker covers only the subset zod emits (type, required, properties, items, enum, const, anyOf, additionalProperties: false) and is not a general JSON Schema validator.

Stores

Both implementations extend BaseGateStore, which owns every settle rule, and both pass the same behaviour suite.

| Store | Use when | |---|---| | MemoryGateStore | tests, and single-process work that needs the pause but not the durability | | SqliteGateStore | anything that must survive a restart or be answered by another process |

SqliteGateStore installs a hitl_gate table through store-sqlite's runMigrations on construction. Pass migrate: false and put gateMigration(n) in the product's own migration list when hitl shares a database with domain tables. table renames the table so one database can host several gate spaces. Timestamps are ISO-8601 strings, the shape store-sqlite writes and any surface can send on as-is.

The row carries a reason column beyond the minimum, so a cancelled gate can tell its waiter why.

Who resolved it

resolve and resolveGate take an optional third argument, a GateResolver: { class, id, channel, confirmEvent? }. class is an actor class from @titan-design/authority. The store records it as resolvedBy.

resolveGate(store, "deploy-approval", { approved: true }, {
  class: "owner-terminal",
  id: "owner",
  channel: "cli",
});

Every store refuses a resolver whose class is not in authority's RESOLVER_CLASSES, so an agent or automation never answers a gate. It throws GateResolverRefused and the gate stays pending. The store reads each declared resolver field once into a frozen copy, and checks and stores only that copy. A store's authorize option runs after the class check and can refuse more, never fewer. It must return { allowed } synchronously, or the store throws GateAuthorizeInvalid. With authorize installed, a resolve that names no resolver is refused. Refusals name the gate id and the actor class, never the resolver's other fields.

hitl records a claim about the resolver; it cannot prove one. Any process that can write the database can claim any class.

On SQLite the resolver lives in a resolved_by column that gateResolverMigration(n) adds, along with a trigger that refuses any resolve naming no resolver. Add it to your own migration list after gateMigration. It is idempotent and does not backfill: gates resolved before it read back with resolvedBy undefined. Until the migration runs, a store resolves without a resolver as before and throws GateStoreSchemaOutdated when given one, rather than dropping it. migrate: true does not run it yet.

After the migration, a writer built on hitl 0.2.x fails when it resolves: SQLite aborts the statement with a raw error whose message is hitl: resolvedBy required. The same trigger refuses a direct insert of a resolved row with no resolver. Cancels from an old writer still work. The fix is to upgrade that writer so it passes a resolver.

Settling

A gate is pending, then exactly one of resolved, cancelled, or expired. A second resolve or cancel throws GateAlreadySettled and leaves the first answer intact.

Expiry is lazy: nothing sweeps the table, so a read is what notices the deadline passed and flips the row to expired. The instant named by expiresAt counts as expired. Both stores take an injectable now so expiry is testable without waiting.

wait rejects with GateCancelled, GateExpired, GateNotFound, GatePayloadInvalid, or GateAborted (when the caller's AbortSignal fires). Every one of them extends GateError and carries gateId. Aborting a wait does not touch the gate; the row stays pending for whoever picks it up next.