Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
55 changes: 55 additions & 0 deletions opencode/README.md
Original file line number Diff line number Diff line change
@@ -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/<id>
└─▶ 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.
217 changes: 217 additions & 0 deletions opencode/glance.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
})
Loading