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

@compforge/baton-plugin

v0.8.2

Published

Public protocol and authoring types for Baton plugins.

Readme

Public, host-independent authoring contract for Baton plugins.

import {
  type ConditionedStatus,
  type Resource as ExampleResource,
  type PluginContext,
  type PluginPackage,
} from "@compforge/baton-plugin";

const EXAMPLE_RESOURCE = {
  apiVersion: "example.baton.dev/v1alpha1",
  kind: "Example",
  shortNames: ["ex"],
} as const;

// Creation may attach Plugin-defined string metadata:
// await context.resources.create(EXAMPLE_RESOURCE, {
//   name: "example-1",
//   labels: { "example.com/team": "platform" },
//   annotations: { "example.com/display-name": "First example" },
//   spec: { title: "Hello" },
// });

interface ExampleStatus extends ConditionedStatus {
  readonly phase?: "active" | "done";
}

type Example = ExampleResource<{ title: string }, ExampleStatus>;

const plugin: PluginPackage = {
  pluginId: "example/plugin",
  version: "0.1.0",
  async activate(context: PluginContext) {
    context.logger.info("Example plugin activated", {
      component: "activation",
      attributes: { resourceTypes: [EXAMPLE_RESOURCE.kind] },
    });
    context.toast.show({ text: "Example plugin ready", tone: "success" });
    context.commands.register({
      commandId: "examples",
      name: "examples",
      description: "List examples",
      async execute() {
        return {
          kind: "picker",
          title: "Examples",
          search: {
            mode: "remote",
            query: "",
            placeholder: "Search examples",
          },
          options: [{ name: "Hello", value: "hello" }],
        };
      },
    });
    context.mentions.register({
      namespace: "example",
      async search(query) {
        const resources = await context.resources.list<
          { title: string },
          ExampleStatus
        >(EXAMPLE_RESOURCE);
        return resources
          .filter((resource) =>
            resource.spec.title.toLowerCase().includes(query.toLowerCase())
          )
          .map((resource) => ({
            id: resource.metadata.name,
            label: resource.spec.title,
            description: resource.status.phase,
          }));
      },
      async resolve(id, { maxChars }) {
        const resource = await context.resources.get<
          { title: string },
          ExampleStatus
        >(EXAMPLE_RESOURCE, id);
        return `Example: ${resource.spec.title}`.slice(0, maxChars);
      },
    });
    context.controllers.register({
      resourceType: EXAMPLE_RESOURCE,
      sources: [{
        type: "cron",
        sourceId: "periodic-refresh",
        cron: "*/5 * * * *",
        timeZone: "UTC",
      }],
      async reconcile(_ctx, resource: Example) {
        // Observe current facts and patch status through context.resources.
      },
      async present(resource) {
        return {
          title: resource.spec.title,
          url: resource.status.url,
          status: resource.status.phase,
          priority: resource.status.phase === "blocked" ? 100 : 0,
        };
      },
    });
  },
};

export default plugin;

This package contains protocol types only. Baton host implementations such as Daemon, Plugin Host, Worker, Manager, Binding, Controller, Store, Marketplace, persistence, and Harness routing are intentionally excluded.

One enabled Plugin instance has one Binding and one Worker. A Plugin is the organization and ownership unit for Resource schemas, Controllers, Sources, and Connectors; it does not declare a scope or namespace. Each Resource carries its own canonical namespace, so one Plugin may manage global, Project, and Session Resources at the same time. Omitting namespace from create() or emit() uses the user-global v1 namespace.

await context.resources.create(EXAMPLE_RESOURCE, {
  name: "project-example",
  namespace: "v1/project/project-a",
  spec: { title: "Project example" },
});

const everyExample = await context.resources.list(EXAMPLE_RESOURCE, {
  namespace: "v1",
  includeDescendants: true,
});

