@aivi/plugin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,30 @@
1
- # Temporary Holding Version
1
+ # @aivi/plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ This is the package you import to write an aivi plugin — not the package
4
+ `aivi install` installs. A plugin is a plain npm package that exports a
5
+ module the host composes, a `./setup` entry `aivi install` runs, and a
6
+ `./cli` entry whose commands join `aivi --help`. The kit holds every
7
+ contract a plugin author needs and nothing more; the host stays the
8
+ runtime that implements them. The seam words — tracker, forge adapter,
9
+ platform adapter, project contributor — are defined in
10
+ [docs/vocabulary.md](docs/vocabulary.md).
11
+
12
+ ## Exports
13
+
14
+ | Path | Contents |
15
+ | --- | --- |
16
+ | `@aivi/plugin` | the contracts: `PluginSetupContext`/`PluginSetupResult`/`PluginSetupCancelled` (the install-time subpath), `PluginCliContext` and `resolveBlocked` (the runtime one), and the module contract under its authoring names (`AiviModule`, `AiviServices`) |
17
+ | `@aivi/plugin/api` | `createHostClient` — the fetch-only HTTP client for the aivi server, the one piece of runtime code a guest in someone else's process (the OpenCode plugin) may carry |
18
+ | `@aivi/plugin/channel` | the channel kind's vocabulary: `ChannelModule` and the conversation-binding types — types only; the shared inbox, engine and turn runner are the host's |
19
+
20
+ Rules the kit keeps:
21
+
22
+ - The kit depends on `@aivi/core` only — never on `@aivi/host` at runtime.
23
+ Where a contract names a runtime type (`Store`, `ConversationStore`), the
24
+ import is type-only: the host implements the bag, plugins receive it typed.
25
+ - Prompts: the runner hands over its own `@clack/prompts` module instance
26
+ (`ctx.prompts`); whoever is called owns the screen. There are no `ask.*`
27
+ wrappers — two clacks animating one terminal mangle each other.
28
+ - A plugin kind gets a subpath (`./channel`, later `./tracker`); it is
29
+ promoted to its own package only if the kind ever needs runtime-shared
30
+ code.
package/dist/api.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { HostClient } from '@aivi/core';
2
+ export interface HostClientOptions {
3
+ /** Bearer token identifying the caller (a person token). Omit to stay anonymous. */
4
+ token?: string | undefined;
5
+ }
6
+ /**
7
+ * Fetch-only client for the aivi host API. Used by the OpenCode plugin, so it
8
+ * must stay free of SQLite, QMD, and other host-side dependencies.
9
+ */
10
+ export declare function createHostClient(baseUrl: string, options?: HostClientOptions): HostClient;
package/dist/api.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The HTTP client for the aivi server, for whoever talks to it from outside:
3
+ * the OpenCode-side plugin (a guest in someone else's process) and a plugin's
4
+ * own tool surface. It ships in the kit, not in the host, so reaching the
5
+ * server never pulls SQLite, QMD or other host-side weight into the caller.
6
+ */
7
+ import { createRequire } from 'node:module';
8
+ /**
9
+ * The version of this `@aivi/plugin` installation — read from its own
10
+ * `package.json` at runtime, so the number can never drift from the package
11
+ * that carries it. It travels as `x-aivi-client`; the host serves a client at
12
+ * or behind its own version, and `@aivi/cli`, `@aivi/host` and `@aivi/plugin`
13
+ * move in lockstep through the changesets `fixed` group, so the two sides of
14
+ * the wire agree. The gate's rules are in the host's `api/gate.ts`.
15
+ */
16
+ const clientVersion = createRequire(import.meta.url)('../package.json').version;
17
+ /**
18
+ * Fetch-only client for the aivi host API. Used by the OpenCode plugin, so it
19
+ * must stay free of SQLite, QMD, and other host-side dependencies.
20
+ */
21
+ export function createHostClient(baseUrl, options = {}) {
22
+ const base = new URL(baseUrl);
23
+ if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password) {
24
+ throw new Error('Expected an HTTP host URL without embedded credentials');
25
+ }
26
+ const headers = {
27
+ // The version the host negotiates against; a host that cannot speak to
28
+ // this client answers 403 with the version that would pass, and the
29
+ // error below carries that answer. Read from this package's own
30
+ // package.json: this module must stay free of SQLite, QMD, and other
31
+ // host-side weight.
32
+ 'x-aivi-client': clientVersion,
33
+ ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
34
+ };
35
+ async function request(path, init) {
36
+ const { timeoutMs, ...rest } = init;
37
+ let response;
38
+ try {
39
+ response = await fetch(new URL(path, base), {
40
+ ...rest,
41
+ headers: { ...headers, ...rest.headers },
42
+ signal: AbortSignal.timeout(timeoutMs),
43
+ redirect: 'error',
44
+ });
45
+ }
46
+ catch (error) {
47
+ // Every aivi tool goes through the host; the usual cause is simply that `aivi serve` is not running.
48
+ if (error instanceof Error && error.name === 'TimeoutError')
49
+ throw error;
50
+ throw new Error(`aivi is not reachable at ${base.origin}. Is \`aivi serve\` running?`, { cause: error });
51
+ }
52
+ if (!response.ok)
53
+ throw new Error(await describeFailure(response));
54
+ return (await response.json());
55
+ }
56
+ const get = (path) => request(path, { timeoutMs: 10_000 });
57
+ return {
58
+ search(query) {
59
+ const params = new URLSearchParams({ q: query.query });
60
+ if (query.limit !== undefined)
61
+ params.set('limit', String(query.limit));
62
+ appendSelection(params, query);
63
+ return get(`/knowledge/search?${params}`);
64
+ },
65
+ status: () => get('/status'),
66
+ sources(selection = {}) {
67
+ const params = new URLSearchParams();
68
+ appendSelection(params, selection);
69
+ return get(`/sources?${params}`);
70
+ },
71
+ projects: () => get('/projects'),
72
+ async listTools() {
73
+ return (await get('/tools')).tools;
74
+ },
75
+ callTool(id, call) {
76
+ // One budget per tool comes from its descriptor: a browser action may
77
+ // wait behind other tabs, a status read must not.
78
+ return request('/tools', {
79
+ method: 'POST',
80
+ headers: { 'content-type': 'application/json' },
81
+ body: JSON.stringify({
82
+ tool: id,
83
+ sessionId: call.sessionId,
84
+ ...(call.messageId ? { messageId: call.messageId } : {}),
85
+ input: call.input,
86
+ }),
87
+ timeoutMs: call.timeoutMs ?? 10_000,
88
+ });
89
+ },
90
+ health: () => request('/health', { timeoutMs: 3_000 }),
91
+ runMembership: sessionId => request(`/run?${new URLSearchParams({ session: sessionId })}`, { timeoutMs: 3_000 }),
92
+ context: sessionId => request(`/context?${new URLSearchParams({ session: sessionId })}`, { timeoutMs: 20_000 }),
93
+ wake: () => request('/wake', { method: 'POST', timeoutMs: 3_000 }),
94
+ whoami: () => get('/whoami'),
95
+ people: () => get('/people'),
96
+ createPerson(input) {
97
+ return request('/people', {
98
+ method: 'POST',
99
+ headers: { 'content-type': 'application/json' },
100
+ body: JSON.stringify(input),
101
+ timeoutMs: 10_000,
102
+ });
103
+ },
104
+ createPersonToken(personId, label) {
105
+ return request(`/people/${encodeURIComponent(personId)}/tokens`, {
106
+ method: 'POST',
107
+ headers: { 'content-type': 'application/json' },
108
+ body: JSON.stringify({ label }),
109
+ timeoutMs: 10_000,
110
+ });
111
+ },
112
+ jobs(body) {
113
+ // Creating a job checks the calling session and agent against OpenCode; a few seconds at most.
114
+ return request('/jobs', {
115
+ method: 'POST',
116
+ headers: { 'content-type': 'application/json' },
117
+ body: JSON.stringify(body),
118
+ timeoutMs: 30_000,
119
+ });
120
+ },
121
+ };
122
+ }
123
+ function appendSelection(params, selection) {
124
+ for (const project of selection.projects ?? [])
125
+ params.append('project', project);
126
+ // An explicit empty selection means core only.
127
+ if (selection.projects?.length === 0)
128
+ params.set('coreOnly', 'true');
129
+ if (selection.includeCore === false)
130
+ params.set('includeCore', 'false');
131
+ for (const kind of selection.kinds ?? [])
132
+ params.append('kind', kind);
133
+ }
134
+ async function describeFailure(response) {
135
+ let detail = '';
136
+ try {
137
+ const body = (await response.json());
138
+ if (typeof body?.error === 'string')
139
+ detail = `: ${body.error}`;
140
+ }
141
+ catch {
142
+ // Non-JSON error body; the status is enough.
143
+ }
144
+ if (response.status === 401)
145
+ return `aivi host rejected the request (401)${detail}. This endpoint needs a bearer token that names a person.`;
146
+ return `aivi host returned HTTP ${response.status}${detail}`;
147
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The **channel** contract: how aivi carries a conversation on a chat
3
+ * platform. A channel is a core aivi concept shared with plugins, so the
4
+ * vocabulary is declared here — the host's engine and the platform modules
5
+ * both import it and follow it; nobody owns it by implementing it.
6
+ *
7
+ * What a module keeps is its gateway, routing, sending and commands; what
8
+ * the shared machinery owns is the inbox tables, the engine and the turn
9
+ * runner — those ship in the host, and the two narrow interfaces below are
10
+ * the contract's view of them: what the shared sequences call, and what the
11
+ * host's classes are checked against.
12
+ */
13
+ import type { Run, RunState } from '@aivi/core';
14
+ export interface DeliveryContext {
15
+ /** The run whose outcome this text delivers; absent on a module's own notice. */
16
+ run?: Run;
17
+ /** How that run ended; absent on a notice. */
18
+ state?: RunState;
19
+ /** Thread title for a notice without a run; a run's report is titled by its job. */
20
+ title?: string;
21
+ }
22
+ /** A re-entry is a job's result coming back: it always carries its run. */
23
+ export type ReentryContext = DeliveryContext & {
24
+ run: Run;
25
+ };
26
+ /**
27
+ * What a chat platform adapter registers with the host (`services.channels.register`).
28
+ * Registering is what makes the module a report destination and a session owner:
29
+ * outcomes for sessions it owns re-enter its conversations as turns, and
30
+ * `report.to: "channel"` with its id posts through it.
31
+ */
32
+ export interface ChannelModule {
33
+ /** What a conversation is **on**: `report.module`, table prefix, lease owner,
34
+ * `metadata.aivi.origin`. Not the plugin registry's module id — that one is
35
+ * the package name (`channel-discord`) and keys `config.json`, while this one
36
+ * is the platform's short name (`discord`) and keys the database, so renaming
37
+ * it would orphan every bound conversation that already exists. */
38
+ id: string;
39
+ /** This session is one of the module's conversations (bound or adopted). */
40
+ ownsSession(sessionId: string): boolean;
41
+ /** Bring a job outcome into the conversation bound to `sessionId` as a turn of kind `job`. */
42
+ reenter(sessionId: string, text: string, context: ReentryContext): Promise<void>;
43
+ /** Post text to a platform channel. Throw if aivi may not post there. */
44
+ post(channel: string, text: string, context: DeliveryContext): Promise<void>;
45
+ /** Whether a report to `channel` could be delivered; refuses a job before it spends anything. */
46
+ accepts(channel: string): boolean;
47
+ /** The platform channel a conversation lives in (a thread's parent, a DM itself), for "post it to this channel". */
48
+ channelOf(sessionId: string): Promise<string | undefined>;
49
+ /**
50
+ * How a person spends a link code on this platform, shown by `aivi link`
51
+ * ("DM the bot: /link <code>."). Redemption itself is the shared
52
+ * `redeemLink` helper — this is only the wording.
53
+ */
54
+ linkHint?: string;
55
+ }
56
+ /**
57
+ * How the shared engine talks back to a platform conversation. `edit` and
58
+ * `delete` power the progress placeholder; without `edit` progress falls back
59
+ * to silent, without `delete` the placeholder is edited into the reply.
60
+ */
61
+ export interface ChannelDelivery {
62
+ /** Post the answer (or a chunk of it); return the platform's message id when it has one. */
63
+ send(conversation: string, text: string): Promise<string | undefined>;
64
+ edit?(conversation: string, messageId: string, text: string): Promise<void>;
65
+ delete?(conversation: string, messageId: string): Promise<void>;
66
+ /** Post the progress placeholder when it is not an ordinary message on this platform (Linear: an ephemeral thought); default `send`. */
67
+ placeholder?(conversation: string, text: string): Promise<string | undefined>;
68
+ /** Post a notice that is not an answer (a stop, a failure, going offline); default: edit the placeholder, else `send`. */
69
+ notice?(conversation: string, text: string): Promise<void>;
70
+ }
71
+ /**
72
+ * What differs between platforms in the shared machinery. `id` prefixes the
73
+ * inbox tables (`<id>_turns`), owns the leases (`<id>:<turn>`), the session ids
74
+ * (`ses_<id>_…`), the native message ids (`msg_<id>_…`) and is the session origin.
75
+ */
76
+ export interface ChannelPlatform {
77
+ id: string;
78
+ /** Human name for session titles, prompt prefixes and error messages. */
79
+ label: string;
80
+ /** Message size limit in UTF-16 units; replies are split below it. */
81
+ replyLimit: number;
82
+ /** How a person's message is introduced to the agent; default `[<label> message from <name> (user <id>)]`. */
83
+ describeSpeaker?: (turn: {
84
+ name: string;
85
+ user: string;
86
+ }) => string;
87
+ /**
88
+ * What a turn changes besides its reply. `reply` (default, chat): an interrupted turn is
89
+ * discarded. `work` (workers editing a checkout): a turn interrupted by a restart ends
90
+ * `blocked` because nobody can tell whether the agent stopped; stops by choice still release.
91
+ */
92
+ effects?: 'reply' | 'work';
93
+ /** Platform wording for the engine's notices; the chat defaults otherwise. */
94
+ notices?: Partial<EngineNotices>;
95
+ }
96
+ /** The texts the shared engine posts into a conversation when a turn does not end with an answer. */
97
+ export interface EngineNotices {
98
+ /** A chat turn ended knowably (the engine appends the short reason); Linear overrides via `notices` and never uses it. */
99
+ failed: string;
100
+ stopped: string;
101
+ notStarted: string;
102
+ offline: string;
103
+ offlineMidReply: string;
104
+ offlineQueued: string;
105
+ blocked: string;
106
+ }
107
+ export interface ChatCommandArgument {
108
+ name: string;
109
+ description: string;
110
+ required: boolean;
111
+ /** Discord offers choices for this argument from the named catalogue. */
112
+ autocomplete?: 'model';
113
+ }
114
+ /** One chat command, the same on every platform; the adapters translate names and arguments. */
115
+ export interface ChatCommand {
116
+ name: string;
117
+ /** Under 100 characters: Discord's limit for a command description. */
118
+ description: string;
119
+ /** Acts on one conversation: refused where a slash command cannot name one (a threads-mode channel). */
120
+ conversation: boolean;
121
+ arguments: ChatCommandArgument[];
122
+ }
123
+ /**
124
+ * The name of a built-in chat command. The commands are shared channel
125
+ * vocabulary — the host's `CHAT_COMMANDS` list is typed against this union,
126
+ * so a name added here without a command, or a command without a name here,
127
+ * is a compile error.
128
+ */
129
+ export type ChatCommandName = 'new' | 'status' | 'context' | 'search' | 'model' | 'stop' | 'queue' | 'jobs' | 'link' | 'help';
130
+ /** A catalogue model as OpenCode names it, with an optional variant (`high`, `max`). */
131
+ export interface ModelRef {
132
+ providerID: string;
133
+ modelID: string;
134
+ variant?: string;
135
+ }
136
+ export type TurnState = 'queued' | 'running' | 'replying' | 'sent' | 'blocked' | 'discarded';
137
+ export type TurnKind = 'message' | 'job';
138
+ /** One message's whole trip through the inbox, from a person's text to a sent reply. */
139
+ export interface Turn {
140
+ id: string;
141
+ channel: string;
142
+ user: string;
143
+ name: string;
144
+ text: string;
145
+ session: string;
146
+ ready: boolean;
147
+ state: TurnState;
148
+ result: string | null;
149
+ error: string | null;
150
+ /** `message`: a person wrote it. `job`: aivi brings a job's outcome back into the conversation. */
151
+ kind: TurnKind;
152
+ /** Set when the conversation adopted a job's session: that session runs this agent in this directory, not the module's. */
153
+ agent: string | null;
154
+ directory: string | null;
155
+ /** Text posted in the conversation before any session existed (a script's output); context for the first turn. */
156
+ seed: string | null;
157
+ /** The conversation's model override (`/model`), applied to the session before each prompt; null means the agent's default. */
158
+ model: ModelRef | null;
159
+ /** Set by `bind` for workers: one turn at a time per issue across the module's conversations. */
160
+ project: string | null;
161
+ issue: string | null;
162
+ }
163
+ /**
164
+ * The conversation store as the shared sequences touch it. Every channel's
165
+ * `resolve` command ends the same way (release the blocked record, print
166
+ * the receipt, wake the host — the sequence in `./cli.ts`), and that is the
167
+ * one member the contract may name. The store itself is the host's
168
+ * `ConversationStore` class, checked against this interface; a module opens
169
+ * it with its own platform and binding.
170
+ */
171
+ export interface ConversationStore {
172
+ /** Release the turn blocked on `id`, with the reason a person gave. */
173
+ resolve(id: string, reason: string): void;
174
+ }
175
+ /**
176
+ * The channel registry as a module meets it: registering is what makes a
177
+ * chat platform adapter a report destination and a session owner. The
178
+ * registry itself is the host's `Channels` class, checked against this
179
+ * interface; what the host does with a registered module is the engine's.
180
+ */
181
+ export interface Channels {
182
+ /** Register once at module start; the returned function unregisters. */
183
+ register(module: ChannelModule): () => void;
184
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The **channel** contract: how aivi carries a conversation on a chat
3
+ * platform. A channel is a core aivi concept shared with plugins, so the
4
+ * vocabulary is declared here — the host's engine and the platform modules
5
+ * both import it and follow it; nobody owns it by implementing it.
6
+ *
7
+ * What a module keeps is its gateway, routing, sending and commands; what
8
+ * the shared machinery owns is the inbox tables, the engine and the turn
9
+ * runner — those ship in the host, and the two narrow interfaces below are
10
+ * the contract's view of them: what the shared sequences call, and what the
11
+ * host's classes are checked against.
12
+ */
13
+ export {};
package/dist/cli.d.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The operator-CLI contract an installable plugin package fills. Where
3
+ * `./setup` is the install-time subpath, `./cli` is the runtime one: a
4
+ * package that default-exports a factory `(ctx) => Command` from its `./cli`
5
+ * subpath has its command subtree mounted into the server CLI whenever the
6
+ * package is installed. The factory builds real commander — parsing, help,
7
+ * choices and variadics are commander's own, and the plugin declares
8
+ * `commander` itself — while `ctx` carries everything the plugin cannot
9
+ * know: the home, the store door, the host poke and the streams. The
10
+ * contract stays testable without a terminal.
11
+ */
12
+ import type { LoadedConfig, OutputBlock } from '@aivi/core';
13
+ import type * as prompts from '@clack/prompts';
14
+ import type { ConversationStore } from './channel.ts';
15
+ import type { Store } from './module.ts';
16
+ /** What the machine this CLI runs on *is*: the one fact that decides command
17
+ * membership. `home` is the aivi home this machine has, or absent when it has
18
+ * none. The CLI resolves it once per run (AIVI_HOME, then the client record)
19
+ * and hands it to every command provider; a provider that cannot run without
20
+ * a home simply does not register the command — membership is decided by the
21
+ * provider at mount time, never by hiding a command after the fact. */
22
+ export interface MachineStatus {
23
+ home?: string | undefined;
24
+ /** The client record's path when this machine has one — the file `configure`
25
+ * edits (where the host is, who signs in). The command exists only where
26
+ * the file exists; `setup` creates the first one. Existence, not
27
+ * parseability: a broken record is exactly what `configure` is for. */
28
+ clientConfig?: string | undefined;
29
+ /** True when this process is the far end of an exec session: the host
30
+ * spawned it (`AIVI_EXEC_SESSION`) to run commands someone typed on
31
+ * another machine. Providers decide from it — the plan's sets are carried
32
+ * by the commands' own declarations, not by the channel: commands that
33
+ * act on the machine you type on answer their guard line here, and
34
+ * commands that would fight the session itself refuse theirs. */
35
+ remote?: boolean | undefined;
36
+ }
37
+ /** What the CLI hands a plugin's command factory: the same home the built-ins
38
+ * see, the same stdout contract, and nothing platform-specific. */
39
+ export interface PluginCliContext {
40
+ /** The machine's state, for commands that decide whether to exist here. */
41
+ machine: MachineStatus;
42
+ /** The aivi home that owns `config.json` and `.env`. */
43
+ home: string;
44
+ configPath: string;
45
+ /** The loaded, validated config; a command reads its own module block from it. */
46
+ loaded(): Promise<LoadedConfig>;
47
+ /** The home's SQLite store, open for the callback and closed after. */
48
+ withStore<T>(fn: (store: Store) => T | Promise<T>): Promise<T>;
49
+ /** The record on stdout — JSON whenever stdout is a pipe; on a terminal the
50
+ * optional `output` renders as readable blocks (core's `print`). */
51
+ print(value: unknown, output?: OutputBlock[] | string): void;
52
+ /** A note on stderr: what was saved, what to restart, what survived. */
53
+ log(message: string): void;
54
+ /** Wake the running host so saved work dispatches without waiting. */
55
+ poke(): Promise<void>;
56
+ /** The very `@clack/prompts` module the runner renders with. One rule
57
+ * across both plugin contracts: the runner hands its own module instance;
58
+ * whoever is called owns the screen (two clacks animating one terminal
59
+ * mangle each other), so a command opens one animation at a time and
60
+ * settles it before returning. */
61
+ prompts: typeof prompts;
62
+ }
63
+ /**
64
+ * Open the module's own conversation store against the home store, release the
65
+ * blocked record, print the receipt, and wake the host so the freed worker
66
+ * dispatches without waiting. Every channel's `resolve` command ends with this
67
+ * exact sequence; keeping it in one place is what stops the poke from being
68
+ * dropped or the receipt from drifting when the contract changes. `openStore`
69
+ * supplies the module-specific store (it may close over the module's config).
70
+ */
71
+ export declare function resolveBlocked(ctx: PluginCliContext, id: string | undefined, reason: unknown, openStore: (store: Store) => ConversationStore): Promise<void>;
package/dist/cli.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The operator-CLI contract an installable plugin package fills. Where
3
+ * `./setup` is the install-time subpath, `./cli` is the runtime one: a
4
+ * package that default-exports a factory `(ctx) => Command` from its `./cli`
5
+ * subpath has its command subtree mounted into the server CLI whenever the
6
+ * package is installed. The factory builds real commander — parsing, help,
7
+ * choices and variadics are commander's own, and the plugin declares
8
+ * `commander` itself — while `ctx` carries everything the plugin cannot
9
+ * know: the home, the store door, the host poke and the streams. The
10
+ * contract stays testable without a terminal.
11
+ */
12
+ /**
13
+ * Open the module's own conversation store against the home store, release the
14
+ * blocked record, print the receipt, and wake the host so the freed worker
15
+ * dispatches without waiting. Every channel's `resolve` command ends with this
16
+ * exact sequence; keeping it in one place is what stops the poke from being
17
+ * dropped or the receipt from drifting when the contract changes. `openStore`
18
+ * supplies the module-specific store (it may close over the module's config).
19
+ */
20
+ export async function resolveBlocked(ctx, id, reason, openStore) {
21
+ await ctx.withStore(store => {
22
+ openStore(store).resolve(String(id), String(reason));
23
+ ctx.print({ resolved: true });
24
+ });
25
+ await ctx.poke();
26
+ }
@@ -0,0 +1,67 @@
1
+ /** The dispatcher contract: capacity as named pools. The dispatcher is the
2
+ * host's capacity system — every pool is an endpoint (capacity, an optional
3
+ * model, an optional fallback), and every service that works asks it for a
4
+ * slot before working. Ticket work drew it first; voice rooms draw the same
5
+ * pools, so a local box and a remote endpoint are pools like any other.
6
+ *
7
+ * This is the narrow view a room holder needs (the host's `Dispatcher`
8
+ * satisfies it): ask, be provided a session, work, release. The queue is
9
+ * the dispatcher's memory, not yours: a full pool answers `queued` with an
10
+ * id, and the callback you registered hears when that request lands as a
11
+ * lease. No timers, no polling — capacity moves, you are told.
12
+ */
13
+ export interface DispatcherLeaseRef {
14
+ id: string;
15
+ /** The pool whose slot this holds; a session keeps the pool it was born in. */
16
+ pool: string;
17
+ /** `preparing` means the session is not attached yet: `provide` it. */
18
+ state: 'preparing' | 'active' | 'expiring';
19
+ sessionId?: string;
20
+ }
21
+ export type DispatcherGrant = {
22
+ lease: DispatcherLeaseRef;
23
+ }
24
+ /** The pool is full and took the request: the callback hears when it lands. */
25
+ | {
26
+ queued: string;
27
+ }
28
+ /** `waiting`: the service already holds the pool's one queue place. */
29
+ | {
30
+ refused: 'full' | 'pool-gone' | 'waiting';
31
+ pool: string;
32
+ };
33
+ export interface Dispatcher {
34
+ /** Ask for a slot in a pool (absent: the `default` pool). A resume returns
35
+ * to the pool its session was created in — that is what keeps a room's
36
+ * conversation on the endpoint that made it. */
37
+ request(input: {
38
+ service: string;
39
+ pool?: string;
40
+ resume?: string;
41
+ }): DispatcherGrant;
42
+ /** Provide a preparing lease with its session: created with the pool's
43
+ * model, or resumed with whatever it was born with. `create` says the
44
+ * named session does not exist yet: make it real under that id, with the
45
+ * pool's model — a caller that pre-allocates ids keeps them. `title` and
46
+ * `metadata` ride the create: the aivi origin that dreaming reviews and
47
+ * the name OpenCode's list shows are set where the session is born. */
48
+ provide(leaseId: string, work: {
49
+ agent: string;
50
+ directory: string;
51
+ sessionId?: string;
52
+ create?: boolean;
53
+ title?: string;
54
+ metadata?: Record<string, unknown>;
55
+ }): Promise<DispatcherLeaseRef>;
56
+ /** End the lease and open its slot to the queue. `pending` means the slot
57
+ * stays held until a kill is confirmed — the dispatcher keeps watching. */
58
+ release(leaseId: string): Promise<{
59
+ ended: DispatcherLeaseRef;
60
+ } | {
61
+ pending: DispatcherLeaseRef;
62
+ }>;
63
+ /** Hear when one of this service's queued requests becomes a lease. */
64
+ registerCallback(service: string, callback: (requestId: string, lease: DispatcherLeaseRef) => void): void;
65
+ /** Lose a queue place and nothing more. */
66
+ cancel(requestId: string): void;
67
+ }
@@ -0,0 +1,13 @@
1
+ /** The dispatcher contract: capacity as named pools. The dispatcher is the
2
+ * host's capacity system — every pool is an endpoint (capacity, an optional
3
+ * model, an optional fallback), and every service that works asks it for a
4
+ * slot before working. Ticket work drew it first; voice rooms draw the same
5
+ * pools, so a local box and a remote endpoint are pools like any other.
6
+ *
7
+ * This is the narrow view a room holder needs (the host's `Dispatcher`
8
+ * satisfies it): ask, be provided a session, work, release. The queue is
9
+ * the dispatcher's memory, not yours: a full pool answers `queued` with an
10
+ * id, and the callback you registered hears when that request lands as a
11
+ * lease. No timers, no polling — capacity moves, you are told.
12
+ */
13
+ export {};