Skip to content

Latest commit

 

History

History
227 lines (176 loc) · 9.89 KB

File metadata and controls

227 lines (176 loc) · 9.89 KB
title Plugins and the registry
description How a plugin is assembled rather than constructed, and every way mount() refuses.
group How it works
order 3

Plugins and the registry

A plugin is a package exporting one object:

export const plugin: Plugin = {
  name: "@amykit/plugin-file-queue",
  version: "0.1.0",
  configSchema,
  register(registry, ctx) {
    registry.queue(new FileQueue(path.join(ctx.paths.state, ctx.config.directory as string)));
  },
};

That is the entire contract. Everything below is what the host does with it.

Assembled, not constructed

Plugins are loaded from the config and assembled, not built by the CLI:

const loaded = await load(specs);              // import each by name
const outcome = await mount(loaded.plugins, config, host);

load imports each spec — a package name, or a path — and takes its plugin export. Nothing is compiled in, which is what lets an install carry a plugin this repository has never heard of, and lets a machine skip the ones it has no use for.

A package that is not there is told apart from a plugin that threw, and answered once with the list of what is installed:

amy could not start:
  @acme/plugin-jira: not installed — install it, or drop it from the config

Installed: @amykit/plugin-claude, @amykit/plugin-file-queue, …

Every way mount() refuses

All of these happen at boot, by name, before any work is touched. That is the point of assembling rather than constructing: the alternative is finding out halfway through somebody's ticket.

Refusal Message
A plugin will not import not installed — install it, or drop it from the config
A plugin imported but exports nothing imported, but exports no \plugin``
A plugin threw while registering failed to mount — <what it said>
A setting is the wrong type `retentionDays` must be number, got string — <what the field is for>
A required setting is missing `repos` is required — <what the field is for>
A setting is not one it declared `retentionDay` is not a setting this plugin has
A plugin with no schema was given settings has no settings, but the config gives it some
Two plugins claim the same port the `tracker` port is already mounted by another plugin
Two plugins claim the queue, store, engine or workflow the queue is already mounted by another plugin
Two contributions collide in one collection `claude` is already in the `agent` collection
The workflow emits an action nothing defines action `escalate`: nothing defines it
An action's port is not mounted action `triage`: needs the `agent` port, which nothing mounted
An observation nothing contributes observation `tracker`: nothing contributes it

Every problem is reported, not just the first. One boot fixes one round of edits.

What a plugin is lent

interface PluginContext {
  readonly config: Record<string, unknown>;   // its own slice, already validated
  readonly runner: CommandRunner;             // the only way to a child process
  readonly now: () => Date;                   // never `new Date()`, so tests can drive time
  readonly log?: EventLog;
  readonly paths: HostPaths;                  // { workspace, state }
  contributions(collection: string): ReadonlyMap<string, object>;
  port(kind: PortKind): object | undefined;
  workflow(): Workflow<never, never> | undefined;
}

The last three are live views, and that matters more than it looks. A plugin that composes others cannot see contributions made after it, so it reads them when it is used rather than when it is mounted. Otherwise the order plugins appear in the config would be something an operator has to get right.

The five things a plugin can register

interface Registry {
  queue(impl): void;
  store(impl): void;
  engine(impl): void;
  workflow(impl): void;
  port(kind, impl): void;
  action(name, spec, port): void;
  observer(slice, source): void;
  contribute(collection, name, impl): void;
}

A port is a named slot. One plugin fills it; a second one trying is a refusal. See Ports.

An action the core does not have may be registered, and when it is, the plugin has to bring the port that runs it in the same package. The pair is inseparable on purpose: an action nobody can execute is a promise the machine cannot keep. If such an action proves general it graduates into the core, by evidence rather than guess.

An observation is one named slice of what a workflow reads before deciding.

A contribution adds to a named collection that some other plugin consumes.

Collections

The core does not know what a collection means, only that several plugins may add to one and something else will read it. It is how the notification channels reach the fan-out without the core learning the word "channel".

