Панель «Токены» для веб-интерфейса OpenCode 2 как плагин

Прокси (server.mjs) вставляет panel.js в страницы веб-интерфейса и отдаёт
/__ctx/api/* из opencode.db. Теперь он поднимается плагином
context-panel.v2.ts внутри процесса OpenCode, а не отдельным Node-процессом.

- server.mjs: экспортирует startPanel(); SQLite — bun:sqlite под Bun,
  node:sqlite под Node; ручной запуск `node server.mjs …` сохранён.
- context-panel.v2.ts: один прокси на процесс (globalThis), при dispose
  не закрывается; порты из CTX_PANEL_LISTEN / CTX_PANEL_TARGET,
  выключение — OPENCODE_CONTEXT_PANEL=0, журнал — log/context-panel.log.

Плагины грузятся лениво (при первом запросе к API проекта), поэтому
обёртка должна сама дёрнуть API после старта `opencode serve`.
This commit is contained in:
2026-10-29 21:24:45 -07:00
commit 1af3ff9d10
3 changed files with 1393 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
/**
* context-panel для OpenCode 2: панель «Токены» в веб-интерфейсе — без отдельного процесса.
*
* Плагин поднимает внутри процесса OpenCode тот же прокси, что server.mjs (startPanel): он
* слушает CTX_PANEL_LISTEN (4196), пропускает всё на CTX_PANEL_TARGET (4197, порт самого
* `opencode serve`) и вставляет в страницы panel.js. Встроить панель прямо в порт OpenCode
* нельзя: веб-интерфейс зашит в бинарник, а свои HTTP-маршруты плагину добавить нечем.
*
* Что учтено (проверено на 2.1.3):
* - плагин грузится лениво — при первом запросе к API проекта, а не при старте `serve`.
* Поэтому обёртка после старта сама дёргает API (bin/opencode-web), иначе на 4196 пусто;
* - модуль импортируется заново для каждого проекта — прокси один на процесс (globalThis);
* - при dispose прокси не закрывается: браузер ходит через него, и без него OpenCode не
* получил бы запрос, который загрузит плагин снова. Живёт, пока жив процесс.
*
* Выключить: OPENCODE_CONTEXT_PANEL=0. База — $XDG_DATA_HOME/opencode/opencode.db или CTX_PANEL_DB.
* Подключение: файл в <config>/opencode/plugins/ — bin/opencode-web делает ссылку сам.
*/
import { appendFile, mkdir } from "node:fs/promises"
import { homedir } from "node:os"
import { dirname, join } from "node:path"
import { realpathSync } from "node:fs"
import { fileURLToPath, pathToFileURL } from "node:url"
const LOG_DIR = join(process.env.XDG_DATA_HOME || join(homedir(), ".local/share"), "opencode/log")
const LOG_FILE = join(LOG_DIR, "context-panel.log")
async function log(line: string) {
try {
await mkdir(LOG_DIR, { recursive: true })
await appendFile(LOG_FILE, `${new Date().toISOString()} ${line}\n`)
} catch {}
}
const KEY = Symbol.for("opencode-plugins.context-panel")
const state = globalThis as { [KEY]?: Promise<unknown> }
function start() {
// Файл подключён ссылкой из plugins/ — server.mjs ищем рядом с настоящим файлом
const here = dirname(realpathSync(fileURLToPath(import.meta.url)))
const opts = {
listen: process.env.CTX_PANEL_LISTEN ?? 4196,
target: process.env.CTX_PANEL_TARGET ?? 4197,
...(process.env.CTX_PANEL_DB ? { db: process.env.CTX_PANEL_DB } : {}),
}
return import(pathToFileURL(join(here, "server.mjs")).href)
.then((m) => m.startPanel(opts))
.then(
() => log(`listening ${opts.listen} → ${opts.target}`),
(e) => log(`не запустилась: ${e?.code ?? e?.message ?? e}`),
)
}
export default {
id: "context-panel",
setup: async () => {
if (process.env.OPENCODE_CONTEXT_PANEL === "0") return
state[KEY] ??= start()
await state[KEY]
},
}