present() may return a finite numeric priority; higher values are shown first and omitted values default to 0. Baton compares priority only within the same Plugin instance and Resource type, then shows at most five items from that group. Returning undefined still hides the Resource from the Board entirely. When url is present, compatible terminal UIs render the title as a native hyperlink that users can open with their terminal's link-click gesture. Detail overflow is handled consistently by the UI rather than configured by individual Plugins.

Plugins may opt into Kubernetes-style current-state conditions by extending ConditionedStatus. conditions remains optional and lives inside the Plugin-owned status schema; Baton stores it but does not interpret condition types, reasons, transitions, or lifecycle policy. Keep at most one current condition per type, and update lastTransitionTime only when that condition's status changes.

context.toast is session-scoped and non-durable. Use it for one-off feedback caused by an operation or state transition. Ongoing state belongs in Resource status and an optional Board presentation; do not emit a toast on every reconcile.

context.logger writes best-effort diagnostics to the owning BatonSession. Baton adds the Plugin identity and owns the log path and persistence. Use debug/info/warn/error(message, context) with structured JSON attributes for troubleshooting context. Never include secrets or use diagnostics as Resource state. Keep lifecycle and aggregate results at info, entity details at debug, and deduplicate repeated polling output.

context.mentions.register exposes searchable, read-only context that a user can explicitly add to one Harness turn with @. namespace is local to the Package; Baton qualifies it as <pluginName>@<namespace>, groups picker candidates by that identity, and removes the registration with the Plugin Binding. Keep search local and side-effect free; resolve runs only after the user selects a reference and submits the turn.

context.hooks.register subscribes a Plugin to Core coordination boundaries:

context.hooks.register({
  hookId: "observe-view-input",
  stage: "view.input",
  async run(hook) {
    if (hook.subject.input.kind !== "prompt") return;
    const decision = await hook.verbs.confirm({
      title: "Autopilot",
      prompt: `Inspect ${hook.subject.inputId}?`,
      timeoutMs: 30_000,
    });
    // The Hook itself returns no decision or replacement payload.
    // Any effect is requested through hook.verbs.
    void decision;
  },
});

Hook exposes the four stable View and Harness IO boundaries:

| stage | subject | delivery | |---|---|---| | view.input | durable ViewInputRecord | inline, before Core lowering | | view.output | published ViewOutput | deferred, after publication | | harness.input | read-only HarnessInputDispatch | inline, before the Adapter call | | harness.output | committed BatonEventReference | deferred, after Event commit |

Inline stages wait for every matching Hook concurrently. Failure or timeout is logged and fails open. Deferred stages use a bounded, best-effort host queue and never extend the main IO path. view.output means Baton published a View update; it does not prove that a person saw it.

The Hook itself returns no replacement, allow/deny decision, or output value. Patch explicitly writable Core Resource fields through context.resources, and request other effects through typed hook.verbs; Core then owns authorization, persistence, routing, and lifecycle. Core mechanics such as input.settled and Adapter admission remain Event, Attempt, or Snapshot facts instead of becoming additional Hook stages.

Core registers its Resource GVKs before Plugin activation. Plugins read them through the same ResourceReader used for Plugin-owned Resources; a later registration of the same apiVersion + kind fails instead of replacing Core. For example, an inline view.input Hook may select one eligible Target for the current Session without introducing another Hook stage:

const BINDING = {
  apiVersion: "baton.dev/v1alpha1",
  kind: "SessionTargetBinding",
} as const;
const TARGET = {
  apiVersion: "baton.dev/v1alpha1",
  kind: "Target",
} as const;

const [binding] = await context.resources.list(BINDING);
const target = (await context.resources.list(TARGET)).find(
  (candidate) => candidate.metadata.name === "codex2",
);
if (binding && target) {
  await context.resources.patch(binding, {
    type: "merge",
    value: {
      spec: {
        targetRef: {
          ...TARGET,
          namespace: target.metadata.namespace,
          name: target.metadata.name,
          uid: target.metadata.uid,
        },
      },
    },
  });
}