Collection Contributed to by Read by
agent claude@amykit/plugin-claude
codex@amykit/plugin-codex
hermes@amykit/plugin-hermes-agent
@amykit/agent-kit
harness claude@amykit/plugin-claude
codex@amykit/plugin-codex
hermes@amykit/plugin-hermes-agent
@amykit/agent-kit
notify-channel hermes@amykit/plugin-notify-hermes
inbox@amykit/plugin-notify-inbox
tracker@amykit/plugin-linear
whichever plugin reads it
workflow-runtime errand@amykit/workflow-errand
note-to-plan@amykit/workflow-note-to-plan
ticket-to-qa@amykit/workflow-ticket-to-qa
@amykit/agent-kit

A collection is read when it is used, not when it is mounted, so the order plugins are listed in does not matter.

ready(), and why it exists

ready?(ctx: PluginContext): void | Promise<void>;

Checked once every plugin has registered, and only for a plugin whose settings cannot be judged before then.

The agent relay is the case it exists for. Its ladder: [claude:sonnet, codex:gpt-5] names harnesses that other plugins contribute, and it cannot tell whether that list makes sense until they have. Throwing in ready is a refusal at boot, named — the same promise register makes.

A ladder that quietly meant less than it says would first show up as a ticket escalating for no reason.

Config schemas

A plugin declares what its settings look like, and the host validates them without knowing what any of them mean:

export const configSchema: ConfigSchema = {
  directory: {
    type: "string",
    description: "where the queue is kept, relative to the state directory",
    default: "queue",
  },
  retentionDays: { type: "number", description: "…", default: 7 },
};

Five types only — string, number, boolean, string[], record — and that ceiling is deliberate. A plugin that needs more shape than this wants its own validation, not a bigger schema language in the host.

The description is not decoration: it is printed when the field is missing or wrong. "invalid config" with nothing else in it is the error message that costs an hour.

Singletons, and the trap in them

The exported plugin is a module singleton. A field on it would be shared by every host in the same process, and the second mount would answer with the first one's state.

The shipped plugins that build something expensive key it by context:

const relays = new WeakMap<PluginContext, AgentRelay>();

The context is per mount, which makes it the right key. If your plugin holds anything built from ctx, do the same.

Every shipped plugin

Plugin What it is Mounts Contributes
@amykit/plugin-agent-relay One agent made of several: swaps harness on a quota, escalates model on a failure. agent
@amykit/plugin-claude The claude CLI as the agent, with git on the side. agent:claude
harness:claude
@amykit/plugin-codex The codex CLI as the agent, over its JSONL event stream. agent:codex
harness:codex
@amykit/plugin-command Any command line tool, reached by a name the config allows. commands
@amykit/plugin-command-gate A gate that runs the target repository's own commands. gate
@amykit/plugin-file-notes Friction as a directory of notes: written by hand, by a hook, or by a tick that failed. notes
@amykit/plugin-file-queue A queue kept as one file per item, claimed by rename. queue
@amykit/plugin-file-store Work records kept as one file per item. store
@amykit/plugin-file-tasks Tasks as a directory of files: written by amy btw, by an editor, or by a hook. tasks
@amykit/plugin-github GitHub as the code host, through the gh CLI. code-host
@amykit/plugin-hermes-agent Hermes as the agent, over its one-shot mode and usage report. agent:hermes
harness:hermes
@amykit/plugin-linear Linear as the tracker, over its GraphQL API. tracker notify-channel:tracker
@amykit/plugin-notify-fanout Sends one announcement down every configured channel, and keeps going when one is down. notifier
@amykit/plugin-notify-hermes Announcements over Hermes, which already owns the messaging credentials. notify-channel:hermes
@amykit/plugin-notify-inbox Announcements as a file on disk plus a desktop notification. notify-channel:inbox
@amykit/plugin-plan-check The quality bar for a drafted plan: the repository's own check, run in its checkout. plan-check
@amykit/plugin-serial-engine Advances one work item by one move per tick. the engine

Writing one

Write a plugin goes from an empty package to a proven one, including the gate that expires when you change it.