aibp-opencode 0.0.0 → 1.0.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.
Files changed (3) hide show
  1. package/README.md +49 -3
  2. package/index.tsx +446 -0
  3. package/package.json +26 -4
package/README.md CHANGED
@@ -1,5 +1,51 @@
1
- # aibp-opencode (placeholder)
1
+ # aibp-opencode
2
2
 
3
- 占位包,未实现。未来将作为 AIBP (AI Bridge Protocol) opencode 上的接收端扩展发布。
3
+ AIBP (AI Bridge Protocol) 接收端插件,让 **opencode** 成为 microNeo 的 AI 接收端:在 microNeo 里选中代码按 Alt-Enter,内容递送到当前运行的 opencode。
4
4
 
5
- 参见:[microNeo](https://github.com/sollawen/microNeo)
5
+ ## 工作原理
6
+
7
+ - **形态**:opencode **TUI 插件**(`export default { id, tui }`)。在 opencode 主界面就绪时立即加载(不受 instance bootstrap gating 影响),完成「注册名字 + 显示名字 + 开 socket」。
8
+ - **协议**:与 [`aibp-pi`](../pi) 同协议(`aibp-1`),共用同一名字池文件与 registryDir,pi 与 opencode 并存时自动分配不同名字(如 Alpha / Bravo)。
9
+ - **递送**:收到 microNeo 消息后,通过 `api.client.tui.*`(`clearPrompt` + `appendPrompt` + `submitPrompt`)填输入框并触发 LLM 对话;纯上下文则只填输入框不提交。
10
+
11
+ 详见 `docs/agent-comm/D19b-插件加载时机与形态反转.md`。
12
+
13
+ ## 安装(本地开发)
14
+
15
+ 源码在 `aibp-agents/opencode/`,Bun 直接加载 `.ts`,无需预编译。
16
+
17
+ ### 方式一:path plugin(推荐,源码就在本地)
18
+
19
+ ```bash
20
+ cd /path/to/microNeo
21
+ opencode plugin ./aibp-agents/opencode -g # 写入全局 ~/.config/opencode/opencode.json
22
+ ```
23
+
24
+ 然后重启 opencode。启动后右下角应弹 toast `aibp 已就绪 ● Bravo`(名字视池子分配而定)。
25
+
26
+ ### 方式二:发布到 npm 后
27
+
28
+ ```bash
29
+ opencode plugin aibp-opencode -g
30
+ ```
31
+
32
+ ## 验证
33
+
34
+ ```bash
35
+ # 1. opencode 启动后,注册文件应已生成(不用先发消息)
36
+ ls "$XDG_RUNTIME_DIR/microneo-agent-bridge-$(id -u)/" # 见 ai-Bravo.json
37
+ # 2. 名字池(与 aibp-pi 共用)
38
+ cat ~/.config/aibp/aibp-names.json
39
+ ```
40
+
41
+ 然后在 microNeo 里选中代码按 Alt-Enter,opencode 端应:
42
+ - 带消息 → 自动发起对话(输入框被填 + 提交);
43
+ - 纯上下文 → 仅填入输入框,等用户编辑后手动发送。
44
+
45
+ ## 卸载
46
+
47
+ ```bash
48
+ opencode plugin remove aibp-opencode # 或从 opencode.json 的 plugin[] 删条目
49
+ ```
50
+
51
+ microNeo 侧零改动,协议 agent 无关。
package/index.tsx ADDED
@@ -0,0 +1,446 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ // aibp-opencode —— AIBP (AI Bridge Protocol) 在 opencode 上的接收端插件。
3
+ //
4
+ // 设计要点(见 docs/agent-comm/D19b-插件加载时机与形态反转.md):
5
+ // - 形态:TUI 插件(export default { id, tui })。
6
+ // TUI 插件在 App mount(主界面就绪)时立即加载,满足「启动即注册」需求。
7
+ // - 协议层(registryDir / 名字池 / formatText / 分帧 / 版本校验)逐字复制 aibp-pi。
8
+ // - 递送:最简版——只把消息发到 TUI 当前正在看的对话,不创建 session、不选 agent/model。
9
+ // - 显示名字:toast 通知 + app_bottom slot 持久显示;清理用 api.lifecycle.onDispose。
10
+ //
11
+ // 注意:import type 在 Bun 运行时擦除,本文件零运行时外部依赖(仅 node:*)。
12
+ // JSX 运行时使用 opencode 环境已有的 @opentui/solid(peerDependency)。
13
+ // 调试日志:写 /tmp/aibp-opencode.log(append),tail -f 可实时观察。
14
+
15
+ import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
16
+ import type { JSX } from "@opentui/solid"
17
+ import * as net from "node:net"
18
+ import * as fs from "node:fs"
19
+ import * as path from "node:path"
20
+ import * as os from "node:os"
21
+ import { fileURLToPath } from "node:url"
22
+
23
+ // ===== 诊断日志 =====
24
+ const LOG_FILE = "/tmp/aibp-opencode.log"
25
+ let LOG_TAG = "boot" // 分配名字前用 "boot",注册成功后换成名字
26
+ function log(message: string, data?: unknown) {
27
+ try {
28
+ const ts = new Date().toISOString()
29
+ const body =
30
+ data === undefined ? "" : typeof data === "string" ? " " + data : " " + JSON.stringify(data)
31
+ fs.appendFileSync(LOG_FILE, `${ts} [${LOG_TAG}] ${message}${body}\n`)
32
+ } catch {}
33
+ }
34
+
35
+ log("===== module loaded =====")
36
+
37
+ // —— 协议版本单一事实来源:package.json 的 aibp.protocol ——
38
+ let PROTOCOL = "aibp-1"
39
+ let PROTOCOL_MAJOR = 1
40
+ try {
41
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
42
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"))
43
+ if (pkg?.aibp?.protocol) {
44
+ PROTOCOL = pkg.aibp.protocol
45
+ PROTOCOL_MAJOR = Number(PROTOCOL.split("-").pop())
46
+ }
47
+ } catch {}
48
+ log("protocol detected", { protocol: PROTOCOL, major: PROTOCOL_MAJOR })
49
+
50
+ // D11 §4.2:默认 NATO 音标字母表前 15 个 A–O(去连字符满足 §4.3 字符约束)。
51
+ const DEFAULT_NAMES_STR =
52
+ "Alpha Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliet Kilo Lima Mike November Oscar"
53
+
54
+ const tui: TuiPlugin = async (api: TuiPluginApi) => {
55
+ const client = api.client // = OpencodeClient
56
+
57
+ let server: net.Server | null = null
58
+ let name = "",
59
+ socketPath = "",
60
+ regFile = "",
61
+ slotRegId: string | undefined
62
+
63
+ log("===== tui() invoked, plugin starting =====", { pid: process.pid, cwd: process.cwd() })
64
+
65
+ function toast(message: string, variant: "info" | "warning" = "info") {
66
+ log("toast", { message, variant })
67
+ try {
68
+ api.ui.toast({ message, variant })
69
+ } catch (e) {
70
+ log("toast failed", { error: (e as Error).message })
71
+ }
72
+ }
73
+
74
+ // ===== 启动主体 =====
75
+
76
+ const names = loadNamePool()
77
+ log("name pool loaded", { ok: names !== null, count: names?.length, names })
78
+ if (names === null) {
79
+ toast("⚠ aibp/aibp-names.json 格式错误,本次不接收消息", "warning")
80
+ log("name pool invalid, abort")
81
+ return
82
+ }
83
+
84
+ const connectionHandler = (conn: net.Socket) => {
85
+ log("connection accepted")
86
+ let buf = ""
87
+ conn.on("data", (chunk) => {
88
+ const str = chunk.toString("utf8")
89
+ log("data chunk", { len: chunk.length, preview: str.slice(0, 200) })
90
+ buf += str
91
+ let nl
92
+ while ((nl = buf.indexOf("\n")) >= 0) {
93
+ handleLine(buf.slice(0, nl))
94
+ buf = buf.slice(nl + 1)
95
+ }
96
+ })
97
+ conn.on("error", (e) => log("connection error", { error: e.message }))
98
+ conn.on("close", () => log("connection closed"))
99
+ }
100
+
101
+ const got = await allocateName(names, connectionHandler)
102
+ if (got === null) {
103
+ toast("⚠ aibp 名字池已满,本次不接收消息", "warning")
104
+ log("name allocation exhausted, abort")
105
+ return
106
+ }
107
+ name = got.name
108
+ socketPath = got.socketPath
109
+ LOG_TAG = name // 后续日志带名字
110
+
111
+ regFile = path.join(registryDir(), `ai-${name}.json`)
112
+ fs.writeFileSync(
113
+ regFile,
114
+ JSON.stringify({
115
+ name,
116
+ pid: process.pid,
117
+ transport: "unix",
118
+ socket: socketPath,
119
+ protocol: PROTOCOL,
120
+ startedAt: Math.floor(Date.now() / 1000),
121
+ cwd: process.cwd(),
122
+ labels: ["default"],
123
+ }),
124
+ )
125
+ log("registry file written", { regFile, name, pid: process.pid, socketPath })
126
+
127
+ log("===== ready =====", { name })
128
+
129
+ // ===== 注册 app_bottom slot 持久显示名字 =====
130
+ try {
131
+ const slotPlugin: TuiSlotPlugin = {
132
+ order: 1000, // 靠后显示,避免遮挡其他内容
133
+ slots: {
134
+ app_bottom(ctx) {
135
+ return <text>● {name}</text>
136
+ },
137
+ },
138
+ }
139
+ slotRegId = api.slots.register(slotPlugin)
140
+ log("app_bottom slot registered", { slotRegId })
141
+ } catch (e) {
142
+ log("slot registration failed", { error: (e as Error).message })
143
+ toast("⚠ aibp 已就绪,但底部显示失败", "warning")
144
+ }
145
+
146
+ const cleanup = () => {
147
+ log("cleanup start", { name, regFile, socketPath, slotRegId })
148
+ try {
149
+ server?.close()
150
+ } catch {}
151
+ // opencode 自动清理 slot,无需手动 unregister
152
+ try {
153
+ fs.unlinkSync(regFile)
154
+ } catch {}
155
+ try {
156
+ fs.unlinkSync(socketPath)
157
+ } catch {}
158
+ log("cleanup done")
159
+ }
160
+ api.lifecycle.onDispose(cleanup)
161
+
162
+ // ===== 报文处理(递送)=====
163
+
164
+ function handleLine(line: string) {
165
+ log("line received", { len: line.length, line })
166
+ let env: any
167
+ try {
168
+ env = JSON.parse(line)
169
+ } catch (e) {
170
+ log("parse failed", { line, error: (e as Error).message })
171
+ return
172
+ }
173
+ log("envelope parsed", { v: env.v, type: env.type, hasPayload: !!env.payload })
174
+ if (env.v !== PROTOCOL_MAJOR || env.type !== "context") {
175
+ log("envelope rejected (version/type mismatch)", {
176
+ gotV: env.v,
177
+ expectedV: PROTOCOL_MAJOR,
178
+ gotType: env.type,
179
+ expectedType: "context",
180
+ })
181
+ return
182
+ }
183
+ void onMessage(env)
184
+ }
185
+
186
+ // 递送策略(最简版):只发到 TUI 当前正在看的对话,不创建 session、不选 agent/model。
187
+ async function onMessage(env: any) {
188
+ const p = env.payload
189
+ log("onMessage", {
190
+ path: p?.path,
191
+ hasSelection: !!p?.selection,
192
+ selectionLen: p?.selection?.text?.length,
193
+ selectionStart: p?.selection?.start,
194
+ selectionEnd: p?.selection?.end,
195
+ cursor: p?.cursor,
196
+ hasMessage: !!p?.message,
197
+ message: p?.message,
198
+ })
199
+
200
+ const text = formatText(p)
201
+ log("formatText output (FULL TEXT SENT TO LLM)", { text })
202
+
203
+ try {
204
+ const route = api.route.current as any
205
+ log("current route", {
206
+ name: route?.name,
207
+ params: route?.params,
208
+ })
209
+
210
+ if (route?.name !== "session" || typeof route?.params?.sessionID !== "string") {
211
+ log("no active session in TUI, skipping delivery")
212
+ toast("⚠ 请先在 opencode 打开一个对话", "warning")
213
+ return
214
+ }
215
+
216
+ const sessionID = route.params.sessionID
217
+ // v2 SDK 顶层参数风格(非 v1 的 {path, body})。
218
+ // 插件拿到的 api.client 是 @opencode-ai/sdk/v2 的 OpencodeClient,
219
+ // session.prompt 签名是 ({ sessionID, parts, agent?, ... }),URL=/session/{sessionID}/message。
220
+ // opencode 自己(prompt/index.tsx:1090)就是这么调的。fire-and-forget:不 await,
221
+ // TUI 通过 session 订阅渲染流式响应。
222
+ log("session.prompt calling", { sessionID })
223
+
224
+ client.session
225
+ .prompt({
226
+ sessionID,
227
+ parts: [{ type: "text", text }],
228
+ })
229
+ .then((res: any) => {
230
+ log("session.prompt resolved", {
231
+ status: res?.status,
232
+ hasData: !!res?.data,
233
+ hasError: !!res?.error,
234
+ error: res?.error,
235
+ })
236
+ })
237
+ .catch((e: any) => {
238
+ log("session.prompt REJECTED", { message: e?.message, stack: e?.stack, name: e?.name })
239
+ })
240
+ } catch (e) {
241
+ const err = e as Error
242
+ log("onMessage ERROR", { message: err.message, stack: err.stack, name: err.name })
243
+ toast(`⚠ aibp 递送失败: ${err.message}`, "warning")
244
+ }
245
+ }
246
+
247
+ // ===== 以下 ⟨复制 aibp-pi⟩ =====
248
+
249
+ function normalizeNames(raw: string[]): string[] {
250
+ const truncated = raw.map((n) => (typeof n === "string" ? n.slice(0, 10) : ""))
251
+ const seen = new Set<string>()
252
+ const deduped: string[] = []
253
+ for (const n of truncated) {
254
+ if (n && !seen.has(n)) {
255
+ seen.add(n)
256
+ deduped.push(n)
257
+ }
258
+ }
259
+ return deduped.filter((n) => {
260
+ if (/[/\0: -]/.test(n)) {
261
+ log("skip illegal name", { name: n })
262
+ return false
263
+ }
264
+ return true
265
+ })
266
+ }
267
+
268
+ function loadNamePool(): string[] | null {
269
+ const xdg = process.env.XDG_CONFIG_HOME
270
+ const configBase = xdg || path.join(os.homedir(), ".config")
271
+ const poolFile = path.join(configBase, "aibp", "aibp-names.json")
272
+ log("loading name pool", { poolFile })
273
+
274
+ let raw: string[] | null = null
275
+
276
+ if (fs.existsSync(poolFile)) {
277
+ try {
278
+ const parsed = JSON.parse(fs.readFileSync(poolFile, "utf8"))
279
+ if (Array.isArray(parsed) && parsed.length > 0) {
280
+ raw = parsed
281
+ }
282
+ } catch {
283
+ toast("⚠ aibp/aibp-names.json 格式错误,本次不接收消息", "warning")
284
+ log("name pool file corrupt")
285
+ return null
286
+ }
287
+ }
288
+
289
+ if (raw === null) {
290
+ fs.mkdirSync(path.dirname(poolFile), { recursive: true, mode: 0o700 })
291
+ fs.writeFileSync(poolFile, JSON.stringify(DEFAULT_NAMES_STR.split(" ")), { mode: 0o600 })
292
+ raw = DEFAULT_NAMES_STR.split(" ")
293
+ log("seeded default name pool")
294
+ }
295
+
296
+ return normalizeNames(raw)
297
+ }
298
+
299
+ async function allocateName(
300
+ names: string[],
301
+ connectionHandler: (conn: net.Socket) => void,
302
+ ): Promise<{ name: string; socketPath: string } | null> {
303
+ const dir = registryDir()
304
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
305
+ log("allocating name", { dir, candidates: names })
306
+
307
+ const occupied = new Set<string>()
308
+ let entries: fs.Dirent[]
309
+ try {
310
+ entries = fs.readdirSync(dir, { withFileTypes: true })
311
+ } catch {
312
+ entries = []
313
+ }
314
+ for (const entry of entries) {
315
+ if (!entry.isFile()) continue
316
+ const m = entry.name.match(/^ai-(.+)\.json$/)
317
+ if (!m) continue
318
+ const rid = m[1]
319
+ let pid: number | null = null
320
+ try {
321
+ const reg = JSON.parse(fs.readFileSync(path.join(dir, entry.name), "utf8"))
322
+ if (typeof reg.pid === "number") pid = reg.pid
323
+ } catch {
324
+ continue
325
+ }
326
+ let alive = false
327
+ if (pid !== null) {
328
+ try {
329
+ process.kill(pid, 0)
330
+ alive = true
331
+ } catch {
332
+ alive = false
333
+ }
334
+ }
335
+ if (alive) {
336
+ occupied.add(rid)
337
+ } else {
338
+ log("gc zombie registry", { rid, pid })
339
+ try {
340
+ fs.unlinkSync(path.join(dir, entry.name))
341
+ } catch {}
342
+ try {
343
+ fs.unlinkSync(path.join(dir, `ai-${rid}.sock`))
344
+ } catch {}
345
+ }
346
+ }
347
+ log("scan done", { occupied: [...occupied] })
348
+
349
+ const tryListen = (sockPath: string): Promise<boolean> => {
350
+ return new Promise((resolve) => {
351
+ const s = net.createServer(connectionHandler)
352
+ let settled = false
353
+ const finish = (ok: boolean) => {
354
+ if (settled) return
355
+ settled = true
356
+ if (ok) {
357
+ s.removeAllListeners("error")
358
+ s.on("error", (err) => log("server runtime error", { error: err.message }))
359
+ server = s
360
+ resolve(true)
361
+ } else {
362
+ s.close()
363
+ resolve(false)
364
+ }
365
+ }
366
+ s.once("listening", () => finish(true))
367
+ s.once("error", (err: NodeJS.ErrnoException) => {
368
+ log("listen error", { sockPath, code: err.code, message: err.message })
369
+ finish(false)
370
+ })
371
+ s.listen(sockPath)
372
+ })
373
+ }
374
+
375
+ for (const n of names) {
376
+ if (occupied.has(n)) {
377
+ log("name occupied, skip", { name: n })
378
+ continue
379
+ }
380
+ const sockPath = path.join(dir, `ai-${n}.sock`)
381
+ log("try listen", { name: n, sockPath })
382
+
383
+ if (await tryListen(sockPath)) {
384
+ fs.chmodSync(sockPath, 0o600)
385
+ log("listen ok, allocated", { name: n, sockPath })
386
+ return { name: n, socketPath: sockPath }
387
+ }
388
+
389
+ const alive = await new Promise<boolean>((resolve) => {
390
+ const probe = net.connect(sockPath)
391
+ let done = false
392
+ const finish = (ok: boolean) => {
393
+ if (done) return
394
+ done = true
395
+ probe.destroy()
396
+ resolve(ok)
397
+ }
398
+ probe.once("connect", () => finish(true))
399
+ probe.once("error", () => finish(false))
400
+ setTimeout(() => finish(false), 200)
401
+ })
402
+ log("probe result", { name: n, alive })
403
+
404
+ if (!alive) {
405
+ try {
406
+ fs.unlinkSync(sockPath)
407
+ } catch {}
408
+ if (await tryListen(sockPath)) {
409
+ fs.chmodSync(sockPath, 0o600)
410
+ log("listen ok after gc, allocated", { name: n, sockPath })
411
+ return { name: n, socketPath: sockPath }
412
+ }
413
+ }
414
+ }
415
+
416
+ return null
417
+ }
418
+
419
+ function registryDir(): string {
420
+ const override = process.env.MNAB_REG_DIR
421
+ if (override) return override
422
+ const base = process.env.XDG_RUNTIME_DIR || process.env.TMPDIR || "/tmp"
423
+ return path.join(base, `microneo-agent-bridge-${process.getuid?.() ?? 0}`)
424
+ }
425
+
426
+ function formatText(p: any): string {
427
+ const sel = p.selection
428
+ const selText = sel?.text && sel.text.length > 0 ? sel.text : ""
429
+
430
+ if (sel && selText) {
431
+ const header = `<selection: ${p.path} lines ${sel.start.line}-${sel.end.line}>`
432
+ return p.message ? `${header}\n\n${selText}\n\n<user input>\n\n${p.message}` : `${header}\n\n${selText}`
433
+ }
434
+
435
+ const focus = sel ? `line${sel.start.line}-${sel.end.line}` : `${p.cursor.line}`
436
+ const base = `@${p.path} :line${focus}`
437
+ return p.message ? `${base}\n\n${p.message}` : base
438
+ }
439
+ }
440
+
441
+ const plugin: TuiPluginModule = {
442
+ id: "aibp-opencode", // 文件式加载必需
443
+ tui,
444
+ }
445
+
446
+ export default plugin
package/package.json CHANGED
@@ -1,8 +1,30 @@
1
1
  {
2
2
  "name": "aibp-opencode",
3
- "version": "0.0.0",
4
- "description": "AIBP (AI Bridge Protocol) receiver extension for opencode (placeholder)",
3
+ "version": "1.0.0",
4
+ "aibp": {
5
+ "protocol": "aibp-1"
6
+ },
7
+ "description": "AIBP (AI Bridge Protocol) receiver plugin for opencode",
5
8
  "license": "MIT",
6
- "keywords": ["pi-package", "microNeo", "opencode"],
7
- "files": ["README.md"]
9
+ "type": "module",
10
+ "main": "./index.tsx",
11
+ "exports": {
12
+ ".": "./index.tsx",
13
+ "./tui": "./index.tsx"
14
+ },
15
+ "keywords": [
16
+ "opencode-plugin",
17
+ "microNeo",
18
+ "aibp"
19
+ ],
20
+ "files": [
21
+ "index.tsx",
22
+ "README.md"
23
+ ],
24
+ "peerDependencies": {
25
+ "@opencode-ai/plugin": ">=1.4.0"
26
+ },
27
+ "engines": {
28
+ "opencode": ">=1.4.0"
29
+ }
8
30
  }