@brimveyn/aimux-plugin 0.1.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BrimVeyn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # @brimveyn/aimux-plugin
2
+
3
+ Authoring API for [aimux](https://github.com/BrimVeyn/aimux) plugins.
4
+
5
+ A plugin is a directory with a manifest and up to two entry files — one per
6
+ host process. aimux loads them in-process, so a plugin renders real UI and
7
+ reacts to real events; and it unloads them by running every disposer they
8
+ registered, so editing one reloads it in place.
9
+
10
+ ```
11
+ my-plugin/
12
+ aimux-plugin.json manifest — read without executing any code
13
+ ui.ts UI half (optional)
14
+ daemon.ts daemon half (optional)
15
+ package.json
16
+ ```
17
+
18
+ ```jsonc
19
+ // aimux-plugin.json
20
+ {
21
+ "id": "acme.telegram-notify",
22
+ "name": "Telegram notify",
23
+ "version": "0.1.0",
24
+ "apiVersion": 1,
25
+ "minAimuxVersion": "1.24.0",
26
+ "entries": { "daemon": "./daemon.ts" },
27
+ "config": {
28
+ "botToken": { "type": "string", "required": true, "secret": true },
29
+ },
30
+ }
31
+ ```
32
+
33
+ ```ts
34
+ // daemon.ts
35
+ import { type DaemonPluginContext, definePlugin } from '@brimveyn/aimux-plugin'
36
+
37
+ export default definePlugin<DaemonPluginContext>({
38
+ apply(ctx) {
39
+ ctx.on('tab:turnComplete', ({ tabId }) => {
40
+ ctx.log.info('turn complete', { tabId })
41
+ })
42
+
43
+ ctx.effect(() => {
44
+ const timer = setInterval(poll, 60_000)
45
+ return () => clearInterval(timer)
46
+ })
47
+ },
48
+ })
49
+ ```
50
+
51
+ ## The one rule
52
+
53
+ **Everything a plugin registers must be reversible.** Register through
54
+ `ctx.on`, `ctx.effect`, or an API that returns a disposer, and unloading is
55
+ automatic. Reach around the context — a global, a bare `setInterval`, a
56
+ listener on someone else's emitter — and it survives the reload as a leak.
57
+
58
+ ## Context
59
+
60
+ | Member | What it does |
61
+ | ------------------------------------------------- | ---------------------------------------------------------------- |
62
+ | `ctx.id`, `ctx.manifest`, `ctx.host` | identity, and which process this half runs in |
63
+ | `ctx.log` | writes to `<state>/plugin.log`, readable via `aimux plugin log` |
64
+ | `ctx.config` | manifest schema ⊕ registry ⊕ `aimux.config.ts`, defaults applied |
65
+ | `ctx.paths` | `root`, `config`, `state`, `log` |
66
+ | `ctx.effect(setup)` | run now, dispose on unload (reverse order) |
67
+ | `ctx.on(event, listener)` | subscribe; auto-disposed |
68
+ | `ctx.emit / parallel / serial / bail / waterfall` | five dispatch modes |
69
+ | `ctx.rpc.call / handle / broadcast` | talk to this plugin's other half across the process boundary |
70
+ | `ctx.provide / ctx.service` | publish and read services other plugins may `inject` |
71
+
72
+ ## Testing
73
+
74
+ `createTestContext()` builds a context with the real event bus and effect
75
+ stack behind it, and stubs only what crosses a process boundary.
76
+
77
+ ```ts
78
+ import { createTestContext } from '@brimveyn/aimux-plugin'
79
+ import plugin from './daemon'
80
+
81
+ test('notifies on turn complete', async () => {
82
+ const t = createTestContext({ config: { botToken: 'x' }, onCall: () => ({ ok: true }) })
83
+ await t.apply(plugin)
84
+
85
+ t.bus.emit('tab:turnComplete', { tabId: 't1' })
86
+
87
+ await t.dispose()
88
+ expect(t.effectCount()).toBe(0)
89
+ })
90
+ ```
91
+
92
+ ## Author loop
93
+
94
+ ```
95
+ aimux plugin new acme.thing --daemon # scaffold
96
+ aimux plugin link ./acme-thing # register + build, watched from now on
97
+ aimux plugin log acme.thing -f # watch it work
98
+ aimux plugin doctor ./acme-thing # validate manifest, dry-import, list registrations
99
+ ```
100
+
101
+ Full docs: `docs/developer/plugins.md` in the aimux repository.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@brimveyn/aimux-plugin",
3
+ "version": "0.1.2",
4
+ "description": "Plugin authoring API for aimux — context, effects, events and RPC for in-process plugins.",
5
+ "keywords": [
6
+ "aimux",
7
+ "extension",
8
+ "multiplexer",
9
+ "plugin",
10
+ "terminal",
11
+ "tui"
12
+ ],
13
+ "homepage": "https://github.com/BrimVeyn/aimux#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/BrimVeyn/aimux/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "BrimVeyn",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/BrimVeyn/aimux.git",
22
+ "directory": "packages/aimux-plugin"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "type": "module",
30
+ "exports": {
31
+ ".": "./src/index.ts"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "devDependencies": {
37
+ "@types/react": "^19.2.14"
38
+ },
39
+ "peerDependencies": {
40
+ "react": ">=19"
41
+ }
42
+ }
@@ -0,0 +1,156 @@
1
+ import type { Disposer } from './types'
2
+
3
+ /**
4
+ * The daemon half's services — what a plugin reaches through `ctx.tabs`,
5
+ * `ctx.projects`, `ctx.workspaces`, `ctx.assistants`, `ctx.hooks` and
6
+ * `ctx.cli`.
7
+ *
8
+ * Declared here, implemented by `src/daemon/plugin-services.ts`. Same split as
9
+ * the UI half: this package stays free of aimux's internals so a plugin can be
10
+ * typechecked with nothing but `bun install`.
11
+ *
12
+ * The shapes that cross over — a tab view, a project record — are the aimux
13
+ * ones, structurally re-declared where they are small and left `unknown` where
14
+ * re-declaring them would be a second copy to keep in sync.
15
+ */
16
+
17
+ export interface PluginTabView {
18
+ id: string
19
+ projectId: string
20
+ assistant: string
21
+ title: string
22
+ command: string
23
+ workspaceId?: string
24
+ workerName?: string
25
+ }
26
+
27
+ export interface PluginSpawnTabInput {
28
+ projectId: string
29
+ /** A registered assistant id, or a bare command's id. */
30
+ assistant: string
31
+ title: string
32
+ command: string
33
+ args?: string[]
34
+ cwd?: string
35
+ workspaceId?: string
36
+ }
37
+
38
+ export interface PluginTabsApi {
39
+ list: (projectId?: string) => PluginTabView[]
40
+ get: (tabId: string) => PluginTabView | undefined
41
+ /** The tab the UI is focused on for this project, or null. */
42
+ activeId: (projectId: string) => string | null
43
+ /**
44
+ * Spawns a tab and resolves with its id. Sized from the project's last
45
+ * attached dimensions — a plugin has no viewport of its own — so it fails
46
+ * with a clear error if no UI has ever attached to that project.
47
+ */
48
+ spawn: (input: PluginSpawnTabInput) => Promise<string>
49
+ /** Writes to the PTY. Bytes, not a line: the newline is the caller's to add. */
50
+ send: (tabId: string, data: string) => Promise<void>
51
+ /**
52
+ * Sets the tab's title. Reaches every attached UI and the persisted session,
53
+ * so it survives a restart — the same path aimux's own auto-rename uses.
54
+ *
55
+ * A title the user typed is theirs: a plugin that renames on its own
56
+ * schedule should check whether one is already set rather than overwrite it.
57
+ */
58
+ rename: (tabId: string, title: string) => Promise<void>
59
+ focus: (projectId: string, tabId: string) => Promise<void>
60
+ close: (tabId: string) => Promise<void>
61
+ /** The tab's last rendered lines, or null when it has produced no viewport. */
62
+ snapshot: (tabId: string, lines?: number) => string | null
63
+ }
64
+
65
+ /**
66
+ * A workspace, as a plugin sees it. `path` is the directory to run a command
67
+ * in and `repoRoot` the repository it belongs to — for a git worktree those
68
+ * differ, which is exactly why both are here.
69
+ */
70
+ export interface PluginWorkspaceView {
71
+ id: string
72
+ name: string
73
+ path: string
74
+ repoRoot: string
75
+ branch?: string
76
+ baseRef?: string
77
+ }
78
+
79
+ export interface PluginProjectView {
80
+ id: string
81
+ name: string
82
+ /** Where the project lives, when it has a directory of its own. */
83
+ path?: string
84
+ workspaces: PluginWorkspaceView[]
85
+ activeWorkspaceId?: string
86
+ }
87
+
88
+ /**
89
+ * Project and workspace records, read from the catalog on every call because
90
+ * another process may have written it.
91
+ *
92
+ * Narrow projections rather than the raw records: the catalog entry carries a
93
+ * whole persisted UI snapshot — layout trees, scrollback buffers — and handing
94
+ * that to a plugin would make every field of it something aimux can no longer
95
+ * change.
96
+ */
97
+ export interface PluginProjectsApi {
98
+ list: () => PluginProjectView[]
99
+ get: (projectId: string) => PluginProjectView | undefined
100
+ }
101
+
102
+ export interface PluginWorkspacesApi {
103
+ list: (projectId: string) => PluginWorkspaceView[]
104
+ }
105
+
106
+ /**
107
+ * What aimux knows about its own use, per local calendar day — counts and
108
+ * nothing else. No key identity, no content, nothing that leaves the machine.
109
+ */
110
+ export interface PluginCounterDay {
111
+ /** `YYYY-MM-DD`, local calendar. */
112
+ day: string
113
+ values: Record<string, number>
114
+ }
115
+
116
+ export interface PluginMetricsApi {
117
+ /** Newest day first. `days` caps how far back it looks; default 30. */
118
+ counters: (days?: number) => PluginCounterDay[]
119
+ }
120
+
121
+ export interface PluginAssistantsApi {
122
+ /**
123
+ * Registers a complete assistant: spawn command, status classifier, question
124
+ * parser, usage adapter, hook mapping. See `AssistantDefinition` in
125
+ * `src/pty/assistant-registry.ts` for the shape.
126
+ */
127
+ register: (definition: unknown) => Disposer
128
+ }
129
+
130
+ export interface PluginHooksApi {
131
+ /**
132
+ * Adds an HTTP hook route. The id is namespaced, so the path a bridge script
133
+ * POSTs to is `/hook/<pluginId>.<id>` and cannot collide with another
134
+ * plugin's — or with `claude`.
135
+ */
136
+ route: (routeId: string, onEvent: (event: unknown) => void) => Disposer
137
+ /** The URL to hand a bridge script, or null when the route is not registered. */
138
+ url: (routeId: string) => string | null
139
+ }
140
+
141
+ export interface PluginCliApi {
142
+ /**
143
+ * Adds an `aimux <group> <verb>`. The command runs here, in the daemon; the
144
+ * CLI process learns its shape from a sidecar and never loads plugin code.
145
+ * Whatever `run` returns becomes the command's JSON body on stdout.
146
+ */
147
+ register: (command: {
148
+ group: string
149
+ verb: string
150
+ summary: string
151
+ run: (args: {
152
+ flags: Record<string, string | number | boolean>
153
+ positionals: string[]
154
+ }) => Promise<unknown>
155
+ }) => Disposer
156
+ }
@@ -0,0 +1,21 @@
1
+ import type { PluginContext, PluginDefinition } from './types'
2
+
3
+ /**
4
+ * Identity function that pins the context type, so `apply(ctx)` is inferred
5
+ * rather than annotated:
6
+ *
7
+ * ```ts
8
+ * export default definePlugin<UiPluginContext>({
9
+ * inject: ['ui'],
10
+ * apply(ctx) { ctx.log.info('hello') },
11
+ * })
12
+ * ```
13
+ *
14
+ * Plain `definePlugin({ … })` gives the base context, which is all a plugin
15
+ * that only uses `log` / `config` / `effect` / `on` / `rpc` needs.
16
+ */
17
+ export function definePlugin<Ctx extends PluginContext = PluginContext>(
18
+ definition: PluginDefinition<Ctx>
19
+ ): PluginDefinition<Ctx> {
20
+ return definition
21
+ }
package/src/effects.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { Disposer } from './types'
2
+
3
+ /**
4
+ * The disposer stack behind `ctx.effect` and every `register()` that hands one
5
+ * back. Unwinds in reverse registration order — the same discipline as a
6
+ * destructor chain — so a later effect can rely on an earlier one still being
7
+ * live while it tears itself down.
8
+ *
9
+ * A throwing disposer never stops the unwind: the remaining effects still run
10
+ * and the errors are reported together. A half-disposed fiber is the one state
11
+ * hot reload cannot recover from.
12
+ */
13
+ export class EffectStack {
14
+ private disposers: Disposer[] = []
15
+ private disposed = false
16
+
17
+ get size(): number {
18
+ return this.disposers.length
19
+ }
20
+
21
+ get isDisposed(): boolean {
22
+ return this.disposed
23
+ }
24
+
25
+ /**
26
+ * Registers a disposer. Adding one to an already-disposed stack runs it
27
+ * immediately rather than leaking it — that happens when an async `apply`
28
+ * resolves after the fiber was torn down.
29
+ */
30
+ add(disposer: Disposer): void {
31
+ if (this.disposed) {
32
+ void this.disposeOrphan(disposer)
33
+ return
34
+ }
35
+ this.disposers.push(disposer)
36
+ }
37
+
38
+ private async disposeOrphan(disposer: Disposer): Promise<void> {
39
+ try {
40
+ await disposer()
41
+ } catch {
42
+ // Nothing left to report to: the stack this belonged to is gone.
43
+ }
44
+ }
45
+
46
+ /** Runs `setup` now and registers whatever disposer it returns. */
47
+ async run(setup: () => Disposer | void | Promise<Disposer | void>): Promise<void> {
48
+ const disposer = await setup()
49
+ if (typeof disposer === 'function') this.add(disposer)
50
+ }
51
+
52
+ /** Unwind. Returns every error thrown, in the order they were thrown. */
53
+ async dispose(): Promise<unknown[]> {
54
+ this.disposed = true
55
+ const pending = this.disposers
56
+ this.disposers = []
57
+ const errors: unknown[] = []
58
+ for (let i = pending.length - 1; i >= 0; i--) {
59
+ try {
60
+ await pending[i]?.()
61
+ } catch (error) {
62
+ errors.push(error)
63
+ }
64
+ }
65
+ return errors
66
+ }
67
+ }
@@ -0,0 +1,137 @@
1
+ import type { PluginEventListener } from './types'
2
+
3
+ /**
4
+ * The event bus lives in the public package rather than in the host so that
5
+ * `createTestContext()` dispatches exactly the way the kernel does. A plugin
6
+ * test that passes against the harness is testing the real semantics of
7
+ * `bail` and `waterfall`, not an approximation of them.
8
+ *
9
+ * It has no aimux dependencies — it is a listener map and five dispatch
10
+ * strategies.
11
+ */
12
+
13
+ interface Registration {
14
+ listener: PluginEventListener<never>
15
+ /** Only used for error attribution; the bus itself is owner-agnostic. */
16
+ owner?: string
17
+ }
18
+
19
+ export interface EventBusOptions {
20
+ /**
21
+ * Called when a listener throws or rejects. `emit` has nowhere to put the
22
+ * error otherwise, and swallowing it silently is how a plugin bug becomes
23
+ * a mystery. The awaiting modes rethrow instead.
24
+ */
25
+ onError?: (error: unknown, context: { event: string; owner?: string }) => void
26
+ }
27
+
28
+ export class PluginEventBus {
29
+ private readonly listeners = new Map<string, Registration[]>()
30
+
31
+ constructor(private readonly options: EventBusOptions = {}) {}
32
+
33
+ /** The unsubscribe handle. Synchronous — nothing here can suspend. */
34
+ on<T = unknown>(event: string, listener: PluginEventListener<T>, owner?: string): () => void {
35
+ const registration: Registration = { listener: listener as PluginEventListener<never>, owner }
36
+ const existing = this.listeners.get(event)
37
+ if (existing) existing.push(registration)
38
+ else this.listeners.set(event, [registration])
39
+
40
+ return () => {
41
+ const current = this.listeners.get(event)
42
+ if (!current) return
43
+ const index = current.indexOf(registration)
44
+ if (index !== -1) current.splice(index, 1)
45
+ if (current.length === 0) this.listeners.delete(event)
46
+ }
47
+ }
48
+
49
+ /** Snapshot: a listener that unsubscribes mid-dispatch must not shift the list. */
50
+ private snapshot(event: string): Registration[] {
51
+ const current = this.listeners.get(event)
52
+ return current ? [...current] : []
53
+ }
54
+
55
+ listenerCount(event: string): number {
56
+ return this.listeners.get(event)?.length ?? 0
57
+ }
58
+
59
+ /** Every event that currently has at least one listener. */
60
+ events(): string[] {
61
+ return [...this.listeners.keys()]
62
+ }
63
+
64
+ private report(error: unknown, event: string, owner?: string): void {
65
+ this.options.onError?.(error, { event, owner })
66
+ }
67
+
68
+ private async reportRejection(
69
+ result: Promise<unknown>,
70
+ event: string,
71
+ owner?: string
72
+ ): Promise<void> {
73
+ try {
74
+ await result
75
+ } catch (error) {
76
+ this.report(error, event, owner)
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Synchronous listeners run synchronously — `emit` then asserting on the
82
+ * effect must not need an intervening await. Only an async listener's
83
+ * rejection is picked up later.
84
+ */
85
+ emit(event: string, payload?: unknown): void {
86
+ for (const { listener, owner } of this.snapshot(event)) {
87
+ let result: unknown
88
+ try {
89
+ result = listener(payload as never)
90
+ } catch (error) {
91
+ this.report(error, event, owner)
92
+ continue
93
+ }
94
+ if (result instanceof Promise) void this.reportRejection(result, event, owner)
95
+ }
96
+ }
97
+
98
+ async parallel(event: string, payload?: unknown): Promise<unknown[]> {
99
+ return Promise.all(this.snapshot(event).map(({ listener }) => listener(payload as never)))
100
+ }
101
+
102
+ async serial(event: string, payload?: unknown): Promise<unknown[]> {
103
+ const results: unknown[] = []
104
+ for (const { listener } of this.snapshot(event)) {
105
+ results.push(await listener(payload as never))
106
+ }
107
+ return results
108
+ }
109
+
110
+ /** First listener to return anything but `undefined` wins; the rest never run. */
111
+ async bail<T>(event: string, payload?: unknown): Promise<T | undefined> {
112
+ for (const { listener } of this.snapshot(event)) {
113
+ const result = await listener(payload as never)
114
+ if (result !== undefined) return result as T
115
+ }
116
+ return undefined
117
+ }
118
+
119
+ /**
120
+ * Threads a value through the chain. A listener returning `undefined` is
121
+ * read as "no opinion" and leaves the value untouched, so a listener that
122
+ * only inspects the value does not have to remember to return it.
123
+ */
124
+ async waterfall<T>(event: string, value: T): Promise<T> {
125
+ let current = value
126
+ for (const { listener } of this.snapshot(event)) {
127
+ const next = await listener(current as never)
128
+ if (next !== undefined) current = next as T
129
+ }
130
+ return current
131
+ }
132
+
133
+ /** Drop every listener. Used when a whole kernel is torn down. */
134
+ clear(): void {
135
+ this.listeners.clear()
136
+ }
137
+ }
package/src/index.ts ADDED
@@ -0,0 +1,82 @@
1
+ // Public API — what plugin authors import from '@brimveyn/aimux-plugin'.
2
+ //
3
+ // Everything here is `apiVersion: 1`. A plugin declares that number in its
4
+ // manifest and aimux refuses to load a plugin written against a generation it
5
+ // does not implement, rather than failing halfway through `apply`.
6
+
7
+ export type {
8
+ PluginAssistantsApi,
9
+ PluginCliApi,
10
+ PluginCounterDay,
11
+ PluginHooksApi,
12
+ PluginMetricsApi,
13
+ PluginProjectsApi,
14
+ PluginProjectView,
15
+ PluginSpawnTabInput,
16
+ PluginTabsApi,
17
+ PluginTabView,
18
+ PluginWorkspacesApi,
19
+ PluginWorkspaceView,
20
+ } from './daemon-api'
21
+ export { definePlugin } from './define-plugin'
22
+ export { EffectStack } from './effects'
23
+ export { type EventBusOptions, PluginEventBus } from './event-bus'
24
+ export {
25
+ PLUGIN_API_VERSION,
26
+ type PluginBarContribution,
27
+ type PluginCommandSpec,
28
+ type PluginConfigField,
29
+ type PluginConfigFieldType,
30
+ type PluginContributions,
31
+ type PluginHost,
32
+ type PluginKeymapContribution,
33
+ type PluginManifest,
34
+ } from './manifest'
35
+ export {
36
+ createTestContext,
37
+ type TestContextHandle,
38
+ type TestContextOptions,
39
+ type TestLogEntry,
40
+ type TestRpcCall,
41
+ } from './test-context'
42
+ export type {
43
+ PluginActionsApi,
44
+ PluginBarWidget,
45
+ PluginComponent,
46
+ PluginKit,
47
+ PluginModal,
48
+ PluginModalsApi,
49
+ PluginNode,
50
+ PluginPane,
51
+ PluginPanesApi,
52
+ PluginSettingsApi,
53
+ PluginSettingValue,
54
+ PluginStateApi,
55
+ PluginStatsApi,
56
+ PluginStatsPage,
57
+ PluginStatusBarApi,
58
+ PluginStatusBarSegment,
59
+ PluginStoreApi,
60
+ PluginTabInfo,
61
+ PluginThemeMode,
62
+ PluginThemesApi,
63
+ PluginThemeSnapshot,
64
+ PluginToastApi,
65
+ PluginUiApi,
66
+ PluginUiState,
67
+ PluginView,
68
+ PluginViewsApi,
69
+ PluginWidgetsApi,
70
+ } from './ui'
71
+ export type {
72
+ DaemonPluginContext,
73
+ Disposer,
74
+ PluginContext,
75
+ PluginDefinition,
76
+ PluginEventDispatch,
77
+ PluginEventListener,
78
+ PluginLogger,
79
+ PluginPaths,
80
+ PluginRpc,
81
+ UiPluginContext,
82
+ } from './types'