Session, Target, and Turn are read-only projections. The SessionTargetBinding provider accepts only spec.targetRef for the current Session, checks resourceVersion, and rejects targets outside its eligible set.

Controller Sources have two narrow roles:

  • A Source performs initial discovery, installs live subscriptions, and calls emit(resource) when it observes a Resource owned by that Controller. Baton materializes a missing Resource, treats an identical repeated value as a keyed wakeup, and rejects an implicit spec change. start() resolves only after the initial scan and subscription are ready; the owning Binding aborts its signal on close.
  • A CronSource periodically enqueues every current Resource owned by that Controller.

Both paths use the same keyed reconcile queue as Resource changes and requeueAfterMs. Sources never update status or use reconcile capabilities; those remain exclusively owned by reconcile.

Resources may set one metadata.owner when they are created or emitted. The owner must be an existing Resource in the same concrete Resource namespace and must include its uid, so a replacement with the same name does not inherit dependents. Use this only for structural ownership, not discovery provenance or domain references.

Labels are constrained, machine-readable selection metadata:

const openForgeTasks = await context.resources.list(TASK, {
  matchLabels: {
    "example.baton.dev/source": "forge",
    state: "open",
  },
});

Every matchLabels entry must match exactly. Annotations are opaque string metadata and are not selectable. Use patchMetadata() to update either map by key; a null value removes that key without replacing unrelated entries.

Use get(ref) to read a Resource. Omit uid for a name-based lookup that may resolve the current incarnation. Include uid when continuing work from an earlier observation, especially after awaiting a Core verb:

const current = await context.resources.get({
  ...EXAMPLE_RESOURCE,
  namespace: resource.metadata.namespace,
  name: resource.metadata.name,
  uid: resource.metadata.uid,
});
if (!current) return; // Deleted or replaced while this continuation waited.

get(ref) returns undefined when the name is absent or its uid no longer matches. Omitting uid intentionally performs a name-based lookup that may resolve a replacement. Namespace isolation is still enforced.

ResourceClient.delete() requests deletion. Baton persists metadata.deletionTimestamp, cascades the request to structural descendants, hides terminating Resources from the Board, and removes each Resource after its Controller reconciles successfully. A failed terminating reconcile keeps the Resource durable and uses the normal retry path.

Controllers may also declare watches to map changes from secondary Resources to primary ReconcileRequests:

import type {
  EventHandler,
  EventResource,
  ReconcileRequest,
} from "@compforge/baton-plugin";

function requests(workspace: EventResource): readonly ReconcileRequest[] {
  const status = workspace.status as {
    readonly repositories?: readonly string[];
  };
  return (status.repositories ?? []).map((name) => ({ name }));
}

const repositories: EventHandler = {
  async create(event) {
    return requests(event.object);
  },
  async update(event) {
    return [...requests(event.oldObject), ...requests(event.newObject)];
  },
  async delete(event) {
    return requests(event.object);
  },
};

context.controllers.register({
  resourceType: "Repository",
  watches: [{ resourceType: "Workspace", handler: repositories }],
  async reconcile(_ctx, repository) {
    // Reconcile repository.metadata.name from the latest stored state.
  },
});

For an update, map both the old and new Resource so removed relationships still wake their former owner, then deduplicate the resulting requests. A Watch routes Resources already stored by Baton; a Source discovers external state and materializes the primary Resource before it is reconciled.

A Controller receives a ReconcileContext, reads current facts from ctx.snapshot, and can await a typed user decision directly:

const decision = await ctx.verbs.ask({
  title: "Associate pull request",
  prompt: "Which requirement should own this pull request?",
  timeoutMs: 10 * 60_000,
  choices: [
    { value: "req_1", label: "REQ-1" },
    { value: "standalone", label: "Do not associate" },
  ],
});
if (decision.state !== "success") {
  // dismissed, timeout, or failure: apply the domain's fallback policy.
  return;
}
await associate(decision.value);

