Skip to content

Commit 8639722

Browse files
authored
Add Opencode plugin
Add OpenCode plugin
2 parents 2edd0cc + 9cee564 commit 8639722

File tree

4 files changed

+559
-0
lines changed

4 files changed

+559
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Paste a screenshot in your browser, your agent gets the URL instantly.
99
| Agent | Directory | Status |
1010
|---|---|---|
1111
| [pi](https://github.com/mariozechner/pi) | [`pi/`](pi/) ||
12+
| [OpenCode](https://github.com/anomalyco/opencode) | [`opencode/`](opencode/) ||
1213

1314
## How it works
1415

opencode/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# glance.sh plugin for OpenCode
2+
3+
[OpenCode](https://github.com/anomalyco/opencode) plugin that lets your agent request screenshots from you via [glance.sh](https://glance.sh).
4+
5+
## What it does
6+
7+
Maintains a **persistent background session** on glance.sh. Paste an image anytime — the agent receives it instantly.
8+
9+
- **Background listener** — starts when OpenCode launches, reconnects automatically, refreshes sessions before they expire.
10+
- **`glance` tool** — the LLM calls it when it needs to see something visual. Surfaces the session URL and waits for the next paste.
11+
- **Multiple images** — paste as many images as you want during a session.
12+
13+
## Install
14+
15+
Symlink or copy `glance.ts` into your OpenCode plugins directory:
16+
17+
```bash
18+
# symlink (recommended — stays up to date with git pulls)
19+
ln -s "$(pwd)/glance.ts" ~/.config/opencode/plugins/glance.ts
20+
21+
# or per-project
22+
ln -s "$(pwd)/glance.ts" .opencode/plugins/glance.ts
23+
```
24+
25+
Restart OpenCode. The background session starts automatically.
26+
27+
## How it works
28+
29+
```
30+
opencode starts
31+
└─▶ plugin creates session on glance.sh
32+
└─▶ connects SSE (background, auto-reconnect)
33+
34+
LLM calls glance tool
35+
└─▶ surfaces session URL
36+
└─▶ waits for image paste
37+
38+
user pastes image at /s/<id>
39+
└─▶ SSE emits "image" event
40+
└─▶ tool returns image URL to LLM
41+
42+
session expires (~10 min)
43+
└─▶ plugin creates new session, reconnects
44+
```
45+
46+
## Requirements
47+
48+
- [OpenCode](https://github.com/anomalyco/opencode) v0.1+
49+
- Bun runtime (ships with OpenCode)
50+
51+
## Configuration
52+
53+
No API keys required — sessions are anonymous and ephemeral (10-minute TTL).
54+
55+
The plugin connects to `https://glance.sh` by default. The SSE connection is held for ~5 minutes per cycle, with automatic reconnection.

opencode/glance.test.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { describe, it, expect, vi, afterEach } from "vitest"
2+
3+
// Mock @opencode-ai/plugin — `tool()` is a passthrough that returns the config
4+
vi.mock("@opencode-ai/plugin", () => ({
5+
tool: (config: any) => config,
6+
}))
7+
8+
// Helper: dynamically import the plugin to get fresh module-level state
9+
async function loadPlugin() {
10+
vi.resetModules()
11+
vi.doMock("@opencode-ai/plugin", () => ({
12+
tool: (config: any) => config,
13+
}))
14+
const mod = await import("./glance.js")
15+
return mod.GlancePlugin
16+
}
17+
18+
function mockClient() {
19+
return { client: {} }
20+
}
21+
22+
function mockContext(abort?: AbortSignal) {
23+
return {
24+
metadata: vi.fn(),
25+
abort,
26+
}
27+
}
28+
29+
/**
30+
* Build a ReadableStream that emits the given SSE chunks, then hangs forever.
31+
* The hang prevents the background loop from spinning in a tight reconnect cycle.
32+
*/
33+
function sseStream(events: string[]) {
34+
const encoder = new TextEncoder()
35+
let i = 0
36+
return new ReadableStream({
37+
pull(controller) {
38+
if (i < events.length) {
39+
controller.enqueue(encoder.encode(events[i]))
40+
i++
41+
return
42+
}
43+
// Hang forever after all events are emitted
44+
return new Promise(() => {})
45+
},
46+
})
47+
}
48+
49+
function cleanupWaiters() {
50+
for (const key of Object.keys(globalThis)) {
51+
if (key.startsWith("__glance_waiter_")) {
52+
delete (globalThis as any)[key]
53+
}
54+
}
55+
}
56+
57+
/**
58+
* URL-aware fetch mock. Routes by URL so both the background loop and
59+
* tool calls get correct responses regardless of call order.
60+
*/
61+
function routedFetch(opts: {
62+
session?: { id: string; url: string }
63+
sessionError?: number
64+
sseEvents?: string[]
65+
}) {
66+
return vi.fn(async (url: string, _init?: any) => {
67+
if (url === "https://glance.sh/api/session") {
68+
if (opts.sessionError) {
69+
return { ok: false, status: opts.sessionError }
70+
}
71+
return {
72+
ok: true,
73+
json: async () => opts.session ?? { id: "test-id", url: "/s/test-id" },
74+
}
75+
}
76+
77+
if (typeof url === "string" && url.includes("/events")) {
78+
return {
79+
ok: true,
80+
body: sseStream(opts.sseEvents ?? []),
81+
}
82+
}
83+
84+
return { ok: false, status: 404 }
85+
})
86+
}
87+
88+
describe("opencode glance plugin", () => {
89+
afterEach(() => {
90+
vi.restoreAllMocks()
91+
cleanupWaiters()
92+
})
93+
94+
describe("glance tool", () => {
95+
it("creates a session and returns the URL", async () => {
96+
vi.stubGlobal(
97+
"fetch",
98+
routedFetch({ session: { id: "abc123", url: "/s/abc123" } }),
99+
)
100+
101+
const GlancePlugin = await loadPlugin()
102+
const plugin = await GlancePlugin(mockClient())
103+
const result = await plugin.tool.glance.execute({})
104+
105+
expect(result).toContain("https://glance.sh/s/abc123")
106+
expect(result).toContain("Session ready")
107+
})
108+
109+
it("reuses an existing session on second call", async () => {
110+
const fetchFn = routedFetch({
111+
session: { id: "abc123", url: "/s/abc123" },
112+
})
113+
vi.stubGlobal("fetch", fetchFn)
114+
115+
const GlancePlugin = await loadPlugin()
116+
const plugin = await GlancePlugin(mockClient())
117+
118+
// Let background loop create its session
119+
await new Promise((r) => setTimeout(r, 20))
120+
121+
const r1 = await plugin.tool.glance.execute({})
122+
const r2 = await plugin.tool.glance.execute({})
123+
124+
expect(r1).toContain("/s/abc123")
125+
expect(r2).toContain("/s/abc123")
126+
})
127+
128+
it("returns error when session creation fails", async () => {
129+
vi.stubGlobal("fetch", routedFetch({ sessionError: 500 }))
130+
131+
const GlancePlugin = await loadPlugin()
132+
const plugin = await GlancePlugin(mockClient())
133+
134+
// Wait for background loop to fail
135+
await new Promise((r) => setTimeout(r, 50))
136+
137+
const result = await plugin.tool.glance.execute({})
138+
expect(result).toContain("Failed to create session")
139+
})
140+
})
141+
142+
describe("glance_wait tool", () => {
143+
it("returns error when no session exists", async () => {
144+
vi.stubGlobal(
145+
"fetch",
146+
vi.fn().mockRejectedValue(new Error("no network")),
147+
)
148+
149+
const GlancePlugin = await loadPlugin()
150+
const plugin = await GlancePlugin(mockClient())
151+
152+
// Give background loop time to fail
153+
await new Promise((r) => setTimeout(r, 50))
154+
155+
const ctx = mockContext()
156+
const result = await plugin.tool.glance_wait.execute({}, ctx)
157+
expect(result).toContain("No active session")
158+
})
159+
160+
it("returns image URL when image is dispatched", async () => {
161+
const imagePayload = JSON.stringify({
162+
url: "https://glance.sh/tok123.png",
163+
expiresAt: Date.now() + 60_000,
164+
})
165+
166+
vi.stubGlobal(
167+
"fetch",
168+
routedFetch({
169+
session: { id: "sess1", url: "/s/sess1" },
170+
sseEvents: [
171+
`event: connected\ndata: {}\n\n`,
172+
`event: image\ndata: ${imagePayload}\n\n`,
173+
],
174+
}),
175+
)
176+
177+
const GlancePlugin = await loadPlugin()
178+
const plugin = await GlancePlugin(mockClient())
179+
180+
// Ensure session exists
181+
await plugin.tool.glance.execute({})
182+
183+
const ctx = mockContext()
184+
const result = await plugin.tool.glance_wait.execute({}, ctx)
185+
186+
expect(result).toContain("https://glance.sh/tok123.png")
187+
expect(ctx.metadata).toHaveBeenCalledWith(
188+
expect.objectContaining({
189+
title: expect.stringContaining("Waiting for paste"),
190+
}),
191+
)
192+
})
193+
194+
it("returns timeout message when aborted", async () => {
195+
vi.stubGlobal(
196+
"fetch",
197+
routedFetch({
198+
session: { id: "sess2", url: "/s/sess2" },
199+
}),
200+
)
201+
202+
const GlancePlugin = await loadPlugin()
203+
const plugin = await GlancePlugin(mockClient())
204+
await plugin.tool.glance.execute({})
205+
206+
const ac = new AbortController()
207+
const ctx = mockContext(ac.signal)
208+
209+
const waitPromise = plugin.tool.glance_wait.execute({}, ctx)
210+
await new Promise((r) => setTimeout(r, 50))
211+
ac.abort()
212+
213+
const result = await waitPromise
214+
expect(result).toContain("timed out")
215+
})
216+
})
217+
})

0 commit comments

Comments
 (0)