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.
- package/README.md +49 -3
- package/index.tsx +446 -0
- package/package.json +26 -4
package/README.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
|
-
# aibp-opencode
|
|
1
|
+
# aibp-opencode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
AIBP (AI Bridge Protocol) 接收端插件,让 **opencode** 成为 microNeo 的 AI 接收端:在 microNeo 里选中代码按 Alt-Enter,内容递送到当前运行的 opencode。
|
|
4
4
|
|
|
5
|
-
|
|
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": "
|
|
4
|
-
"
|
|
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
|
-
"
|
|
7
|
-
"
|
|
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
|
}
|