mu-core 0.8.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/src/hooks.ts ADDED
@@ -0,0 +1,89 @@
1
+ import type { AgentEndReason, BeforeToolExecResult, LifecycleHooks, TurnResult, UserInputTransform } from './plugin';
2
+ import type { ChatMessage, ProviderConfig, ToolCall } from './types/llm';
3
+
4
+ export async function runBeforeLlmHooks(
5
+ hooks: LifecycleHooks[],
6
+ messages: ChatMessage[],
7
+ config: ProviderConfig,
8
+ ): Promise<ChatMessage[]> {
9
+ let current = messages;
10
+ for (const hook of hooks) {
11
+ if (hook.beforeLlmCall) {
12
+ current = await hook.beforeLlmCall(current, config);
13
+ }
14
+ }
15
+ return current;
16
+ }
17
+
18
+ export async function runAfterLlmHooks(hooks: LifecycleHooks[], result: TurnResult): Promise<TurnResult> {
19
+ let current = result;
20
+ for (const hook of hooks) {
21
+ if (hook.afterLlmCall) {
22
+ current = await hook.afterLlmCall(current);
23
+ }
24
+ }
25
+ return current;
26
+ }
27
+
28
+ /**
29
+ * Run every `beforeToolExec` hook in order. Each hook may either return a
30
+ * (possibly mutated) `ToolCall` to keep the chain going, or a `ToolBlock` to
31
+ * short-circuit execution. Once a hook blocks the call, no further hooks run
32
+ * — there's nothing useful to forward.
33
+ */
34
+ export async function runBeforeToolExecHook(
35
+ hooks: LifecycleHooks[],
36
+ toolCall: ToolCall,
37
+ ): Promise<BeforeToolExecResult> {
38
+ let current: BeforeToolExecResult = toolCall;
39
+ for (const hook of hooks) {
40
+ if (!hook.beforeToolExec) continue;
41
+ if ('blocked' in current) return current;
42
+ current = await hook.beforeToolExec(current);
43
+ }
44
+ return current;
45
+ }
46
+
47
+ export async function runAfterToolExecHook(
48
+ hooks: LifecycleHooks[],
49
+ toolCall: ToolCall,
50
+ result: string,
51
+ ): Promise<string> {
52
+ let current = result;
53
+ for (const hook of hooks) {
54
+ if (hook.afterToolExec) {
55
+ current = await hook.afterToolExec(toolCall, current);
56
+ }
57
+ }
58
+ return current;
59
+ }
60
+
61
+ export async function runAfterAgentRunHooks(hooks: LifecycleHooks[], reason: AgentEndReason): Promise<void> {
62
+ for (const hook of hooks) {
63
+ if (hook.afterAgentRun) {
64
+ await hook.afterAgentRun(reason);
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Compose every `transformUserInput` hook. Earlier hooks see the raw text;
71
+ * each subsequent hook sees the (possibly rewritten) text emitted by the
72
+ * previous one. The first `intercept` short-circuits the chain.
73
+ */
74
+ export async function runTransformUserInputHooks(hooks: LifecycleHooks[], text: string): Promise<UserInputTransform> {
75
+ let current: UserInputTransform = { kind: 'pass' };
76
+ let working = text;
77
+ for (const hook of hooks) {
78
+ if (!hook.transformUserInput) continue;
79
+ const next = await hook.transformUserInput(working);
80
+ if (next.kind === 'intercept') {
81
+ return next;
82
+ }
83
+ if (next.kind === 'transform') {
84
+ working = next.text;
85
+ current = next;
86
+ }
87
+ }
88
+ return current;
89
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `startMu` — generic host bootstrap. Loads a config, builds the plugin
3
+ * registry with the new side-channel registries (providers/channels/sessions/
4
+ * activity/agents), activates plugins (config first then code-passed), starts
5
+ * channels, and returns a handle for shutdown.
6
+ *
7
+ * Designed for non-coding hosts (Arya etc.). mu-coding currently keeps its
8
+ * own bootstrap (Ink TUI lifecycle) and may migrate to this entry point in a
9
+ * later iteration.
10
+ */
11
+
12
+ import type { ActivityBus } from '../activity';
13
+ import { createActivityBus } from '../activity';
14
+ import type { ChannelRegistry } from '../channel';
15
+ import { createChannelRegistry } from '../channel';
16
+ import type { Plugin } from '../plugin';
17
+ import type { ProviderRegistry } from '../provider/registry';
18
+ import { createProviderRegistry } from '../provider/registry';
19
+ import { PluginRegistry } from '../registry';
20
+ import type { SessionManager } from '../session';
21
+ import { createSessionManager } from '../session';
22
+ import type { ProviderConfig } from '../types/llm';
23
+
24
+ export interface MuConfigShape {
25
+ cwd?: string;
26
+ baseUrl?: string;
27
+ model?: string;
28
+ maxTokens?: number;
29
+ temperature?: number;
30
+ streamTimeoutMs?: number;
31
+ systemPrompt?: string;
32
+ plugins?: Array<string | { name: string; config?: Record<string, unknown> }>;
33
+ }
34
+
35
+ export interface StartMuOptions {
36
+ configPath?: string;
37
+ /** In-memory config — takes precedence over `configPath` when provided. */
38
+ config?: MuConfigShape;
39
+ /** Plugins passed in code (activated after config-listed plugins). */
40
+ plugins?: Plugin[];
41
+ /** Cwd default (overrides config.cwd if set). */
42
+ cwd?: string;
43
+ /**
44
+ * Resolves a `config.plugins` entry to a Plugin instance. Hosts (mu-coding,
45
+ * Arya) plug their own loader here — typically supports `npm:<name>`
46
+ * specifiers, absolute paths, etc. When omitted, config.plugins is ignored.
47
+ */
48
+ resolvePlugin?: (entry: string | { name: string; config?: Record<string, unknown> }) => Promise<Plugin | null>;
49
+ }
50
+
51
+ export interface MuHandle {
52
+ registry: PluginRegistry;
53
+ sessions: SessionManager;
54
+ channels: ChannelRegistry;
55
+ activity: ActivityBus;
56
+ providers: ProviderRegistry;
57
+ shutdown: () => Promise<void>;
58
+ }
59
+
60
+ async function loadConfig(opts: StartMuOptions): Promise<MuConfigShape> {
61
+ if (opts.config) return opts.config;
62
+ if (!opts.configPath) return {};
63
+ const { readFileSync, existsSync } = await import('node:fs');
64
+ if (!existsSync(opts.configPath)) return {};
65
+ const text = readFileSync(opts.configPath, 'utf8');
66
+ return JSON.parse(text) as MuConfigShape;
67
+ }
68
+
69
+ export async function startMu(options: StartMuOptions = {}): Promise<MuHandle> {
70
+ const cfg = await loadConfig(options);
71
+ const cwd = options.cwd ?? cfg.cwd ?? process.cwd();
72
+
73
+ const providers = createProviderRegistry();
74
+ const channels = createChannelRegistry();
75
+ const activity = createActivityBus();
76
+
77
+ const providerConfig: ProviderConfig = {
78
+ baseUrl: cfg.baseUrl ?? 'http://localhost:11434/v1',
79
+ model: cfg.model,
80
+ maxTokens: cfg.maxTokens ?? 4096,
81
+ temperature: cfg.temperature ?? 0.7,
82
+ streamTimeoutMs: cfg.streamTimeoutMs ?? 60_000,
83
+ systemPrompt: cfg.systemPrompt,
84
+ };
85
+
86
+ // Build a placeholder for sessions injected after construction (circular:
87
+ // SessionManager needs the registry, plugins want to see SessionManager).
88
+ let sessions: SessionManager | null = null;
89
+ const sessionsProxy: SessionManager = new Proxy({} as SessionManager, {
90
+ get(_t, prop) {
91
+ if (!sessions) throw new Error('SessionManager not yet initialised');
92
+ return (sessions as unknown as Record<string | symbol, unknown>)[prop as string];
93
+ },
94
+ });
95
+
96
+ const registry = new PluginRegistry({
97
+ cwd,
98
+ config: {},
99
+ providers,
100
+ channels,
101
+ activity,
102
+ sessions: sessionsProxy,
103
+ });
104
+
105
+ sessions = createSessionManager({ registry, config: providerConfig, model: cfg.model ?? 'unknown' });
106
+
107
+ // Activate config-listed plugins via the host's resolver. mu-coding wires
108
+ // its npm:/path loader; minimal hosts may omit and pass plugins in code.
109
+ if (options.resolvePlugin && cfg.plugins) {
110
+ for (const entry of cfg.plugins) {
111
+ const plugin = await options.resolvePlugin(entry);
112
+ if (plugin) await registry.register(plugin);
113
+ }
114
+ }
115
+ // Activate code-passed plugins after config-listed ones (so code overrides
116
+ // config-driven hooks compose-wise).
117
+ for (const plugin of options.plugins ?? []) {
118
+ await registry.register(plugin);
119
+ }
120
+
121
+ await channels.startAll();
122
+
123
+ const sm = sessions;
124
+ return {
125
+ registry,
126
+ sessions: sm,
127
+ channels,
128
+ activity,
129
+ providers,
130
+ async shutdown() {
131
+ await channels.stopAll();
132
+ for (const s of sm.list()) s.abort();
133
+ },
134
+ };
135
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import type { Plugin } from '../plugin';
3
+ import { startMu } from './index';
4
+
5
+ function makePlugin(name: string, marks: string[]): Plugin {
6
+ return {
7
+ name,
8
+ activate() {
9
+ marks.push(`activate:${name}`);
10
+ },
11
+ };
12
+ }
13
+
14
+ describe('startMu', () => {
15
+ it('activates code-passed plugins in order', async () => {
16
+ const marks: string[] = [];
17
+ const handle = await startMu({
18
+ plugins: [makePlugin('a', marks), makePlugin('b', marks)],
19
+ });
20
+ expect(marks).toEqual(['activate:a', 'activate:b']);
21
+ await handle.shutdown();
22
+ });
23
+
24
+ it('exposes provider/channel/activity/sessions registries', async () => {
25
+ const handle = await startMu({});
26
+ expect(handle.providers.list()).toEqual([]);
27
+ expect(handle.channels.list()).toEqual([]);
28
+ expect(handle.sessions.list()).toEqual([]);
29
+ expect(typeof handle.activity.emit).toBe('function');
30
+ await handle.shutdown();
31
+ });
32
+
33
+ it('config-listed plugins are activated before code-passed', async () => {
34
+ const marks: string[] = [];
35
+ const cfgPlugin = makePlugin('cfg', marks);
36
+ const codePlugin = makePlugin('code', marks);
37
+ const handle = await startMu({
38
+ config: { plugins: ['cfg'] },
39
+ plugins: [codePlugin],
40
+ resolvePlugin: async (entry) => (typeof entry === 'string' && entry === 'cfg' ? cfgPlugin : null),
41
+ });
42
+ expect(marks).toEqual(['activate:cfg', 'activate:code']);
43
+ await handle.shutdown();
44
+ });
45
+
46
+ it('starts all registered channels', async () => {
47
+ const startMarks: string[] = [];
48
+ const handle = await startMu({
49
+ plugins: [
50
+ {
51
+ name: 'chan-plugin',
52
+ activate(ctx) {
53
+ ctx.channels?.register({
54
+ id: 'test',
55
+ async start() {
56
+ startMarks.push('started');
57
+ },
58
+ });
59
+ },
60
+ },
61
+ ],
62
+ });
63
+ expect(startMarks).toEqual(['started']);
64
+ await handle.shutdown();
65
+ });
66
+ });
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ export type { ActivityBus, ActivityEvent, ActivityKind, SubAgentEvent, SubAgentEventKind } from './activity';
2
+ export { createActivityBus } from './activity';
3
+ export { runAgent } from './agent';
4
+ export type { Channel, ChannelRegistry, ChannelResponder, InboundKind, InboundMessage, ResponseMode } from './channel';
5
+ export { createChannelRegistry } from './channel';
6
+ export { runTransformUserInputHooks } from './hooks';
7
+ export type { MuConfigShape, MuHandle, StartMuOptions } from './host/index';
8
+ export { startMu } from './host/index';
9
+ export type {
10
+ AgentEndReason,
11
+ AgentEvent,
12
+ AgentLoopStrategy,
13
+ AgentSourceRegistry,
14
+ BeforeToolExecResult,
15
+ CommandContext,
16
+ LifecycleHooks,
17
+ MentionCompletion,
18
+ MentionProvider,
19
+ MessageBus,
20
+ MessageRenderer,
21
+ Plugin,
22
+ PluginContext,
23
+ PluginExtras,
24
+ PluginRegistryView,
25
+ PluginTool,
26
+ PluginToolPermission,
27
+ ShortcutHandler,
28
+ SlashCommand,
29
+ StatusSegment,
30
+ ToolBlock,
31
+ ToolDisplayHint,
32
+ ToolExecutor,
33
+ ToolExecutorResult,
34
+ ToolResult,
35
+ TurnResult,
36
+ UserInputTransform,
37
+ } from './plugin';
38
+ export type {
39
+ ChatRequestInput,
40
+ ModelsRequestInput,
41
+ ParsedChatEvent,
42
+ Provider,
43
+ ProviderAdapter,
44
+ RequestSpec,
45
+ } from './provider/adapter';
46
+ export { createProvider } from './provider/adapter';
47
+ export type { ProviderRegistry } from './provider/registry';
48
+ export { createProviderRegistry } from './provider/registry';
49
+ export { fetchWithIdleTimeout, readNDJSON, readSSE } from './provider/transport';
50
+ export { PluginRegistry, type PluginRegistryOptions } from './registry';
51
+ export type {
52
+ CreateSessionManagerOptions,
53
+ RunTurnOptions,
54
+ Session,
55
+ SessionEvent,
56
+ SessionInit,
57
+ SessionManager,
58
+ } from './session';
59
+ export { createSessionManager } from './session';
60
+ export type {
61
+ ApiModel,
62
+ ChatMessage,
63
+ ImageAttachment,
64
+ MessageDisplay,
65
+ ProviderConfig,
66
+ StreamChunk,
67
+ StreamOptions,
68
+ ToolCall,
69
+ ToolDefinition,
70
+ ToolResultInfo,
71
+ Usage,
72
+ } from './types/llm';
73
+ export { ConsoleUIService, type UINotifyLevel, type UIService } from './ui';
package/src/plugin.ts ADDED
@@ -0,0 +1,337 @@
1
+ import type { ActivityBus } from './activity';
2
+ import type { ChannelRegistry } from './channel';
3
+ import type { ProviderRegistry } from './provider/registry';
4
+ import type { SessionManager } from './session';
5
+ import type { ChatMessage, ProviderConfig, ToolCall, ToolDefinition } from './types/llm';
6
+
7
+ /** Source of agent definitions on disk; implemented by mu-agents. */
8
+ export interface AgentSourceRegistry {
9
+ registerSource: (absoluteDirPath: string) => () => void;
10
+ }
11
+
12
+ import type { UIService } from './ui';
13
+
14
+ /**
15
+ * MessageBus lets plugins inject synthetic messages into the live chat
16
+ * transcript without participating in the LLM streaming loop.
17
+ *
18
+ * - `append(msg)` pushes a message into the **on-screen** transcript right
19
+ * now. The host preserves it across subsequent agent `messages` events
20
+ * when the message looks plugin-synthetic (carries `customType`, `meta`,
21
+ * or `display.hidden`). The LLM does NOT see appended entries — it only
22
+ * sees what was sent in the most recent turn. Use for banners / status
23
+ * entries that should persist in the UI but not influence the model.
24
+ * - `injectNext(msg)` queues a message that's spliced in alongside the
25
+ * *next* user turn. The message reaches the LLM and is persisted with
26
+ * the rest of the transcript. Use for "system reminder" injections that
27
+ * should travel with the user's next message.
28
+ * - `subscribe(fn)` notifies on any transcript change. The listener fires
29
+ * once on subscribe with the current snapshot.
30
+ */
31
+ export interface MessageBus {
32
+ append: (message: ChatMessage) => void;
33
+ injectNext: (message: ChatMessage) => void;
34
+ drainNext: () => ChatMessage[];
35
+ subscribe: (listener: (messages: ChatMessage[]) => void) => () => void;
36
+ get: () => ChatMessage[];
37
+ }
38
+
39
+ /**
40
+ * Renderer signature used by `registerMessageRenderer`. The host calls
41
+ * `render(message)` whenever it encounters a message whose `customType`
42
+ * matches the registered key. The return value is renderer-defined — for the
43
+ * mu-coding host, it is a React element; renderer-agnostic hosts may use a
44
+ * different shape.
45
+ */
46
+ export type MessageRenderer = (message: ChatMessage) => unknown;
47
+
48
+ /**
49
+ * Handler for plugin-registered keyboard shortcuts. Registering a handler
50
+ * always consumes the key for that frame — the default editor binding is
51
+ * skipped. Async handlers fire-and-forget; the input loop does not await.
52
+ */
53
+ export type ShortcutHandler = () => void | Promise<void>;
54
+
55
+ export interface MentionCompletion {
56
+ /** Value inserted into the input (replaces `@partial`). */
57
+ value: string;
58
+ /** Display label in the picker. Defaults to `value`. */
59
+ label?: string;
60
+ /** Secondary text shown dimly in the picker. */
61
+ description?: string;
62
+ }
63
+
64
+ export type MentionProvider = (partial: string) => MentionCompletion[] | Promise<MentionCompletion[]>;
65
+
66
+ /**
67
+ * Side-channel registries the host exposes to plugins. None are guaranteed —
68
+ * plugins should null-check before calling so they degrade gracefully on
69
+ * non-TUI hosts (single-shot CLI, tests).
70
+ */
71
+ export interface PluginExtras {
72
+ /** Inject / observe synthetic chat messages. */
73
+ messages?: MessageBus;
74
+ /** Register a custom renderer for `ChatMessage.customType`. */
75
+ registerMessageRenderer?: (customType: string, renderer: MessageRenderer) => () => void;
76
+ /**
77
+ * Claim a key combo. The id mirrors the input handler's internal key ids
78
+ * (`tab`, `escape`, `ctrl+t`, ...). Returns an unregister fn.
79
+ */
80
+ registerShortcut?: (key: string, handler: ShortcutHandler) => () => void;
81
+ /**
82
+ * Provide @mention completions. Trigger char defaults to `@`. Returning an
83
+ * empty array hides the picker for that prefix.
84
+ */
85
+ registerMentionProvider?: (trigger: string, provider: MentionProvider) => () => void;
86
+ }
87
+
88
+ /**
89
+ * Read-only registry surface exposed to plugins via `PluginContext.registry`.
90
+ * Lets a plugin enumerate tools or hand them off to a nested run (e.g. a
91
+ * subagent loop) without circular type imports.
92
+ */
93
+ export interface PluginRegistryView {
94
+ getTools: () => PluginTool[];
95
+ getFilteredTools: () => Promise<PluginTool[]>;
96
+ getHooks: () => LifecycleHooks[];
97
+ getSystemPrompts: () => Promise<string[]>;
98
+ applySystemPromptTransforms: (prompt: string) => Promise<string>;
99
+ }
100
+
101
+ export interface PluginContext extends PluginExtras {
102
+ cwd: string;
103
+ config: Record<string, unknown>;
104
+ /**
105
+ * Host-provided UI service. Available when the host (e.g. mu-coding) supplies
106
+ * one; otherwise plugins should fall back to a no-op or `ConsoleUIService`.
107
+ */
108
+ ui?: UIService;
109
+ getPlugin?: <T extends Plugin>(name: string) => T | undefined;
110
+ /**
111
+ * Read-only handle to the live registry. Plugins use this for advanced
112
+ * scenarios — e.g. running subagent loops via `runAgent` over a custom
113
+ * tool subset. Most plugins should rely on hooks + their own `tools`
114
+ * field instead.
115
+ */
116
+ registry?: PluginRegistryView;
117
+ /**
118
+ * Push status segments for this plugin into the registry. Replaces the older
119
+ * polling-based `Plugin.statusLine()` getter. Pass `[]` to clear.
120
+ */
121
+ setStatusLine?: (segments: StatusSegment[]) => void;
122
+ /**
123
+ * Host-provided graceful shutdown hook. When supplied, plugins should prefer
124
+ * this over `process.exit(...)` so the host can deactivate plugins and restore
125
+ * terminal state.
126
+ */
127
+ shutdown?: (code?: number) => Promise<void> | void;
128
+ /** LLM provider registry. Plugins implementing providers register here. */
129
+ providers?: ProviderRegistry;
130
+ /** Channel registry — input surfaces (TUI, Telegram, websocket, ...). */
131
+ channels?: ChannelRegistry;
132
+ /** Session manager — owns conversation state per `sessionId`. */
133
+ sessions?: SessionManager;
134
+ /** Activity bus — pub/sub for agent + tool events (timeline, broadcast). */
135
+ activity?: ActivityBus;
136
+ /** Agent source registry (file-based agent definitions). */
137
+ agents?: AgentSourceRegistry;
138
+ /**
139
+ * Plugins that *implement* an `AgentSourceRegistry` (i.e. mu-agents)
140
+ * publish it here so subsequent plugins (mu-coding-agents, user packages)
141
+ * see it in their own `ctx.agents`. The registry mutates the registry's
142
+ * shared context so every following `register()` propagates the value.
143
+ */
144
+ setAgentsRegistry?: (registry: AgentSourceRegistry) => void;
145
+ }
146
+
147
+ /**
148
+ * What a tool's `execute` may return:
149
+ * - a plain string (legacy / convenience): an error is heuristically inferred
150
+ * when the string starts with `"Error:"`. Convenient for quick tools but
151
+ * fragile (collisions with legitimate output that begins with that prefix).
152
+ * - a `ToolExecutorResult`: explicit `error` flag, no heuristics. Preferred
153
+ * for new tools and for any tool whose output may legitimately start with
154
+ * "Error:".
155
+ *
156
+ * The agent runtime accepts both forms; the registry doesn't care.
157
+ */
158
+ export interface ToolExecutorResult {
159
+ content: string;
160
+ error?: boolean;
161
+ }
162
+
163
+ export type ToolExecutor = (
164
+ args: Record<string, unknown>,
165
+ signal?: AbortSignal,
166
+ ) => Promise<string | ToolExecutorResult> | string | ToolExecutorResult;
167
+
168
+ /**
169
+ * Optional rendering hints the host can use when displaying a tool call.
170
+ * The host (e.g. mu-coding's TUI) maps `kind` to a renderer; tools without a
171
+ * `display` hint fall back to a generic preview.
172
+ *
173
+ * Kept renderer-agnostic on purpose — `mu-agents` has no React / Ink dependency.
174
+ */
175
+ export interface ToolDisplayHint {
176
+ /** Verb shown in the spinner line, e.g. "reading", "editing". */
177
+ verb?: string;
178
+ /** Renderer kind. Hosts decide how to render each kind. Built-ins use
179
+ * 'file-read' | 'file-write' | 'diff' | 'shell'. */
180
+ kind?: string;
181
+ /** Semantic field mapping from rendering concepts to actual JSON arg names.
182
+ * Examples: { path: 'path' }, { from: 'from', to: 'to' }. */
183
+ fields?: Record<string, string>;
184
+ }
185
+
186
+ /**
187
+ * Permission descriptor. `matchKey` extracts the value to glob-match from
188
+ * call args (e.g. `cmd` for bash, `path` for file tools). Tools without a
189
+ * `matchKey` may only be configured with simple actions (`allow|deny|ask`).
190
+ *
191
+ * Validated at agent-definition load time by `mu-agents`.
192
+ */
193
+ export interface PluginToolPermission {
194
+ matchKey?: (args: Record<string, unknown>) => string | undefined;
195
+ }
196
+
197
+ export interface PluginTool {
198
+ definition: ToolDefinition;
199
+ execute: ToolExecutor;
200
+ display?: ToolDisplayHint;
201
+ permission?: PluginToolPermission;
202
+ }
203
+
204
+ export interface ToolResult {
205
+ tool_call_id: string;
206
+ name: string;
207
+ content: string;
208
+ error?: boolean;
209
+ }
210
+
211
+ export interface TurnResult {
212
+ content: string;
213
+ reasoning: string;
214
+ toolCalls: ToolCall[];
215
+ usage: number;
216
+ /** Subset of prompt tokens served from the server's prompt cache, when
217
+ * reported. 0 when unsupported or no cache hit. */
218
+ cachedPromptTokens?: number;
219
+ }
220
+
221
+ export type AgentEndReason = 'complete' | 'aborted';
222
+
223
+ /**
224
+ * Result a `beforeToolExec` hook may return. Either:
225
+ * - a `ToolCall` (possibly mutated) — execution proceeds normally
226
+ * - a `ToolBlock` — the host short-circuits execution and uses the
227
+ * supplied content as the tool result (rendered as if the tool ran).
228
+ * Lets policy plugins reject calls without throwing.
229
+ */
230
+ export interface ToolBlock {
231
+ blocked: true;
232
+ content: string;
233
+ error?: boolean;
234
+ }
235
+
236
+ export type BeforeToolExecResult = ToolCall | ToolBlock;
237
+
238
+ /**
239
+ * Result a `transformUserInput` hook may return.
240
+ * - `pass` (or `undefined`) — leave the user's text untouched
241
+ * - `transform` — replace the text but still send it as a user message
242
+ * - `intercept` — suppress the input entirely; the host should not call the
243
+ * LLM. Plugins typically pair this with `MessageBus.append` to surface a
244
+ * reply or status entry.
245
+ */
246
+ export type UserInputTransform = { kind: 'pass' } | { kind: 'transform'; text: string } | { kind: 'intercept' };
247
+
248
+ export interface LifecycleHooks {
249
+ beforeLlmCall?: (messages: ChatMessage[], config: ProviderConfig) => ChatMessage[] | Promise<ChatMessage[]>;
250
+ afterLlmCall?: (result: TurnResult) => TurnResult | Promise<TurnResult>;
251
+ beforeToolExec?: (toolCall: ToolCall) => BeforeToolExecResult | Promise<BeforeToolExecResult>;
252
+ afterToolExec?: (toolCall: ToolCall, result: string) => string | Promise<string>;
253
+ /**
254
+ * Restrict the tool set the LLM can see for the next turn. Plugins return
255
+ * the subset of tools they want exposed. Multiple plugins compose by
256
+ * intersection — each hook narrows the previous result.
257
+ */
258
+ filterTools?: (tools: PluginTool[]) => PluginTool[] | Promise<PluginTool[]>;
259
+ /**
260
+ * Mutate the merged system prompt right before it goes to the provider.
261
+ * Composes left-to-right; later plugins see the prior plugin's output.
262
+ * Useful for per-agent prompt wrapping.
263
+ */
264
+ transformSystemPrompt?: (prompt: string) => string | Promise<string>;
265
+ /**
266
+ * Inspect / transform / intercept user input on submit. Composes by
267
+ * threading the current text through each plugin; an `intercept` short-
268
+ * circuits and stops the chain. Hosts call this before constructing the
269
+ * user `ChatMessage`.
270
+ */
271
+ transformUserInput?: (text: string) => UserInputTransform | Promise<UserInputTransform>;
272
+ /**
273
+ * Fires once per `runAgent` invocation, after the loop exits — whether the
274
+ * agent finished normally (LLM produced a final response with no tool calls)
275
+ * or was aborted via the signal. Plugins should use this for end-of-agent
276
+ * cleanup; per-turn cleanup belongs in `afterLlmCall`.
277
+ */
278
+ afterAgentRun?: (reason: AgentEndReason) => void | Promise<void>;
279
+ }
280
+
281
+ export interface CommandContext {
282
+ messages: ChatMessage[];
283
+ cwd: string;
284
+ config: ProviderConfig;
285
+ }
286
+
287
+ export interface SlashCommand {
288
+ name: string;
289
+ description: string;
290
+ execute: (args: string, context: CommandContext) => Promise<string | undefined>;
291
+ }
292
+
293
+ export type AgentEvent =
294
+ | { type: 'content'; text: string }
295
+ | { type: 'reasoning'; text: string }
296
+ | { type: 'usage'; totalTokens: number; cachedTokens?: number }
297
+ | { type: 'messages'; messages: ChatMessage[] }
298
+ | { type: 'turn_end' };
299
+
300
+ export interface AgentLoopStrategy {
301
+ name: string;
302
+ run: (
303
+ messages: ChatMessage[],
304
+ config: ProviderConfig,
305
+ model: string,
306
+ signal: AbortSignal,
307
+ tools: PluginTool[],
308
+ hooks: LifecycleHooks[],
309
+ ) => AsyncGenerator<AgentEvent>;
310
+ }
311
+
312
+ export interface StatusSegment {
313
+ text: string;
314
+ color?: string;
315
+ dim?: boolean;
316
+ }
317
+
318
+ export interface Plugin {
319
+ name: string;
320
+ version?: string;
321
+
322
+ tools?: PluginTool[];
323
+ systemPrompt?: string | ((context: PluginContext) => string | Promise<string>);
324
+ hooks?: LifecycleHooks;
325
+ commands?: SlashCommand[];
326
+ agentLoop?: AgentLoopStrategy;
327
+
328
+ activate?: (context: PluginContext) => Promise<void> | void;
329
+ deactivate?: () => Promise<void> | void;
330
+
331
+ /**
332
+ * Plugins may attach arbitrary public fields (e.g. an ApprovalGateway
333
+ * instance, a SourceManager). Sibling plugins fetch them via
334
+ * `ctx.getPlugin<MyPlugin>('name')` and dot into typed fields.
335
+ */
336
+ [extra: string]: unknown;
337
+ }