diff --git a/packages/opencode-config/README.md b/packages/opencode-config/README.md new file mode 100644 index 00000000..28e60bd2 --- /dev/null +++ b/packages/opencode-config/README.md @@ -0,0 +1,32 @@ +# opencode-config + +## TLDR +Template config + plugins injected into every OpenCode instance that CodeNomad launches. It provides a CodeNomad bridge plugin for local event exchange between the CLI server and opencode. + +## What it is +A packaged config directory that CodeNomad copies into `~/.config/codenomad/opencode-config` for production builds or uses directly in dev. OpenCode autoloads any `plugin/*.ts` or `plugin/*.js` from this directory. + +## How it works +- CodeNomad sets `OPENCODE_CONFIG_DIR` when spawning each opencode instance (`packages/server/src/workspaces/manager.ts`). +- This template is synced from `packages/opencode-config` (`packages/server/src/opencode-config.ts`, `packages/server/scripts/copy-opencode-config.mjs`). +- OpenCode autoloads plugins from `plugin/` (`packages/opencode-config/plugin/codenomad.ts`). +- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-config/plugin/lib/client.ts`). +- The server exposes the plugin routes and maps events into the UI SSE pipeline (`packages/server/src/server/routes/plugin.ts`, `packages/server/src/plugins/handlers.ts`). + +## Expectations +- Local-only bridge (no auth/token yet). +- Plugin must fail startup if it cannot connect after 3 retries. +- Keep plugin entrypoints thin; put shared logic under `plugin/lib/` to avoid autoloaded helpers. +- Keep event shapes small and explicit; use `type` + `properties` only. + +## Ideas +- Add feature modules under `plugin/lib/features/` (tool lifecycle, permission prompts, custom commands). +- Expand `/workspaces/:id/plugin/*` with dedicated endpoints as needed. +- Promote stable event shapes and version tags once the protocol settles. + +## Pointers +- Plugin entry: `packages/opencode-config/plugin/codenomad.ts` +- Plugin client: `packages/opencode-config/plugin/lib/client.ts` +- Plugin server routes: `packages/server/src/server/routes/plugin.ts` +- Plugin event handling: `packages/server/src/plugins/handlers.ts` +- Workspace env injection: `packages/server/src/workspaces/manager.ts` diff --git a/packages/opencode-config/plugin/codenomad.ts b/packages/opencode-config/plugin/codenomad.ts new file mode 100644 index 00000000..166cb256 --- /dev/null +++ b/packages/opencode-config/plugin/codenomad.ts @@ -0,0 +1,35 @@ +import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client" + +export async function CodeNomadPlugin() { + const config = getCodeNomadConfig() + const client = createCodeNomadClient(config) + + await client.startEvents((event) => { + if (event.type === "codenomad.ping") { + void client.postEvent({ + type: "codenomad.pong", + properties: { + ts: Date.now(), + pingTs: (event.properties as any)?.ts, + }, + }).catch(() => {}) + } + }) + + return { + async event(input: { event: any }) { + const opencodeEvent = input?.event + if (!opencodeEvent || typeof opencodeEvent !== "object") return + + if (opencodeEvent.type === "session.idle") { + const sessionID = (opencodeEvent as any).properties?.sessionID + void client.postEvent({ + type: "opencode.session.idle", + properties: { + sessionID, + }, + }).catch(() => {}) + } + }, + } +} diff --git a/packages/opencode-config/plugin/lib/client.ts b/packages/opencode-config/plugin/lib/client.ts new file mode 100644 index 00000000..b6727de4 --- /dev/null +++ b/packages/opencode-config/plugin/lib/client.ts @@ -0,0 +1,165 @@ +export type PluginEvent = { + type: string + properties?: Record +} + +export type CodeNomadConfig = { + instanceId: string + baseUrl: string +} + +export function getCodeNomadConfig(): CodeNomadConfig { + return { + instanceId: requireEnv("CODENOMAD_INSTANCE_ID"), + baseUrl: requireEnv("CODENOMAD_BASE_URL"), + } +} + +export function createCodeNomadClient(config: CodeNomadConfig) { + return { + postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event), + startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent), + } +} + +function requireEnv(key: string): string { + const value = process.env[key] + if (!value || !value.trim()) { + throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`) + } + return value +} + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) { + const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event` + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(event), + }) + + if (!response.ok) { + throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`) + } +} + +async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) { + const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events` + + // Fail plugin startup if we cannot establish the initial connection. + const initialBody = await connectWithRetries(url, 3) + + // After startup, keep reconnecting; throw after 3 consecutive failures. + void consumeWithReconnect(url, onEvent, initialBody) +} + +async function connectWithRetries(url: string, maxAttempts: number) { + let lastError: unknown + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const response = await fetch(url, { headers: { Accept: "text/event-stream" } }) + if (!response.ok || !response.body) { + throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`) + } + return response.body + } catch (error) { + lastError = error + await delay(500 * attempt) + } + } + + const reason = lastError instanceof Error ? lastError.message : String(lastError) + throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`) +} + +async function consumeWithReconnect( + url: string, + onEvent: (event: PluginEvent) => void, + initialBody: ReadableStream, +) { + let consecutiveFailures = 0 + let body: ReadableStream | null = initialBody + + while (true) { + try { + if (!body) { + body = await connectWithRetries(url, 3) + } + + await consumeSseBody(body, onEvent) + body = null + consecutiveFailures = 0 + } catch (error) { + body = null + consecutiveFailures += 1 + if (consecutiveFailures >= 3) { + const reason = error instanceof Error ? error.message : String(error) + throw new Error(`[CodeNomadPlugin] Plugin event stream failed after 3 retries: ${reason}`) + } + await delay(500 * consecutiveFailures) + } + } +} + +async function consumeSseBody(body: ReadableStream, onEvent: (event: PluginEvent) => void) { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffer = "" + + while (true) { + const { done, value } = await reader.read() + if (done || !value) { + break + } + + buffer += decoder.decode(value, { stream: true }) + + let separatorIndex = buffer.indexOf("\n\n") + while (separatorIndex >= 0) { + const chunk = buffer.slice(0, separatorIndex) + buffer = buffer.slice(separatorIndex + 2) + separatorIndex = buffer.indexOf("\n\n") + + const event = parseSseChunk(chunk) + if (event) { + onEvent(event) + } + } + } + + throw new Error("SSE stream ended") +} + +function parseSseChunk(chunk: string): PluginEvent | null { + const lines = chunk.split(/\r?\n/) + const dataLines: string[] = [] + + for (const line of lines) { + if (line.startsWith(":")) continue + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trimStart()) + } + } + + if (dataLines.length === 0) return null + + const payload = dataLines.join("\n").trim() + if (!payload) return null + + try { + const parsed = JSON.parse(payload) + if (!parsed || typeof parsed !== "object" || typeof (parsed as any).type !== "string") { + return null + } + return parsed as PluginEvent + } catch { + return null + } +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8144faae..6b548402 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -122,22 +122,6 @@ async function main() { logger.info({ options }, "Starting CodeNomad CLI server") const eventBus = new EventBus(eventLogger) - const configStore = new ConfigStore(options.configPath, eventBus, configLogger) - const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger) - const workspaceManager = new WorkspaceManager({ - rootDir: options.rootDir, - configStore, - binaryRegistry, - eventBus, - logger: workspaceLogger, - }) - const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) - const instanceStore = new InstanceStore() - const instanceEventBridge = new InstanceEventBridge({ - workspaceManager, - eventBus, - logger: logger.child({ component: "instance-events" }), - }) const serverMeta: ServerMeta = { httpBaseUrl: `http://${options.host}:${options.port}`, @@ -150,6 +134,24 @@ async function main() { addresses: [], } + const configStore = new ConfigStore(options.configPath, eventBus, configLogger) + const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger) + const workspaceManager = new WorkspaceManager({ + rootDir: options.rootDir, + configStore, + binaryRegistry, + eventBus, + logger: workspaceLogger, + getServerBaseUrl: () => serverMeta.httpBaseUrl, + }) + const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) + const instanceStore = new InstanceStore() + const instanceEventBridge = new InstanceEventBridge({ + workspaceManager, + eventBus, + logger: logger.child({ component: "instance-events" }), + }) + const releaseMonitor = startReleaseMonitor({ currentVersion: packageJson.version, logger: logger.child({ component: "release-monitor" }), diff --git a/packages/server/src/plugins/channel.ts b/packages/server/src/plugins/channel.ts new file mode 100644 index 00000000..c4d645ea --- /dev/null +++ b/packages/server/src/plugins/channel.ts @@ -0,0 +1,55 @@ +import type { FastifyReply } from "fastify" +import type { Logger } from "../logger" + +export interface PluginOutboundEvent { + type: string + properties?: Record +} + +interface ClientConnection { + reply: FastifyReply + workspaceId: string +} + +export class PluginChannelManager { + private readonly clients = new Set() + + constructor(private readonly logger: Logger) {} + + register(workspaceId: string, reply: FastifyReply) { + const connection: ClientConnection = { workspaceId, reply } + this.clients.add(connection) + this.logger.debug({ workspaceId }, "Plugin SSE client connected") + + let closed = false + const close = () => { + if (closed) return + closed = true + this.clients.delete(connection) + this.logger.debug({ workspaceId }, "Plugin SSE client disconnected") + } + + return { close } + } + + send(workspaceId: string, event: PluginOutboundEvent) { + for (const client of this.clients) { + if (client.workspaceId !== workspaceId) continue + this.write(client.reply, event) + } + } + + broadcast(event: PluginOutboundEvent) { + for (const client of this.clients) { + this.write(client.reply, event) + } + } + + private write(reply: FastifyReply, event: PluginOutboundEvent) { + try { + reply.raw.write(`data: ${JSON.stringify(event)}\n\n`) + } catch (error) { + this.logger.warn({ err: error }, "Failed to write plugin SSE event") + } + } +} diff --git a/packages/server/src/plugins/handlers.ts b/packages/server/src/plugins/handlers.ts new file mode 100644 index 00000000..b12432b5 --- /dev/null +++ b/packages/server/src/plugins/handlers.ts @@ -0,0 +1,62 @@ +import type { EventBus } from "../events/bus" +import type { WorkspaceManager } from "../workspaces/manager" +import type { Logger } from "../logger" +import type { PluginOutboundEvent } from "./channel" + +export interface PluginInboundEvent { + type: string + properties?: Record +} + +interface HandlerDeps { + workspaceManager: WorkspaceManager + eventBus: EventBus + logger: Logger +} + +export function handlePluginEvent(workspaceId: string, event: PluginInboundEvent, deps: HandlerDeps) { + switch (event.type) { + case "codenomad.pong": + deps.logger.debug({ workspaceId, properties: event.properties }, "Plugin pong received") + return + + case "opencode.session.idle": { + const workspace = deps.workspaceManager.get(workspaceId) + const title = workspace?.name || workspace?.path?.split(/[\\/]/).filter(Boolean).pop() || "CodeNomad" + + const sessionId = readString(event.properties?.sessionID) + const message = sessionId ? `Session ${sessionId} is idle` : "Session is idle" + + deps.eventBus.publish({ + type: "instance.event", + instanceId: workspaceId, + event: { + type: "tui.toast.show", + properties: { + title, + message, + variant: "info", + duration: 8000, + }, + }, + }) + return + } + + default: + deps.logger.debug({ workspaceId, eventType: event.type }, "Unhandled plugin event") + } +} + +export function buildPingEvent(): PluginOutboundEvent { + return { + type: "codenomad.ping", + properties: { + ts: Date.now(), + }, + } +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index eb57fb0f..0af7b408 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -18,6 +18,7 @@ import { registerFilesystemRoutes } from "./routes/filesystem" import { registerMetaRoutes } from "./routes/meta" import { registerEventRoutes } from "./routes/events" import { registerStorageRoutes } from "./routes/storage" +import { registerPluginRoutes } from "./routes/plugin" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" @@ -110,6 +111,7 @@ export function createHttpServer(deps: HttpServerDeps) { eventBus: deps.eventBus, workspaceManager: deps.workspaceManager, }) + registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) diff --git a/packages/server/src/server/routes/plugin.ts b/packages/server/src/server/routes/plugin.ts new file mode 100644 index 00000000..374ce545 --- /dev/null +++ b/packages/server/src/server/routes/plugin.ts @@ -0,0 +1,75 @@ +import { FastifyInstance } from "fastify" +import { z } from "zod" +import type { WorkspaceManager } from "../../workspaces/manager" +import type { EventBus } from "../../events/bus" +import type { Logger } from "../../logger" +import { PluginChannelManager } from "../../plugins/channel" +import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers" + +interface RouteDeps { + workspaceManager: WorkspaceManager + eventBus: EventBus + logger: Logger +} + +const PluginEventSchema = z.object({ + type: z.string().min(1), + properties: z.record(z.unknown()).optional(), +}) + +export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) { + const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" })) + + app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => { + const workspace = deps.workspaceManager.get(request.params.id) + if (!workspace) { + reply.code(404).send({ error: "Workspace not found" }) + return + } + + reply.raw.setHeader("Content-Type", "text/event-stream") + reply.raw.setHeader("Cache-Control", "no-cache") + reply.raw.setHeader("Connection", "keep-alive") + reply.raw.flushHeaders?.() + reply.hijack() + + const registration = channel.register(request.params.id, reply) + + const heartbeat = setInterval(() => { + channel.send(request.params.id, buildPingEvent()) + }, 15000) + + const close = () => { + clearInterval(heartbeat) + registration.close() + reply.raw.end?.() + } + + request.raw.on("close", close) + request.raw.on("error", close) + }) + + const handleWildcard = async (request: any, reply: any) => { + const workspaceId = request.params.id as string + const workspace = deps.workspaceManager.get(workspaceId) + if (!workspace) { + reply.code(404).send({ error: "Workspace not found" }) + return + } + + const suffix = (request.params["*"] as string | undefined) ?? "" + const normalized = suffix.replace(/^\/+/, "") + + if (normalized === "event" && request.method === "POST") { + const parsed = PluginEventSchema.parse(request.body ?? {}) + handlePluginEvent(workspaceId, parsed, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: deps.logger }) + reply.code(204).send() + return + } + + reply.code(404).send({ error: "Unknown plugin endpoint" }) + } + + app.all("/workspaces/:id/plugin/*", handleWildcard) + app.all("/workspaces/:id/plugin", handleWildcard) +} diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 02af0223..4d93f73a 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -20,6 +20,7 @@ interface WorkspaceManagerOptions { binaryRegistry: BinaryRegistry eventBus: EventBus logger: Logger + getServerBaseUrl: () => string } interface WorkspaceRecord extends WorkspaceDescriptor {} @@ -108,6 +109,8 @@ export class WorkspaceManager { const environment = { ...userEnvironment, OPENCODE_CONFIG_DIR: this.opencodeConfigDir, + CODENOMAD_INSTANCE_ID: id, + CODENOMAD_BASE_URL: this.options.getServerBaseUrl(), } try {