@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 +21 -0
- package/README.md +101 -0
- package/package.json +42 -0
- package/src/daemon-api.ts +156 -0
- package/src/define-plugin.ts +21 -0
- package/src/effects.ts +67 -0
- package/src/event-bus.ts +137 -0
- package/src/index.ts +82 -0
- package/src/manifest.ts +109 -0
- package/src/test-context.ts +215 -0
- package/src/types.ts +151 -0
- package/src/ui.ts +326 -0
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
|
+
}
|
package/src/event-bus.ts
ADDED
|
@@ -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'
|