diff --git a/README.md b/README.md index 5d993c6..be2f841 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,45 @@ -# opencode-dashboard +# OpenCode Dashboard -To install dependencies: +A local dashboard for monitoring OpenCode sessions and agent activity across projects. -```bash -bun install +## Installation + +OpenCode loads server and TUI plugins separately. Add the package to both global configuration files. + +`~/.config/opencode/opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-dashboard"] +} +``` + +`~/.config/opencode/tui.json`: + +```json +{ + "$schema": "https://opencode.ai/tui.json", + "plugin": ["opencode-dashboard"] +} ``` -To run: +Restart OpenCode after changing either configuration file. Run `/dashboard` in the TUI to start the local collector and open the dashboard in your default browser. + +The dashboard binds to `127.0.0.1:4747` and remains available while the OpenCode TUI that launched it is running. + +## Development ```bash -bun run index.ts +bun install --frozen-lockfile +bun run dev ``` -This project was created using `bun init` in bun v1.4.0. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. +Quality checks: + +```bash +bun run lint +bun run typecheck +bun test +bun run build +``` diff --git a/package.json b/package.json index 9bddb83..5fad7bf 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,26 @@ "name": "opencode-dashboard", "version": "0.0.1", "type": "module", + "main": "./dist/server/index.js", + "exports": { + ".": "./dist/server/index.js", + "./server": "./dist/server/index.js", + "./tui": "./dist/tui/index.js" + }, + "files": [ + "dist" + ], + "engines": { + "opencode": ">=1.18.30" + }, "scripts": { "dev": "bun --hot src/collector/index.ts", "test": "bun test", "lint": "eslint .", "typecheck": "tsc --noEmit", - "build": "bun build --target=bun --production --outdir=dist src/collector/index.ts src/plugin/index.ts", + "clean": "bun -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", + "prebuild": "bun run clean", + "build": "bun scripts/build.ts", "start": "bun dist/collector/index.js" }, "dependencies": { diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000..dec7c1d --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,21 @@ +import tailwind from "bun-plugin-tailwind"; + +const result = await Bun.build({ + entrypoints: [ + "src/collector/index.ts", + "src/server/index.ts", + "src/tui/index.ts", + ], + outdir: "dist", + target: "bun", + minify: true, + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, + plugins: [tailwind], +}); + +if (!result.success) { + for (const log of result.logs) console.error(log); + process.exit(1); +} diff --git a/src/collector/commands.ts b/src/collector/commands.ts index 3be20fa..0e261d8 100644 --- a/src/collector/commands.ts +++ b/src/collector/commands.ts @@ -1,43 +1,52 @@ -import type { SessionCommand, SessionCommandResult } from "../shared/protocol" +import type { SessionCommand, SessionCommandResult } from "../shared/protocol"; type PendingCommand = { - command: SessionCommand - resolve: (result: SessionCommandResult) => void - timer: ReturnType - claimed: boolean -} + command: SessionCommand; + resolve: (result: SessionCommandResult) => void; + timer: ReturnType; + claimed: boolean; +}; -const COMMAND_TIMEOUT_MS = 15_000 -const pending = new Map() +const COMMAND_TIMEOUT_MS = 15_000; +const pending = new Map(); export function enqueueSessionCommand( command: Omit, ): Promise { - const id = crypto.randomUUID() + const id = crypto.randomUUID(); return new Promise((resolve) => { const timer = setTimeout(() => { - pending.delete(id) - resolve({ commandId: id, ok: false, error: "OpenCode did not process the command in time" }) - }, COMMAND_TIMEOUT_MS) - pending.set(id, { command: { ...command, id }, resolve, timer, claimed: false }) - }) + pending.delete(id); + resolve({ + commandId: id, + ok: false, + error: "OpenCode did not process the command in time", + }); + }, COMMAND_TIMEOUT_MS); + pending.set(id, { + command: { ...command, id }, + resolve, + timer, + claimed: false, + }); + }); } export function claimSessionCommands(): SessionCommand[] { - const commands: SessionCommand[] = [] + const commands: SessionCommand[] = []; for (const item of pending.values()) { - if (item.claimed) continue - item.claimed = true - commands.push(item.command) + if (item.claimed) continue; + item.claimed = true; + commands.push(item.command); } - return commands + return commands; } export function completeSessionCommand(result: SessionCommandResult): boolean { - const item = pending.get(result.commandId) - if (!item) return false - clearTimeout(item.timer) - pending.delete(result.commandId) - item.resolve(result) - return true + const item = pending.get(result.commandId); + if (!item) return false; + clearTimeout(item.timer); + pending.delete(result.commandId); + item.resolve(result); + return true; } diff --git a/src/collector/server.ts b/src/collector/server.ts index d07799a..62eda2d 100644 --- a/src/collector/server.ts +++ b/src/collector/server.ts @@ -9,27 +9,32 @@ import { handleSnapshot, isReconciliationRequired, subscribeToEvents, -} from "./state" +} from "./state"; import { claimSessionCommands, completeSessionCommand, enqueueSessionCommand, -} from "./commands" -import { ingestRequestSchema, snapshotRequestSchema, heartbeatSchema } from "../shared/telemetry" -import { z } from "zod" +} from "./commands"; +import { + ingestRequestSchema, + snapshotRequestSchema, + heartbeatSchema, +} from "../shared/telemetry"; +import path from "node:path"; +import { z } from "zod"; const renameSessionSchema = z.object({ title: z.string().trim().min(1).max(200), -}) +}); const commandResultSchema = z.object({ commandId: z.string(), ok: z.boolean(), error: z.string().optional(), -}) +}); function isAllowedOrigin(request: Request) { - const origin = request.headers.get("origin") - return !origin || origin === new URL(request.url).origin + const origin = request.headers.get("origin"); + return !origin || origin === new URL(request.url).origin; } async function mutateOpenCodeSession( @@ -37,7 +42,7 @@ async function mutateOpenCodeSession( sessionId: string, title?: string, ) { - const target = getSessionActionTarget(sessionId) + const target = getSessionActionTarget(sessionId); if (!target) { return Response.json( { @@ -45,7 +50,7 @@ async function mutateOpenCodeSession( "No compatible OpenCode server is available. Restart one OpenCode session to load the latest dashboard plugin, then try again.", }, { status: 503 }, - ) + ); } const result = await enqueueSessionCommand({ @@ -53,77 +58,98 @@ async function mutateOpenCodeSession( sessionId, directory: target.directory, title, - }) + }); if (!result.ok) { - return Response.json({ error: result.error }, { status: 502 }) + return Response.json({ error: result.error }, { status: 502 }); } - if (method === "PATCH") applySessionRename(sessionId, title!) - else applySessionDelete(sessionId) - return Response.json({ ok: true }) + if (method === "PATCH") applySessionRename(sessionId, title!); + else applySessionDelete(sessionId); + return Response.json({ ok: true }); } -type CollectorConfig = { - hostname: string - port: number - dashboard: Bun.HTMLBundle -} +type CollectorConfigBase = { + hostname: string; + port: number; +}; + +type CollectorConfig = CollectorConfigBase & + ( + | { dashboard: Bun.HTMLBundle; dashboardRoot?: never } + | { dashboard?: never; dashboardRoot: string } + ); export function createServer(config: CollectorConfig) { + const staticRoot = config.dashboardRoot; + const dashboardRoute = staticRoot + ? { + GET() { + return new Response( + Bun.file(path.join(staticRoot, "web/index.html")), + ); + }, + } + : config.dashboard!; + const server = Bun.serve({ hostname: config.hostname, port: config.port, routes: { - "/": config.dashboard, + "/": dashboardRoute, "/health": { GET() { - return Response.json({ status: "ok", collectorInstanceId }) + return Response.json({ status: "ok", collectorInstanceId }); }, }, "/api/v1/ingest": { async POST(req) { - const body = await req.json() - const parsed = ingestRequestSchema.safeParse(body) + const body = await req.json(); + const parsed = ingestRequestSchema.safeParse(body); if (!parsed.success) { return Response.json( { error: "Invalid ingest request", details: parsed.error.issues }, { status: 400 }, - ) + ); } - handleIngest(parsed.data.messages) - return Response.json({ ok: true }) + handleIngest(parsed.data.messages); + return Response.json({ ok: true }); }, }, "/api/v1/snapshot": { async POST(req) { - const body = await req.json() - const parsed = snapshotRequestSchema.safeParse(body) + const body = await req.json(); + const parsed = snapshotRequestSchema.safeParse(body); if (!parsed.success) { return Response.json( - { error: "Invalid snapshot request", details: parsed.error.issues }, + { + error: "Invalid snapshot request", + details: parsed.error.issues, + }, { status: 400 }, - ) + ); } - handleSnapshot(parsed.data) - return Response.json({ ok: true }) + handleSnapshot(parsed.data); + return Response.json({ ok: true }); }, }, "/api/v1/heartbeat": { async POST(req) { - const body = await req.json() - const parsed = heartbeatSchema.safeParse(body) + const body = await req.json(); + const parsed = heartbeatSchema.safeParse(body); if (!parsed.success) { return Response.json( { error: "Invalid heartbeat", details: parsed.error.issues }, { status: 400 }, - ) + ); } - handleHeartbeat(parsed.data) - const reconcileRequired = isReconciliationRequired() - const commands = parsed.data.source.capabilities?.includes("session.write") + handleHeartbeat(parsed.data); + const reconcileRequired = isReconciliationRequired(); + const commands = parsed.data.source.capabilities?.includes( + "session.write", + ) ? claimSessionCommands() - : [] + : []; return Response.json({ ok: true, collectorInstanceId: reconcileRequired @@ -131,59 +157,89 @@ export function createServer(config: CollectorConfig) { : collectorInstanceId, reconcileRequired, commands, - }) + }); }, }, "/api/v1/commands/:id/result": { async POST(req) { - const parsed = commandResultSchema.safeParse(await req.json()) + const parsed = commandResultSchema.safeParse(await req.json()); if (!parsed.success || parsed.data.commandId !== req.params.id) { - return Response.json({ error: "Invalid command result" }, { status: 400 }) + return Response.json( + { error: "Invalid command result" }, + { status: 400 }, + ); } if (!completeSessionCommand(parsed.data)) { - return Response.json({ error: "Command is no longer pending" }, { status: 404 }) + return Response.json( + { error: "Command is no longer pending" }, + { status: 404 }, + ); } - return Response.json({ ok: true }) + return Response.json({ ok: true }); }, }, "/api/v1/state": { GET() { return Response.json(getState(), { headers: { "Cache-Control": "no-store" }, - }) + }); }, }, "/api/v1/events": { GET(req) { - return subscribeToEvents(req) + return subscribeToEvents(req); }, }, "/api/v1/sessions/:id": { async PATCH(req) { if (!isAllowedOrigin(req)) { - return Response.json({ error: "Origin not allowed" }, { status: 403 }) + return Response.json( + { error: "Origin not allowed" }, + { status: 403 }, + ); } - const parsed = renameSessionSchema.safeParse(await req.json()) + const parsed = renameSessionSchema.safeParse(await req.json()); if (!parsed.success) { - return Response.json({ error: "Title must be 1-200 characters" }, { status: 400 }) + return Response.json( + { error: "Title must be 1-200 characters" }, + { status: 400 }, + ); } - return mutateOpenCodeSession("PATCH", req.params.id, parsed.data.title) + return mutateOpenCodeSession( + "PATCH", + req.params.id, + parsed.data.title, + ); }, async DELETE(req) { if (!isAllowedOrigin(req)) { - return Response.json({ error: "Origin not allowed" }, { status: 403 }) + return Response.json( + { error: "Origin not allowed" }, + { status: 403 }, + ); } - return mutateOpenCodeSession("DELETE", req.params.id) + return mutateOpenCodeSession("DELETE", req.params.id); }, }, }, - development: { - hmr: true, - console: true, + async fetch(req) { + if (!staticRoot) return new Response("Not found", { status: 404 }); + + const pathname = decodeURIComponent(new URL(req.url).pathname); + const filePath = path.resolve(staticRoot, `.${pathname}`); + if (!filePath.startsWith(`${path.resolve(staticRoot)}${path.sep}`)) { + return new Response("Not found", { status: 404 }); + } + const file = Bun.file(filePath); + if (!(await file.exists())) + return new Response("Not found", { status: 404 }); + return new Response(file); }, - }) - console.log(`OpenCode Dashboard: http://${config.hostname}:${config.port}`) - return server + development: staticRoot ? false : { hmr: true, console: true }, + }); + + console.log(`OpenCode Dashboard: http://${config.hostname}:${config.port}`); + return server; } diff --git a/src/collector/state.ts b/src/collector/state.ts index 7bf8be1..985d8f4 100644 --- a/src/collector/state.ts +++ b/src/collector/state.ts @@ -7,71 +7,71 @@ import type { TelemetryMessage, SnapshotRequest, SessionStatus, -} from "../shared/protocol" +} from "../shared/protocol"; type StoredProcess = { - processInstanceId: string - openCodeVersion?: string - projectIds: Set - directories: Set - pluginInstanceIds: Set - serverUrl?: string - capabilities: Set - lastSeenAt: number -} + processInstanceId: string; + openCodeVersion?: string; + projectIds: Set; + directories: Set; + pluginInstanceIds: Set; + serverUrl?: string; + capabilities: Set; + lastSeenAt: number; +}; type StoredProject = { - id: string - name: string - directories: Set - sessionCount: number - lastActivityAt: number -} + id: string; + name: string; + directories: Set; + sessionCount: number; + lastActivityAt: number; +}; type StoredSession = { - id: string - parentId?: string - projectId: string - directory: string - processInstanceId?: string - title: string - agent?: string - model?: string - status: SessionStatus - updatedAt: number -} + id: string; + parentId?: string; + projectId: string; + directory: string; + processInstanceId?: string; + title: string; + agent?: string; + model?: string; + status: SessionStatus; + updatedAt: number; +}; type SseSubscriber = { - controller: ReadableStreamDefaultController - encoder: TextEncoder -} + controller: ReadableStreamDefaultController; + encoder: TextEncoder; +}; -const HEARTBEAT_INTERVAL_MS = 10_000 -const STALE_THRESHOLD_MS = 30_000 +const HEARTBEAT_INTERVAL_MS = 10_000; +const STALE_THRESHOLD_MS = 30_000; -export const collectorInstanceId = crypto.randomUUID() +export const collectorInstanceId = crypto.randomUUID(); -const processes = new Map() -const projects = new Map() -const sessions = new Map() -const subscribers = new Set() -let receivedGlobalSnapshot = false +const processes = new Map(); +const projects = new Map(); +const sessions = new Map(); +const subscribers = new Set(); +let receivedGlobalSnapshot = false; export function isReconciliationRequired() { - return !receivedGlobalSnapshot + return !receivedGlobalSnapshot; } export function handleHeartbeat(message: Heartbeat) { - const { source } = message - const existing = processes.get(source.processInstanceId) + const { source } = message; + const existing = processes.get(source.processInstanceId); if (existing) { - existing.lastSeenAt = Date.now() - existing.pluginInstanceIds.add(source.pluginInstanceId) - existing.openCodeVersion = source.openCodeVersion - existing.serverUrl = source.serverUrl - existing.capabilities = new Set(source.capabilities ?? []) - existing.projectIds.add(source.projectId) - existing.directories.add(source.directory) + existing.lastSeenAt = Date.now(); + existing.pluginInstanceIds.add(source.pluginInstanceId); + existing.openCodeVersion = source.openCodeVersion; + existing.serverUrl = source.serverUrl; + existing.capabilities = new Set(source.capabilities ?? []); + existing.projectIds.add(source.projectId); + existing.directories.add(source.directory); } else { processes.set(source.processInstanceId, { processInstanceId: source.processInstanceId, @@ -82,12 +82,12 @@ export function handleHeartbeat(message: Heartbeat) { serverUrl: source.serverUrl, capabilities: new Set(source.capabilities ?? []), lastSeenAt: Date.now(), - }) + }); } - const project = projects.get(source.projectId) + const project = projects.get(source.projectId); if (project) { - project.directories.add(source.directory) + project.directories.add(source.directory); } else { projects.set(source.projectId, { id: source.projectId, @@ -95,48 +95,59 @@ export function handleHeartbeat(message: Heartbeat) { directories: new Set([source.directory]), sessionCount: 0, lastActivityAt: Date.now(), - }) + }); } - broadcastEvent({ type: "heartbeat", processInstanceId: source.processInstanceId }) + broadcastEvent({ + type: "heartbeat", + processInstanceId: source.processInstanceId, + }); } export function handleIngest(messages: TelemetryMessage[]) { for (const message of messages) { switch (message.type) { case "heartbeat": - handleHeartbeat(message) - break + handleHeartbeat(message); + break; case "session.status.changed": - handleSessionStatusChanged(message) - break + handleSessionStatusChanged(message); + break; case "session.deleted": - sessions.delete(message.sessionId) - broadcastEvent({ type: "session.deleted", sessionId: message.sessionId }) - break + sessions.delete(message.sessionId); + broadcastEvent({ + type: "session.deleted", + sessionId: message.sessionId, + }); + break; } } } export function handleSnapshot(request: SnapshotRequest) { - if (request.scope === "global") receivedGlobalSnapshot = true - handleHeartbeat({ - protocolVersion: 1, - type: "heartbeat", - sentAt: Date.now(), - source: request.source, - }) + if (request.scope === "global") receivedGlobalSnapshot = true; + if (request.source.kind !== "tui") { + handleHeartbeat({ + protocolVersion: 1, + type: "heartbeat", + sentAt: Date.now(), + source: request.source, + }); + } for (const session of request.sessions) { - const existing = sessions.get(session.id) - const projectId = session.projectId ?? request.source.projectId - const projectName = session.projectName ?? projectId - const directory = session.directory ?? request.source.directory + const existing = sessions.get(session.id); + const projectId = session.projectId ?? request.source.projectId; + const projectName = session.projectName ?? projectId; + const directory = session.directory ?? request.source.directory; - const project = projects.get(projectId) + const project = projects.get(projectId); if (project) { - project.directories.add(directory) - project.lastActivityAt = Math.max(project.lastActivityAt, session.updatedAt) + project.directories.add(directory); + project.lastActivityAt = Math.max( + project.lastActivityAt, + session.updatedAt, + ); } else { projects.set(projectId, { id: projectId, @@ -144,7 +155,7 @@ export function handleSnapshot(request: SnapshotRequest) { directories: new Set([directory]), sessionCount: 0, lastActivityAt: session.updatedAt, - }) + }); } if (!existing || session.updatedAt >= existing.updatedAt) { @@ -165,19 +176,22 @@ export function handleSnapshot(request: SnapshotRequest) { ? existing.status : session.status, updatedAt: session.updatedAt, - }) + }); } } - broadcastEvent({ type: "snapshot", processInstanceId: request.source.processInstanceId }) + broadcastEvent({ + type: "snapshot", + processInstanceId: request.source.processInstanceId, + }); } function handleSessionStatusChanged(message: SessionStatusChanged) { - const { session, source } = message - const existing = sessions.get(session.id) + const { session, source } = message; + const existing = sessions.get(session.id); if (existing && session.updatedAt < existing.updatedAt) { - return + return; } sessions.set(session.id, { @@ -191,80 +205,80 @@ function handleSessionStatusChanged(message: SessionStatusChanged) { model: session.model, status: session.status, updatedAt: session.updatedAt, - }) + }); - const project = projects.get(source.projectId) + const project = projects.get(source.projectId); if (project) { - project.lastActivityAt = Date.now() + project.lastActivityAt = Date.now(); } - broadcastEvent({ type: "session.updated", sessionId: session.id }) + broadcastEvent({ type: "session.updated", sessionId: session.id }); } export function getSessionActionTarget(sessionId: string) { - const session = sessions.get(sessionId) - if (!session) return + const session = sessions.get(sessionId); + if (!session) return; - const now = Date.now() + const now = Date.now(); const executorAvailable = [...processes.values()].some( (process) => process.capabilities.has("session.write") && now - process.lastSeenAt <= STALE_THRESHOLD_MS, - ) - if (!executorAvailable) return - return { directory: session.directory } + ); + if (!executorAvailable) return; + return { directory: session.directory }; } export function applySessionRename(sessionId: string, title: string) { - const session = sessions.get(sessionId) - if (!session) return false + const session = sessions.get(sessionId); + if (!session) return false; - session.title = title - session.updatedAt = Date.now() - broadcastEvent({ type: "session.updated", sessionId }) - return true + session.title = title; + session.updatedAt = Date.now(); + broadcastEvent({ type: "session.updated", sessionId }); + return true; } export function applySessionDelete(sessionId: string) { - if (!sessions.has(sessionId)) return false + if (!sessions.has(sessionId)) return false; - const pending = [sessionId] - const deleted = new Set() + const pending = [sessionId]; + const deleted = new Set(); while (pending.length > 0) { - const current = pending.pop()! - if (deleted.has(current)) continue - deleted.add(current) + const current = pending.pop()!; + if (deleted.has(current)) continue; + deleted.add(current); for (const session of sessions.values()) { - if (session.parentId === current) pending.push(session.id) + if (session.parentId === current) pending.push(session.id); } } - for (const id of deleted) sessions.delete(id) - broadcastEvent({ type: "session.deleted", sessionId }) - return true + for (const id of deleted) sessions.delete(id); + broadcastEvent({ type: "session.deleted", sessionId }); + return true; } export function getState() { - markStaleProcesses() + markStaleProcesses(); - const resultProcesses: DashboardProcess[] = [] + const resultProcesses: DashboardProcess[] = []; for (const [, p] of processes) { - const stale = Date.now() - p.lastSeenAt > STALE_THRESHOLD_MS + const stale = Date.now() - p.lastSeenAt > STALE_THRESHOLD_MS; resultProcesses.push({ processInstanceId: p.processInstanceId, openCodeVersion: p.openCodeVersion, projects: [...p.projectIds], lastSeenAt: p.lastSeenAt, stale, - }) + }); } - const resultProjects: DashboardProject[] = [] + const resultProjects: DashboardProject[] = []; for (const [, p] of projects) { const projectSessionCount = [...sessions.values()].filter( (s) => s.projectId === p.id, - ).length - if (projectSessionCount === 0) continue + ).length; + if (projectSessionCount === 0) continue; resultProjects.push({ id: p.id, @@ -272,18 +286,18 @@ export function getState() { directories: [...p.directories], sessionCount: projectSessionCount, lastActivityAt: p.lastActivityAt, - }) + }); } - const resultSessions: DashboardSession[] = [] + const resultSessions: DashboardSession[] = []; for (const [, s] of sessions) { const process = s.processInstanceId ? processes.get(s.processInstanceId) - : undefined + : undefined; const processStale = process ? Date.now() - process.lastSeenAt > STALE_THRESHOLD_MS - : false - const wasActive = ["running", "waiting", "retrying"].includes(s.status) + : false; + const wasActive = ["running", "waiting", "retrying"].includes(s.status); resultSessions.push({ id: s.id, parentId: s.parentId, @@ -294,7 +308,7 @@ export function getState() { model: s.model, status: processStale && wasActive ? "stale" : s.status, updatedAt: s.updatedAt, - }) + }); } return { @@ -302,55 +316,57 @@ export function getState() { processes: resultProcesses, projects: resultProjects, sessions: resultSessions, - } + }; } function markStaleProcesses() {} function broadcastEvent(event: Record) { - const data = `data: ${JSON.stringify(event)}\n\n` + const data = `data: ${JSON.stringify(event)}\n\n`; for (const sub of subscribers) { try { - sub.controller.enqueue(sub.encoder.encode(data)) + sub.controller.enqueue(sub.encoder.encode(data)); } catch { - subscribers.delete(sub) + subscribers.delete(sub); } } } export function subscribeToEvents(req: Request): Response { - const encoder = new TextEncoder() + const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { - const sub: SseSubscriber = { controller, encoder } - subscribers.add(sub) + const sub: SseSubscriber = { controller, encoder }; + subscribers.add(sub); controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: "connected" })}\n\n`), - ) + ); const interval = setInterval(() => { try { controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: "heartbeat" })}\n\n`), - ) + encoder.encode( + `data: ${JSON.stringify({ type: "heartbeat" })}\n\n`, + ), + ); } catch { - subscribers.delete(sub) - clearInterval(interval) + subscribers.delete(sub); + clearInterval(interval); } - }, HEARTBEAT_INTERVAL_MS) + }, HEARTBEAT_INTERVAL_MS); req.signal.addEventListener("abort", () => { - subscribers.delete(sub) - clearInterval(interval) + subscribers.delete(sub); + clearInterval(interval); try { - controller.close() + controller.close(); } catch { // Already closed } - }) + }); }, - }) + }); return new Response(stream, { headers: { @@ -358,5 +374,5 @@ export function subscribeToEvents(req: Request): Response { "Cache-Control": "no-cache", Connection: "keep-alive", }, - }) + }); } diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..e30e90b --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,7 @@ +import type { PluginModule } from "@opencode-ai/plugin" +import server from "../plugin" + +export default { + id: "opencode-dashboard", + server, +} satisfies PluginModule diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 5049353..7cfcbe8 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -3,6 +3,7 @@ export const PROTOCOL_VERSION = 1 export const DEFAULT_COLLECTOR_PORT = 4747 export type SourceIdentity = { + kind?: "server" | "tui" processInstanceId: string pluginInstanceId: string projectId: string diff --git a/src/shared/telemetry.ts b/src/shared/telemetry.ts index c281aea..2103b79 100644 --- a/src/shared/telemetry.ts +++ b/src/shared/telemetry.ts @@ -2,6 +2,7 @@ import { z } from "zod" import { PROTOCOL_VERSION } from "./protocol" const sourceIdentitySchema = z.object({ + kind: z.enum(["server", "tui"]).optional(), processInstanceId: z.string(), pluginInstanceId: z.string(), projectId: z.string(), diff --git a/src/tui/index.ts b/src/tui/index.ts new file mode 100644 index 0000000..282ad84 --- /dev/null +++ b/src/tui/index.ts @@ -0,0 +1,131 @@ +import type { TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"; +import path from "node:path"; +import { createServer } from "../collector/server"; +import { sanitizeSession } from "../plugin/normalize"; +import { TelemetrySender } from "../plugin/sender"; +import { + DEFAULT_COLLECTOR_PORT, + type SnapshotSession, + type SourceIdentity, +} from "../shared/protocol"; + +const hostname = "127.0.0.1"; +const dashboardUrl = `http://${hostname}:${DEFAULT_COLLECTOR_PORT}`; +const dashboardRoot = import.meta.dir.includes(`${path.sep}src${path.sep}`) + ? path.resolve(import.meta.dir, "../../dist") + : path.resolve(import.meta.dir, ".."); +let collector: ReturnType | undefined; +const sender = new TelemetrySender({ collectorUrl: dashboardUrl }); + +async function collectorIsAvailable() { + try { + const response = await fetch(`${dashboardUrl}/health`, { + signal: AbortSignal.timeout(500), + }); + return response.ok; + } catch { + return false; + } +} + +async function ensureCollector() { + if (await collectorIsAvailable()) return; + + try { + collector = createServer({ + hostname, + port: DEFAULT_COLLECTOR_PORT, + dashboardRoot, + }); + } catch (error) { + if (!(await collectorIsAvailable())) throw error; + } +} + +async function sendGlobalSnapshot(api: TuiPluginApi, source: SourceIdentity) { + const sessions: SnapshotSession[] = []; + let cursor: number | undefined; + + do { + const result = await api.client.experimental.session.list({ + limit: 100, + cursor, + }); + if (result.error) + throw new Error("Could not load persisted OpenCode sessions"); + for (const info of result.data ?? []) { + const session = sanitizeSession(info); + if (session) sessions.push({ ...session, statusKnown: false }); + } + + const nextCursor = result.response.headers.get("x-next-cursor"); + cursor = nextCursor ? Number(nextCursor) : undefined; + } while (cursor !== undefined && Number.isFinite(cursor)); + + await sender.sendSnapshot({ source, scope: "global", sessions }); +} + +function openDashboard() { + const command = + process.platform === "darwin" + ? ["open", dashboardUrl] + : process.platform === "win32" + ? ["cmd", "/c", "start", "", dashboardUrl] + : ["xdg-open", dashboardUrl]; + const subprocess = Bun.spawn(command, { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + subprocess.unref(); +} + +export default { + id: "opencode-dashboard", + tui: async (api) => { + const directory = api.state.path.directory; + const source: SourceIdentity = { + kind: "tui", + processInstanceId: crypto.randomUUID(), + pluginInstanceId: crypto.randomUUID(), + projectId: `tui:${directory}`, + projectName: path.basename(directory) || "opencode", + directory, + }; + + api.keymap.registerLayer({ + commands: [ + { + name: "dashboard.open", + namespace: "palette", + slashName: "dashboard", + title: "Open dashboard", + category: "Dashboard", + async run() { + try { + await ensureCollector(); + await sendGlobalSnapshot(api, source); + openDashboard(); + api.ui.toast({ + variant: "success", + title: "Dashboard", + message: `Opened ${dashboardUrl}`, + }); + } catch (error) { + api.ui.toast({ + variant: "error", + title: "Dashboard", + message: error instanceof Error ? error.message : String(error), + }); + } + }, + }, + ], + }); + + api.lifecycle.onDispose(() => { + collector?.stop(); + collector = undefined; + }); + }, +} satisfies TuiPluginModule; diff --git a/tests/state.test.ts b/tests/state.test.ts index 47f3175..08dff9c 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -109,6 +109,41 @@ test("historical metadata does not overwrite a known live status", () => { ) }) +test("accepts TUI global snapshots without creating a process", () => { + const tuiSource: SourceIdentity = { + ...source, + kind: "tui", + processInstanceId: "tui-snapshot-process", + pluginInstanceId: "tui-snapshot-plugin", + } + handleSnapshot({ + source: tuiSource, + scope: "global", + sessions: [ + { + id: "inactive-global-session", + projectId: "inactive-project", + projectName: "inactive", + directory: "/projects/inactive", + title: "Inactive global session", + status: "idle", + statusKnown: false, + updatedAt: 3500, + }, + ], + }) + + const state = getState() + expect(state.sessions).toContainEqual( + expect.objectContaining({ id: "inactive-global-session" }), + ) + expect( + state.processes.some( + (process) => process.processInstanceId === "tui-snapshot-process", + ), + ).toBeFalse() +}) + test("routes session actions through an active OpenCode server", () => { const actionSource: SourceIdentity = { ...source,