A command can return search.mode: "local" to let chat-tui filter its current options, or "remote" to receive later query text in PluginCommandInput.searchQuery. Baton debounces remote queries and ignores responses superseded by a newer query. A remote result may contain no options; return the same remote-search picker shape so the field stays open.

The Promise stays pending until the user answers, dismisses the Interaction, or the required timeout expires. Baton preserves the current async continuation, but releases both the Controller concurrency slot and the Manager-wide slot while it waits, so other Resources can reconcile. The result is persisted before the original continuation resumes; Baton does not re-enqueue the Resource to deliver the answer.

Every verb returns the same closed outer outcome:

type VerbResult<T> =
  | { state: "success"; value: T }
  | { state: "dismissed" }
  | { state: "timeout" }
  | { state: "failure"; error?: string };

dismissed means the user saw the Interaction and pressed Esc or closed it. A deliberate negative answer, such as declining a confirmation, is a successful business value. The Plugin decides how each non-success outcome degrades.

ReconcileContext.verbs contains typed Core verbs rather than generic messages. Every verb first materializes a Core-owned Interaction. draft continues only after its suggested input is submitted; harness continues only after its mandatory gate is approved. A host policy may auto-approve that gate, but Baton still persists the requested and answered Interaction facts before creating a HarnessInvocation. Plugins cannot select a topic, provide a routing callback, or pass a Harness-native DTO through Core.

Each choice value is the stable answer value persisted by Baton and returned as decision.value; label and description are presentation only. A closed choice ask preserves the literal union of those values. Setting allowOther: true permits arbitrary non-empty text and therefore widens the answer type to string; omit choices and set allowOther: true for a pure free-text ask.

Baton gives each live reconcile a Core-issued Plugin execution identity. Verb continuation is correlated with that execution, not with the triggering Resource and not with a caller-provided operation key. Resource deletion does not implicitly dismiss an Interaction. If the Worker or Core crashes, the in-memory continuation is not replayed and its unfinished verb becomes failure.

A Controller uses ctx.verbs.draft to let the user edit a prompt, or ctx.verbs.harness to enqueue a ready prompt for Harness execution. Both pass through Interaction; only an approved/submitted result creates the HarnessInvocation. The Plugin explicitly declares the Lane for execution:

  • laneId names an existing Lane to continue. main is the reserved main Lane ID.
  • newLane: true allocates a new asynchronous Lane from laneId; omitted or false continues that Lane.

Baton owns target selection, admission, execution, cancellation, ledger and recovery. Lane is a Baton-native task line rather than a Plugin-private execution type:

An explicit draft.harnessTargetId fixes the Target and is shown with the editable draft. When omitted, Baton resolves the host's current selection when the user submits the draft and persists that final Target before scheduling. An omitted harness.harnessTargetId is resolved immediately because direct execution has no editing phase.

const execution = await ctx.verbs.harness({
  title: "Implement",
  prompt: "Implement the example and run its focused tests.",
  timeoutMs: 30 * 60_000,
  laneId: "main",
  newLane: true,
});
if (execution.state !== "success") return;
if (execution.value.outcome === "declined") return;
// Inspect execution.value.turn.stopReason and update Resource status.

The successful result's laneId is the actual execution Lane and can be passed to a later call to continue the same side task. completed means the Turn closed, not that domain acceptance succeeded. A manual gate rejection returns { state: "success", value: { outcome: "declined" } }. Closing a draft or pressing Esc returns dismissed and creates no HarnessInvocation when the gate has not passed. Dispatch errors and process interruption return failure.

timeoutMs is mandatory on ask, confirm, draft, and harness. For an action verb, one deadline covers its Interaction gate, admission, and final Turn; approval does not reset the clock. Its value must be an integer between 1 and MAX_VERB_TIMEOUT_MS (2,147,483,647 milliseconds).