diff --git a/README.md b/README.md index af51424..f5d9c6c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Paste a screenshot in your browser, your agent gets the URL instantly. | Agent | Directory | Status | |---|---|---| | [pi](https://github.com/mariozechner/pi) | [`pi/`](pi/) | ✅ | +| [OpenCode](https://github.com/anomalyco/opencode) | [`opencode/`](opencode/) | ✅ | ## How it works diff --git a/opencode/README.md b/opencode/README.md new file mode 100644 index 0000000..5b561f0 --- /dev/null +++ b/opencode/README.md @@ -0,0 +1,55 @@ +# glance.sh plugin for OpenCode + +[OpenCode](https://github.com/anomalyco/opencode) plugin that lets your agent request screenshots from you via [glance.sh](https://glance.sh). + +## What it does + +Maintains a **persistent background session** on glance.sh. Paste an image anytime — the agent receives it instantly. + +- **Background listener** — starts when OpenCode launches, reconnects automatically, refreshes sessions before they expire. +- **`glance` tool** — the LLM calls it when it needs to see something visual. Surfaces the session URL and waits for the next paste. +- **Multiple images** — paste as many images as you want during a session. + +## Install + +Symlink or copy `glance.ts` into your OpenCode plugins directory: + +```bash +# symlink (recommended — stays up to date with git pulls) +ln -s "$(pwd)/glance.ts" ~/.config/opencode/plugins/glance.ts + +# or per-project +ln -s "$(pwd)/glance.ts" .opencode/plugins/glance.ts +``` + +Restart OpenCode. The background session starts automatically. + +## How it works + +``` +opencode starts + └─▶ plugin creates session on glance.sh + └─▶ connects SSE (background, auto-reconnect) + +LLM calls glance tool + └─▶ surfaces session URL + └─▶ waits for image paste + +user pastes image at /s/ + └─▶ SSE emits "image" event + └─▶ tool returns image URL to LLM + +session expires (~10 min) + └─▶ plugin creates new session, reconnects +``` + +## Requirements + +- [OpenCode](https://github.com/anomalyco/opencode) v0.1+ +- Bun runtime (ships with OpenCode) + +## Configuration + +No API keys required — sessions are anonymous and ephemeral (10-minute TTL). + +The plugin connects to `https://glance.sh` by default. The SSE connection is held for ~5 minutes per cycle, with automatic reconnection. diff --git a/opencode/glance.test.ts b/opencode/glance.test.ts new file mode 100644 index 0000000..28ded77 --- /dev/null +++ b/opencode/glance.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, afterEach } from "vitest" + +// Mock @opencode-ai/plugin — `tool()` is a passthrough that returns the config +vi.mock("@opencode-ai/plugin", () => ({ + tool: (config: any) => config, +})) + +// Helper: dynamically import the plugin to get fresh module-level state +async function loadPlugin() { + vi.resetModules() + vi.doMock("@opencode-ai/plugin", () => ({ + tool: (config: any) => config, + })) + const mod = await import("./glance.js") + return mod.GlancePlugin +} + +function mockClient() { + return { client: {} } +} + +function mockContext(abort?: AbortSignal) { + return { + metadata: vi.fn(), + abort, + } +} + +/** + * Build a ReadableStream that emits the given SSE chunks, then hangs forever. + * The hang prevents the background loop from spinning in a tight reconnect cycle. + */ +function sseStream(events: string[]) { + const encoder = new TextEncoder() + let i = 0 + return new ReadableStream({ + pull(controller) { + if (i < events.length) { + controller.enqueue(encoder.encode(events[i])) + i++ + return + } + // Hang forever after all events are emitted + return new Promise(() => {}) + }, + }) +} + +function cleanupWaiters() { + for (const key of Object.keys(globalThis)) { + if (key.startsWith("__glance_waiter_")) { + delete (globalThis as any)[key] + } + } +} + +/** + * URL-aware fetch mock. Routes by URL so both the background loop and + * tool calls get correct responses regardless of call order. + */ +function routedFetch(opts: { + session?: { id: string; url: string } + sessionError?: number + sseEvents?: string[] +}) { + return vi.fn(async (url: string, _init?: any) => { + if (url === "https://glance.sh/api/session") { + if (opts.sessionError) { + return { ok: false, status: opts.sessionError } + } + return { + ok: true, + json: async () => opts.session ?? { id: "test-id", url: "/s/test-id" }, + } + } + + if (typeof url === "string" && url.includes("/events")) { + return { + ok: true, + body: sseStream(opts.sseEvents ?? []), + } + } + + return { ok: false, status: 404 } + }) +} + +describe("opencode glance plugin", () => { + afterEach(() => { + vi.restoreAllMocks() + cleanupWaiters() + }) + + describe("glance tool", () => { + it("creates a session and returns the URL", async () => { + vi.stubGlobal( + "fetch", + routedFetch({ session: { id: "abc123", url: "/s/abc123" } }), + ) + + const GlancePlugin = await loadPlugin() + const plugin = await GlancePlugin(mockClient()) + const result = await plugin.tool.glance.execute({}) + + expect(result).toContain("https://glance.sh/s/abc123") + expect(result).toContain("Session ready") + }) + + it("reuses an existing session on second call", async () => { + const fetchFn = routedFetch({ + session: { id: "abc123", url: "/s/abc123" }, + }) + vi.stubGlobal("fetch", fetchFn) + + const GlancePlugin = await loadPlugin() + const plugin = await GlancePlugin(mockClient()) + + // Let background loop create its session + await new Promise((r) => setTimeout(r, 20)) + + const r1 = await plugin.tool.glance.execute({}) + const r2 = await plugin.tool.glance.execute({}) + + expect(r1).toContain("/s/abc123") + expect(r2).toContain("/s/abc123") + }) + + it("returns error when session creation fails", async () => { + vi.stubGlobal("fetch", routedFetch({ sessionError: 500 })) + + const GlancePlugin = await loadPlugin() + const plugin = await GlancePlugin(mockClient()) + + // Wait for background loop to fail + await new Promise((r) => setTimeout(r, 50)) + + const result = await plugin.tool.glance.execute({}) + expect(result).toContain("Failed to create session") + }) + }) + + describe("glance_wait tool", () => { + it("returns error when no session exists", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("no network")), + ) + + const GlancePlugin = await loadPlugin() + const plugin = await GlancePlugin(mockClient()) + + // Give background loop time to fail + await new Promise((r) => setTimeout(r, 50)) + + const ctx = mockContext() + const result = await plugin.tool.glance_wait.execute({}, ctx) + expect(result).toContain("No active session") + }) + + it("returns image URL when image is dispatched", async () => { + const imagePayload = JSON.stringify({ + url: "https://glance.sh/tok123.png", + expiresAt: Date.now() + 60_000, + }) + + vi.stubGlobal( + "fetch", + routedFetch({ + session: { id: "sess1", url: "/s/sess1" }, + sseEvents: [ + `event: connected\ndata: {}\n\n`, + `event: image\ndata: ${imagePayload}\n\n`, + ], + }), + ) + + const GlancePlugin = await loadPlugin() + const plugin = await GlancePlugin(mockClient()) + + // Ensure session exists + await plugin.tool.glance.execute({}) + + const ctx = mockContext() + const result = await plugin.tool.glance_wait.execute({}, ctx) + + expect(result).toContain("https://glance.sh/tok123.png") + expect(ctx.metadata).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.stringContaining("Waiting for paste"), + }), + ) + }) + + it("returns timeout message when aborted", async () => { + vi.stubGlobal( + "fetch", + routedFetch({ + session: { id: "sess2", url: "/s/sess2" }, + }), + ) + + const GlancePlugin = await loadPlugin() + const plugin = await GlancePlugin(mockClient()) + await plugin.tool.glance.execute({}) + + const ac = new AbortController() + const ctx = mockContext(ac.signal) + + const waitPromise = plugin.tool.glance_wait.execute({}, ctx) + await new Promise((r) => setTimeout(r, 50)) + ac.abort() + + const result = await waitPromise + expect(result).toContain("timed out") + }) + }) +}) diff --git a/opencode/glance.ts b/opencode/glance.ts new file mode 100644 index 0000000..b3d59e9 --- /dev/null +++ b/opencode/glance.ts @@ -0,0 +1,286 @@ +/** + * glance.sh – Live image paste from browser to agent. + * + * OpenCode plugin that maintains a persistent glance.sh session in the + * background. Images pasted by the user are automatically surfaced to + * the LLM. + * + * Registers a `glance` tool — the LLM can call it to request a + * screenshot; it surfaces the existing session URL and waits for a + * paste. + * + * Install: symlink or copy this file into your opencode plugins + * directory, e.g. .opencode/plugins/glance.ts or + * ~/.config/opencode/plugins/glance.ts + */ + +import { type Plugin, tool } from "@opencode-ai/plugin" + +const BASE_URL = "https://glance.sh" + +/** How long to wait on a single SSE connection before reconnecting. */ +const SSE_TIMEOUT_MS = 305_000 + +/** Pause between reconnect attempts on error. */ +const RECONNECT_DELAY_MS = 3_000 + +/** How often to create a fresh session (sessions have 10-min TTL). */ +const SESSION_REFRESH_MS = 8 * 60 * 1000 + +interface SessionResponse { + id: string + url: string +} + +interface ImageEvent { + url: string + expiresAt: number +} + +// ── Persistent background session ────────────────────────────────── + +let currentSession: SessionResponse | null = null +let sessionCreatedAt = 0 +let abortController: AbortController | null = null +let running = false + +async function createSession(): Promise { + const res = await fetch(`${BASE_URL}/api/session`, { method: "POST" }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const session = (await res.json()) as SessionResponse + currentSession = session + sessionCreatedAt = Date.now() + return session +} + +function isSessionStale(): boolean { + return Date.now() - sessionCreatedAt > SESSION_REFRESH_MS +} + +/** + * Long-running background loop that: + * 1. Creates/refreshes a session as needed + * 2. Connects to SSE + * 3. Yields every image event to `onImage` + * 4. Reconnects on timeout/expiry/error + */ +async function backgroundLoop(onImage: (image: ImageEvent) => void) { + running = true + abortController = new AbortController() + const { signal } = abortController + + while (!signal.aborted) { + try { + if (!currentSession || isSessionStale()) { + await createSession() + } + + await listenForImages(currentSession!.id, signal, (image) => { + onImage(image) + }) + } catch (err: any) { + if (signal.aborted) break + await sleep(RECONNECT_DELAY_MS) + } + } + + running = false +} + +function stopBackground() { + try { + abortController?.abort() + } catch { + // AbortError is expected during teardown + } + abortController = null + currentSession = null +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)) +} + +// ── SSE listener (multi-image) ───────────────────────────────────── + +async function listenForImages( + sessionId: string, + signal: AbortSignal, + onImage: (image: ImageEvent) => void, +): Promise { + const res = await fetch(`${BASE_URL}/api/session/${sessionId}/events`, { + signal, + headers: { Accept: "text/event-stream" }, + }) + + if (!res.ok || !res.body) { + throw new Error(`SSE connect failed: HTTP ${res.status}`) + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + + const timeout = setTimeout(() => { + reader.cancel() + }, SSE_TIMEOUT_MS) + + const onAbort = () => { + clearTimeout(timeout) + reader.cancel().catch(() => {}) + } + signal.addEventListener("abort", onAbort, { once: true }) + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() ?? "" + + let eventType = "" + let dataLines: string[] = [] + + for (const line of lines) { + if (line.startsWith("event: ")) { + eventType = line.slice(7).trim() + } else if (line.startsWith("data: ")) { + dataLines.push(line.slice(6)) + } else if (line === "") { + if (eventType === "image" && dataLines.length > 0) { + const data = JSON.parse(dataLines.join("\n")) as ImageEvent + onImage(data) + } + if (eventType === "expired") { + currentSession = null + clearTimeout(timeout) + return + } + if (eventType === "timeout") { + clearTimeout(timeout) + return + } + eventType = "" + dataLines = [] + } + } + } + } finally { + clearTimeout(timeout) + signal.removeEventListener("abort", onAbort) + } +} + +// ── One-shot wait (for tool call) ────────────────────────────────── + +function waitForNextImage(signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (!currentSession) { + resolve(null) + return + } + + const timeout = setTimeout(() => resolve(null), SSE_TIMEOUT_MS) + + const key = `__glance_waiter_${Date.now()}` + ;(globalThis as any)[key] = (image: ImageEvent) => { + clearTimeout(timeout) + delete (globalThis as any)[key] + resolve(image) + } + + signal?.addEventListener("abort", () => { + clearTimeout(timeout) + delete (globalThis as any)[key] + resolve(null) + }) + }) +} + +function dispatchToWaiters(image: ImageEvent) { + for (const key of Object.keys(globalThis)) { + if (key.startsWith("__glance_waiter_")) { + const fn = (globalThis as any)[key] + if (typeof fn === "function") fn(image) + } + } +} + +// ── Plugin entry point ───────────────────────────────────────────── + +export const GlancePlugin: Plugin = async ({ client }) => { + function handleImage(image: ImageEvent) { + dispatchToWaiters(image) + } + + // Start background listener immediately + if (!running) { + backgroundLoop(handleImage).catch(() => {}) + } + + return { + event: async ({ event }) => { + // Clean up on session delete + if (event.type === "session.deleted") { + stopBackground() + } + }, + + tool: { + glance: tool({ + description: + "Open a live glance.sh session so the user can paste a screenshot from their browser. " + + "The tool returns a session URL for the user to open. After sharing the URL with the " + + "user, call glance_wait to block until they paste an image. " + + "Use this when you need to see the user's screen, a UI, an error dialog, or anything visual.", + args: {}, + async execute() { + // Ensure session exists + if (!currentSession) { + try { + await createSession() + if (!running) { + backgroundLoop(handleImage).catch(() => {}) + } + } catch (err: any) { + return `Failed to create session: ${err.message}` + } + } + + const sessionUrl = `${BASE_URL}${currentSession!.url}` + return `Session ready. Ask the user to paste an image at ${sessionUrl}` + }, + }), + + glance_wait: tool({ + description: + "Wait for the user to paste an image into the glance.sh session. " + + "Call glance first to get the session URL and share it with the user, " + + "then call this tool to block until an image arrives. Returns the image URL.", + args: {}, + async execute(_args, context) { + if (!currentSession) { + return "No active session. Call glance first to create one." + } + + const sessionUrl = `${BASE_URL}${currentSession!.url}` + + context.metadata({ + title: `Waiting for paste at ${sessionUrl}`, + metadata: { sessionUrl }, + }) + + const image = await waitForNextImage(context.abort) + + if (!image) { + return `Session timed out. Ask the user to paste an image at ${sessionUrl}` + } + + return `Screenshot: ${image.url}` + }, + }), + }, + } +}