From 10dc64c975da6432e69a55ea50549aeaaa039c61 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 27 Jun 2026 09:29:21 -0400 Subject: [PATCH] Stop idle glance session refresh loops --- claude/.claude-plugin/plugin.json | 2 +- claude/README.md | 8 +++-- claude/common/mcp/glance-mcp.js | 28 +++++++++++++++--- claude/package.json | 2 +- claude/servers/glance-mcp.test.ts | 28 +++++++++++++----- codex/README.md | 10 ++++--- codex/common/mcp/glance-mcp.js | 28 +++++++++++++++--- codex/package.json | 2 +- common/mcp/glance-mcp.js | 28 +++++++++++++++--- opencode/README.md | 18 ++++++------ opencode/glance.ts | 49 +++++++++++++++++++++++++------ opencode/package.json | 2 +- pi/README.md | 20 ++++++------- pi/glance.test.ts | 22 +++++++++++--- pi/glance.ts | 48 +++++++++++++++++++++--------- pi/package.json | 2 +- 16 files changed, 220 insertions(+), 77 deletions(-) diff --git a/claude/.claude-plugin/plugin.json b/claude/.claude-plugin/plugin.json index bf7408b..fe67a84 100644 --- a/claude/.claude-plugin/plugin.json +++ b/claude/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "glance-claude", - "version": "0.1.0", + "version": "0.1.1", "description": "glance.sh MCP tools for Claude Code", "author": { "name": "Modem" diff --git a/claude/README.md b/claude/README.md index dbeb0b2..3eef86b 100644 --- a/claude/README.md +++ b/claude/README.md @@ -9,7 +9,7 @@ Adds two MCP tools: - **`glance`** — creates/reuses a live session and returns a URL like `https://glance.sh/s/` - **`glance_wait`** — waits for the next pasted image and returns `Screenshot: https://glance.sh/.` -The server keeps a background SSE listener alive, reconnects automatically, and refreshes sessions before they expire. +The server opens an SSE listener on demand and stops after one image, timeout, expiry, cancellation, or a small number of transient retries. ## Install @@ -64,8 +64,8 @@ Prerequisite: configure `NPM_TOKEN` in the `glance-agent-plugins` repository wit 3. Create and push a matching tag: ```bash -git tag claude-v0.1.0 -git push origin claude-v0.1.0 +git tag claude-v0.1.1 +git push origin claude-v0.1.1 ``` The `Release claude package` workflow validates tag/version alignment, checks for already-published versions, runs `npm pack --dry-run`, and publishes with npm provenance. @@ -75,6 +75,7 @@ The `Release claude package` workflow validates tag/version alignment, checks fo ```text Claude calls glance └─▶ MCP server POST /api/session + └─▶ connects SSE for one wait window └─▶ returns session URL Claude calls glance_wait @@ -83,6 +84,7 @@ Claude calls glance_wait User pastes image at /s/ └─▶ glance.sh emits image event └─▶ tool returns Screenshot: + └─▶ listener stops ``` ## Requirements diff --git a/claude/common/mcp/glance-mcp.js b/claude/common/mcp/glance-mcp.js index fe7d100..bc881fd 100644 --- a/claude/common/mcp/glance-mcp.js +++ b/claude/common/mcp/glance-mcp.js @@ -2,6 +2,7 @@ import { realpathSync } from "node:fs" import { fileURLToPath, pathToFileURL } from "node:url" const DEFAULT_BASE_URL = process.env.GLANCE_BASE_URL?.trim() || "https://glance.sh" +const USER_AGENT = "glance-mcp/0.1.2" /** How long to wait on one SSE connection before reconnecting. */ const SSE_TIMEOUT_MS = 305_000 @@ -9,6 +10,9 @@ const SSE_TIMEOUT_MS = 305_000 /** Pause between reconnect attempts on transient errors. */ const RECONNECT_DELAY_MS = 3_000 +/** Maximum transient SSE retries for one user-triggered wait window. */ +const MAX_RECONNECT_ATTEMPTS = 3 + /** How often to mint a fresh session (sessions have 10-minute TTL). */ const SESSION_REFRESH_MS = 8 * 60 * 1000 @@ -106,7 +110,10 @@ export function createGlanceRuntime(options = {}) { } async function createSession() { - const res = await fetchImpl(`${baseUrl}/api/session`, { method: "POST" }) + const res = await fetchImpl(`${baseUrl}/api/session`, { + method: "POST", + headers: { "User-Agent": USER_AGENT }, + }) if (!res.ok) { throw new Error(`HTTP ${res.status}`) } @@ -154,7 +161,10 @@ export function createGlanceRuntime(options = {}) { async function listenForImages(sessionId, signal) { const res = await fetchImpl(`${baseUrl}/api/session/${sessionId}/events`, { signal, - headers: { Accept: "text/event-stream" }, + headers: { + Accept: "text/event-stream", + "User-Agent": USER_AGENT, + }, }) if (!res.ok || !res.body) { @@ -214,6 +224,7 @@ export function createGlanceRuntime(options = {}) { typeof image.expiresAt === "number" ) { dispatchToWaiters(image) + return } } catch { log("Failed to parse image event payload") @@ -240,13 +251,22 @@ export function createGlanceRuntime(options = {}) { } async function backgroundLoop(signal) { + let reconnectAttempts = 0 + while (!signal.aborted) { try { const session = await ensureSession() await listenForImages(session.id, signal) + + // Stop after one active wait window (image, timeout, or expiry). Idle + // agents must not keep refreshing sessions indefinitely. + break } catch (err) { if (signal.aborted) break - await sleep(RECONNECT_DELAY_MS, signal) + reconnectAttempts += 1 + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) break + + await sleep(RECONNECT_DELAY_MS * 2 ** (reconnectAttempts - 1), signal) } } @@ -475,7 +495,7 @@ export function createMcpServer(options = {}) { protocolVersion: params?.protocolVersion ?? "2024-11-05", serverInfo: { name: "glance-sh", - version: "0.1.0", + version: "0.1.2", }, capabilities: { tools: { diff --git a/claude/package.json b/claude/package.json index d0514e2..c42443e 100644 --- a/claude/package.json +++ b/claude/package.json @@ -1,6 +1,6 @@ { "name": "@modemdev/glance-claude", - "version": "0.1.0", + "version": "0.1.1", "description": "glance.sh plugin package for Claude Code", "license": "MIT", "type": "module", diff --git a/claude/servers/glance-mcp.test.ts b/claude/servers/glance-mcp.test.ts index c589774..fd89ddd 100644 --- a/claude/servers/glance-mcp.test.ts +++ b/claude/servers/glance-mcp.test.ts @@ -155,9 +155,20 @@ describe("claude glance runtime", () => { expect(result.content[0].text).toContain("Session ready") expect(result.content[0].text).toContain("https://glance.sh/s/sess-1") - expect(fetchMock).toHaveBeenCalledWith("https://glance.sh/api/session", { + expect(fetchMock).toHaveBeenNthCalledWith(1, "https://glance.sh/api/session", { method: "POST", + headers: { "User-Agent": "glance-mcp/0.1.2" }, }) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://glance.sh/api/session/sess-1/events", + expect.objectContaining({ + headers: { + Accept: "text/event-stream", + "User-Agent": "glance-mcp/0.1.2", + }, + }), + ) }) it("reuses active sessions and refreshes stale sessions", async () => { @@ -236,7 +247,7 @@ describe("claude glance runtime", () => { await sse.close() }) - it("handles session expiry by rotating to a fresh session", async () => { + it("handles session expiry without reconnecting until the next tool call", async () => { let sessionCalls = 0 const fetchMock = vi.fn((input: string | URL, init?: RequestInit) => { @@ -268,13 +279,16 @@ describe("claude glance runtime", () => { await runtime.executeTool("glance") await vi.waitFor(() => { - expect(sessionCalls).toBe(2) + expect(runtime.getState().running).toBe(false) }) - expect(runtime.getState().currentSession).toEqual({ - id: "sess-2", - url: "https://glance.sh/s/sess-2", - }) + expect(sessionCalls).toBe(1) + expect(runtime.getState().currentSession).toBeNull() + + const refreshed = (await runtime.executeTool("glance")) as ToolResult + + expect(sessionCalls).toBe(2) + expect(refreshed.content[0].text).toContain("https://glance.sh/s/sess-2") }) it("returns a helpful error when glance_wait is called before glance", async () => { diff --git a/codex/README.md b/codex/README.md index 7becadc..a586b87 100644 --- a/codex/README.md +++ b/codex/README.md @@ -9,7 +9,7 @@ Adds two MCP tools: - **`glance`** — creates/reuses a live session and returns a URL like `https://glance.sh/s/` - **`glance_wait`** — waits for the next pasted image and returns `Screenshot: https://glance.sh/.` -The server keeps a background SSE listener alive, reconnects automatically, and refreshes sessions before they expire. +The server opens an SSE listener on demand and stops after one image, timeout, expiry, cancellation, or a small number of transient retries. ## Install @@ -22,7 +22,7 @@ codex mcp add glance -- npx -y @modemdev/glance-codex Optional: pin a specific version: ```bash -codex mcp add glance -- npx -y @modemdev/glance-codex@0.1.1 +codex mcp add glance -- npx -y @modemdev/glance-codex@0.1.2 ``` Local development / manual install: @@ -71,8 +71,8 @@ Prerequisite: configure `NPM_TOKEN` in the `glance-agent-plugins` repository wit 3. Create and push a matching tag: ```bash -git tag codex-v0.1.1 -git push origin codex-v0.1.1 +git tag codex-v0.1.2 +git push origin codex-v0.1.2 ``` The `Release codex package` workflow validates tag/version alignment, checks for already-published versions, runs `npm pack --dry-run`, and publishes with npm provenance. @@ -82,6 +82,7 @@ The `Release codex package` workflow validates tag/version alignment, checks for ```text Codex calls glance └─▶ MCP server POST /api/session + └─▶ connects SSE for one wait window └─▶ returns session URL Codex calls glance_wait @@ -90,6 +91,7 @@ Codex calls glance_wait User pastes image at /s/ └─▶ glance.sh emits image event └─▶ tool returns Screenshot: + └─▶ listener stops ``` ## Requirements diff --git a/codex/common/mcp/glance-mcp.js b/codex/common/mcp/glance-mcp.js index fe7d100..bc881fd 100644 --- a/codex/common/mcp/glance-mcp.js +++ b/codex/common/mcp/glance-mcp.js @@ -2,6 +2,7 @@ import { realpathSync } from "node:fs" import { fileURLToPath, pathToFileURL } from "node:url" const DEFAULT_BASE_URL = process.env.GLANCE_BASE_URL?.trim() || "https://glance.sh" +const USER_AGENT = "glance-mcp/0.1.2" /** How long to wait on one SSE connection before reconnecting. */ const SSE_TIMEOUT_MS = 305_000 @@ -9,6 +10,9 @@ const SSE_TIMEOUT_MS = 305_000 /** Pause between reconnect attempts on transient errors. */ const RECONNECT_DELAY_MS = 3_000 +/** Maximum transient SSE retries for one user-triggered wait window. */ +const MAX_RECONNECT_ATTEMPTS = 3 + /** How often to mint a fresh session (sessions have 10-minute TTL). */ const SESSION_REFRESH_MS = 8 * 60 * 1000 @@ -106,7 +110,10 @@ export function createGlanceRuntime(options = {}) { } async function createSession() { - const res = await fetchImpl(`${baseUrl}/api/session`, { method: "POST" }) + const res = await fetchImpl(`${baseUrl}/api/session`, { + method: "POST", + headers: { "User-Agent": USER_AGENT }, + }) if (!res.ok) { throw new Error(`HTTP ${res.status}`) } @@ -154,7 +161,10 @@ export function createGlanceRuntime(options = {}) { async function listenForImages(sessionId, signal) { const res = await fetchImpl(`${baseUrl}/api/session/${sessionId}/events`, { signal, - headers: { Accept: "text/event-stream" }, + headers: { + Accept: "text/event-stream", + "User-Agent": USER_AGENT, + }, }) if (!res.ok || !res.body) { @@ -214,6 +224,7 @@ export function createGlanceRuntime(options = {}) { typeof image.expiresAt === "number" ) { dispatchToWaiters(image) + return } } catch { log("Failed to parse image event payload") @@ -240,13 +251,22 @@ export function createGlanceRuntime(options = {}) { } async function backgroundLoop(signal) { + let reconnectAttempts = 0 + while (!signal.aborted) { try { const session = await ensureSession() await listenForImages(session.id, signal) + + // Stop after one active wait window (image, timeout, or expiry). Idle + // agents must not keep refreshing sessions indefinitely. + break } catch (err) { if (signal.aborted) break - await sleep(RECONNECT_DELAY_MS, signal) + reconnectAttempts += 1 + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) break + + await sleep(RECONNECT_DELAY_MS * 2 ** (reconnectAttempts - 1), signal) } } @@ -475,7 +495,7 @@ export function createMcpServer(options = {}) { protocolVersion: params?.protocolVersion ?? "2024-11-05", serverInfo: { name: "glance-sh", - version: "0.1.0", + version: "0.1.2", }, capabilities: { tools: { diff --git a/codex/package.json b/codex/package.json index e475d9b..b835075 100644 --- a/codex/package.json +++ b/codex/package.json @@ -1,6 +1,6 @@ { "name": "@modemdev/glance-codex", - "version": "0.1.1", + "version": "0.1.2", "description": "glance.sh MCP server package for Codex", "license": "MIT", "type": "module", diff --git a/common/mcp/glance-mcp.js b/common/mcp/glance-mcp.js index fe7d100..bc881fd 100644 --- a/common/mcp/glance-mcp.js +++ b/common/mcp/glance-mcp.js @@ -2,6 +2,7 @@ import { realpathSync } from "node:fs" import { fileURLToPath, pathToFileURL } from "node:url" const DEFAULT_BASE_URL = process.env.GLANCE_BASE_URL?.trim() || "https://glance.sh" +const USER_AGENT = "glance-mcp/0.1.2" /** How long to wait on one SSE connection before reconnecting. */ const SSE_TIMEOUT_MS = 305_000 @@ -9,6 +10,9 @@ const SSE_TIMEOUT_MS = 305_000 /** Pause between reconnect attempts on transient errors. */ const RECONNECT_DELAY_MS = 3_000 +/** Maximum transient SSE retries for one user-triggered wait window. */ +const MAX_RECONNECT_ATTEMPTS = 3 + /** How often to mint a fresh session (sessions have 10-minute TTL). */ const SESSION_REFRESH_MS = 8 * 60 * 1000 @@ -106,7 +110,10 @@ export function createGlanceRuntime(options = {}) { } async function createSession() { - const res = await fetchImpl(`${baseUrl}/api/session`, { method: "POST" }) + const res = await fetchImpl(`${baseUrl}/api/session`, { + method: "POST", + headers: { "User-Agent": USER_AGENT }, + }) if (!res.ok) { throw new Error(`HTTP ${res.status}`) } @@ -154,7 +161,10 @@ export function createGlanceRuntime(options = {}) { async function listenForImages(sessionId, signal) { const res = await fetchImpl(`${baseUrl}/api/session/${sessionId}/events`, { signal, - headers: { Accept: "text/event-stream" }, + headers: { + Accept: "text/event-stream", + "User-Agent": USER_AGENT, + }, }) if (!res.ok || !res.body) { @@ -214,6 +224,7 @@ export function createGlanceRuntime(options = {}) { typeof image.expiresAt === "number" ) { dispatchToWaiters(image) + return } } catch { log("Failed to parse image event payload") @@ -240,13 +251,22 @@ export function createGlanceRuntime(options = {}) { } async function backgroundLoop(signal) { + let reconnectAttempts = 0 + while (!signal.aborted) { try { const session = await ensureSession() await listenForImages(session.id, signal) + + // Stop after one active wait window (image, timeout, or expiry). Idle + // agents must not keep refreshing sessions indefinitely. + break } catch (err) { if (signal.aborted) break - await sleep(RECONNECT_DELAY_MS, signal) + reconnectAttempts += 1 + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) break + + await sleep(RECONNECT_DELAY_MS * 2 ** (reconnectAttempts - 1), signal) } } @@ -475,7 +495,7 @@ export function createMcpServer(options = {}) { protocolVersion: params?.protocolVersion ?? "2024-11-05", serverInfo: { name: "glance-sh", - version: "0.1.0", + version: "0.1.2", }, capabilities: { tools: { diff --git a/opencode/README.md b/opencode/README.md index 9dad46e..a06f4a2 100644 --- a/opencode/README.md +++ b/opencode/README.md @@ -6,10 +6,9 @@ Starts a glance.sh session **on demand**. Idle OpenCode sessions do not keep a background connection open. -- **On-demand listener** — starts when the `glance` tool is used, reconnects automatically, refreshes sessions before they expire. +- **On-demand listener** — starts when the `glance` tool is used. It stops after one image, timeout, expiry, cancellation, or a small number of transient retries. - **`glance` tool** — the LLM calls it when it needs to see something visual. Surfaces the session URL. - **`glance_wait` tool** — waits for the next paste and returns the image URL. -- **Multiple images** — paste as many images as you want while the listener is active. ## Install @@ -31,7 +30,7 @@ Optional: pin a specific version: ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["@modemdev/glance-opencode@0.1.0"] + "plugin": ["@modemdev/glance-opencode@0.1.1"] } ``` @@ -58,8 +57,8 @@ Prerequisite: configure `NPM_TOKEN` in the `glance-agent-plugins` repository wit 3. Create and push a matching tag: ```bash -git tag opencode-v0.1.0 -git push origin opencode-v0.1.0 +git tag opencode-v0.1.1 +git push origin opencode-v0.1.1 ``` The `Release opencode package` workflow validates the tag/version match and publishes with npm provenance. @@ -82,7 +81,7 @@ ln -s "$(pwd)/glance.ts" .opencode/plugins/glance.ts ```text LLM calls glance tool └─▶ plugin creates session on glance.sh - └─▶ connects SSE (background, auto-reconnect) + └─▶ connects SSE for one wait window └─▶ surfaces session URL LLM calls glance_wait tool @@ -91,9 +90,10 @@ LLM calls glance_wait tool user pastes image at /s/ └─▶ SSE emits "image" event └─▶ glance_wait returns image URL to LLM + └─▶ listener stops -session expires (~10 min) - └─▶ plugin creates new session, reconnects +no image arrives within ~5 min, session expires, or request is cancelled + └─▶ listener stops ``` ## Requirements @@ -105,4 +105,4 @@ session expires (~10 min) No API keys required — sessions are anonymous and ephemeral (10-minute TTL). -The plugin connects to `https://glance.sh` by default. Once started, the SSE connection is held for ~5 minutes per cycle, with automatic reconnection. +The plugin connects to `https://glance.sh` by default. Once started, the SSE connection is held for up to ~5 minutes, then stops unless the agent invokes glance again. diff --git a/opencode/glance.ts b/opencode/glance.ts index 09daf28..1bb556c 100644 --- a/opencode/glance.ts +++ b/opencode/glance.ts @@ -17,6 +17,7 @@ import { type Plugin, tool } from "@opencode-ai/plugin" const BASE_URL = "https://glance.sh" +const USER_AGENT = "glance-opencode/0.1.1" /** How long to wait on a single SSE connection before reconnecting. */ const SSE_TIMEOUT_MS = 305_000 @@ -24,6 +25,9 @@ const SSE_TIMEOUT_MS = 305_000 /** Pause between reconnect attempts on error. */ const RECONNECT_DELAY_MS = 3_000 +/** Maximum transient SSE retries for one user-triggered wait window. */ +const MAX_RECONNECT_ATTEMPTS = 3 + /** How often to create a fresh session (sessions have 10-min TTL). */ const SESSION_REFRESH_MS = 8 * 60 * 1000 @@ -39,6 +43,10 @@ interface ImageEvent { expiresAt: number } +function normalizeSessionUrl(url: string): string { + return new URL(url, BASE_URL).toString() +} + // ── Persistent background session ────────────────────────────────── let currentSession: SessionResponse | null = null @@ -48,9 +56,13 @@ let running = false let waiterCounter = 0 async function createSession(): Promise { - const res = await fetch(`${BASE_URL}/api/session`, { method: "POST" }) + const res = await fetch(`${BASE_URL}/api/session`, { + method: "POST", + headers: { "User-Agent": USER_AGENT }, + }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const session = (await res.json()) as SessionResponse + session.url = normalizeSessionUrl(session.url) currentSession = session sessionCreatedAt = Date.now() return session @@ -72,6 +84,8 @@ async function backgroundLoop(onImage: (image: ImageEvent) => void) { abortController = new AbortController() const { signal } = abortController + let reconnectAttempts = 0 + while (!signal.aborted) { try { if (!currentSession || isSessionStale()) { @@ -81,9 +95,16 @@ async function backgroundLoop(onImage: (image: ImageEvent) => void) { await listenForImages(currentSession!.id, signal, (image) => { onImage(image) }) + + // Stop after one active wait window (image, timeout, or expiry). Idle + // agents must not keep refreshing sessions indefinitely. + break } catch (err: any) { if (signal.aborted) break - await sleep(RECONNECT_DELAY_MS) + reconnectAttempts += 1 + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) break + + await sleep(RECONNECT_DELAY_MS * 2 ** (reconnectAttempts - 1)) } } @@ -118,7 +139,10 @@ async function listenForImages( ): Promise { const res = await fetch(`${BASE_URL}/api/session/${sessionId}/events`, { signal, - headers: { Accept: "text/event-stream" }, + headers: { + Accept: "text/event-stream", + "User-Agent": USER_AGENT, + }, }) if (!res.ok || !res.body) { @@ -159,6 +183,8 @@ async function listenForImages( if (eventType === "image" && dataLines.length > 0) { const data = JSON.parse(dataLines.join("\n")) as ImageEvent onImage(data) + clearTimeout(timeout) + return } if (eventType === "expired") { currentSession = null @@ -243,18 +269,19 @@ export const GlancePlugin: Plugin = async ({ client }) => { args: {}, async execute() { // Ensure session exists - if (!currentSession) { + if (!currentSession || isSessionStale()) { try { await createSession() - if (!running) { - backgroundLoop(handleImage).catch(() => {}) - } } catch (err: any) { return `Failed to create session: ${err.message}` } } - const sessionUrl = `${BASE_URL}${currentSession!.url}` + if (!running) { + backgroundLoop(handleImage).catch(() => {}) + } + + const sessionUrl = currentSession!.url return `Session ready. Ask the user to paste an image at ${sessionUrl}` }, }), @@ -270,7 +297,11 @@ export const GlancePlugin: Plugin = async ({ client }) => { return "No active session. Call glance first to create one." } - const sessionUrl = `${BASE_URL}${currentSession!.url}` + if (!running) { + backgroundLoop(handleImage).catch(() => {}) + } + + const sessionUrl = currentSession!.url context.metadata({ title: `Waiting for paste at ${sessionUrl}`, diff --git a/opencode/package.json b/opencode/package.json index c416418..d3b7186 100644 --- a/opencode/package.json +++ b/opencode/package.json @@ -1,6 +1,6 @@ { "name": "@modemdev/glance-opencode", - "version": "0.1.0", + "version": "0.1.1", "description": "glance.sh plugin package for OpenCode", "license": "MIT", "type": "module", diff --git a/pi/README.md b/pi/README.md index 8202ec0..e61a90d 100644 --- a/pi/README.md +++ b/pi/README.md @@ -6,10 +6,9 @@ Starts a glance.sh session **on demand**. Idle pi sessions do not keep a background connection open. -- **On-demand listener** — starts when `/glance` or the `glance` tool is used, reconnects automatically, refreshes sessions before they expire. +- **On-demand listener** — starts when `/glance` or the `glance` tool is used. It stops after one image, timeout, expiry, cancellation, or a small number of transient retries. - **`glance` tool** — the LLM calls it when it needs to see something visual. Surfaces the session URL and waits for the next paste. -- **`/glance` command** — type it to create/show the current session URL. -- **Multiple images** — paste as many images as you want while the listener is active. Each one is injected into the conversation as `Screenshot: `. +- **`/glance` command** — type it to create/show the current session URL and open one wait window. ## Install @@ -61,8 +60,8 @@ Prerequisite: configure `NPM_TOKEN` in the `glance-agent-plugins` repository wit 3. Create and push a matching tag: ```bash -git tag pi-v0.1.0 -git push origin pi-v0.1.0 +git tag pi-v0.1.1 +git push origin pi-v0.1.1 ``` The `Release pi package` workflow validates the tag/version match and publishes with npm provenance. @@ -84,17 +83,18 @@ cp glance.ts ~/.pi/agent/extensions/glance.ts ```text user or LLM invokes /glance or the glance tool └─▶ create session on glance.sh - └─▶ connect SSE (background, auto-reconnect) + └─▶ connect SSE for one wait window user pastes image at /s/ └─▶ SSE emits "image" event └─▶ extension injects "Screenshot: " into conversation + └─▶ listener stops -session expires (~10 min) - └─▶ extension creates new session, reconnects +no image arrives within ~5 min, session expires, or request is cancelled + └─▶ listener stops ``` -The `glance` tool reuses an active background session when one exists; otherwise it creates a session and starts the listener. +The `glance` tool reuses an active session when one exists; otherwise it creates a session and starts a single wait window. ## Requirements @@ -105,4 +105,4 @@ The `glance` tool reuses an active background session when one exists; otherwise No API keys required — sessions are anonymous and ephemeral (10-minute TTL). -The extension connects to `https://glance.sh` by default. Once started, the SSE connection is held for ~5 minutes per cycle, with automatic reconnection. +The extension connects to `https://glance.sh` by default. Once started, the SSE connection is held for up to ~5 minutes, then stops unless the user or agent invokes glance again. diff --git a/pi/glance.test.ts b/pi/glance.test.ts index 84ec832..dca3560 100644 --- a/pi/glance.test.ts +++ b/pi/glance.test.ts @@ -208,6 +208,7 @@ describe("pi/glance", () => { }); expect(fetchMock).toHaveBeenCalledWith("https://glance.sh/api/session", { method: "POST", + headers: { "User-Agent": "glance-pi/0.1.1" }, }); expect(__testing.getState().currentSession).toEqual({ ...session, @@ -219,7 +220,7 @@ describe("pi/glance", () => { expect(__testing.isSessionStale()).toBe(true); }); - it("parses image SSE events and clears the session on expiry", async () => { + it("parses image SSE events and stops listening after the first image", async () => { const session = { id: "session-2", url: "https://glance.sh/s/session-2", @@ -247,12 +248,15 @@ describe("pi/glance", () => { expect(fetchMock).toHaveBeenCalledWith( `https://glance.sh/api/session/${session.id}/events`, { - headers: { Accept: "text/event-stream" }, + headers: { + Accept: "text/event-stream", + "User-Agent": "glance-pi/0.1.1", + }, signal: expect.any(AbortSignal), }, ); expect(onImage).toHaveBeenCalledWith(image); - expect(__testing.getState().currentSession).toBeNull(); + expect(__testing.getState().currentSession).toEqual(session); }); it("does not start the background listener on session_start", async () => { @@ -314,7 +318,17 @@ describe("pi/glance", () => { `Paste screenshots at ${session.url}`, "info", ); - expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(globalThis.fetch).toHaveBeenCalledWith( + `https://glance.sh/api/session/${session.id}/events`, + expect.objectContaining({ + headers: { + Accept: "text/event-stream", + "User-Agent": "glance-pi/0.1.1", + }, + }), + ); + + __testing.stopBackground(); }); it("creates a session through the /glance command when none exists", async () => { diff --git a/pi/glance.ts b/pi/glance.ts index 8815b75..7d5d690 100644 --- a/pi/glance.ts +++ b/pi/glance.ts @@ -20,6 +20,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Text } from "@mariozechner/pi-tui"; const BASE_URL = "https://glance.sh"; +const USER_AGENT = "glance-pi/0.1.1"; /** How long to wait on a single SSE connection before reconnecting. */ const SSE_TIMEOUT_MS = 305_000; @@ -27,6 +28,9 @@ const SSE_TIMEOUT_MS = 305_000; /** Pause between reconnect attempts on error. */ const RECONNECT_DELAY_MS = 3_000; +/** Maximum transient SSE retries for one user-triggered wait window. */ +const MAX_RECONNECT_ATTEMPTS = 3; + /** How often to create a fresh session (sessions have 10-min TTL). */ const SESSION_REFRESH_MS = 8 * 60 * 1000; // 8 minutes — well before expiry @@ -62,7 +66,10 @@ let running = false; let waiterCounter = 0; async function createSession(): Promise { - const res = await fetch(`${BASE_URL}/api/session`, { method: "POST" }); + const res = await fetch(`${BASE_URL}/api/session`, { + method: "POST", + headers: { "User-Agent": USER_AGENT }, + }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const session = (await res.json()) as SessionResponse; session.url = normalizeSessionUrl(session.url); @@ -90,6 +97,8 @@ async function backgroundLoop( abortController = new AbortController(); const { signal } = abortController; + let reconnectAttempts = 0; + while (!signal.aborted) { try { // Create or refresh session @@ -102,12 +111,16 @@ async function backgroundLoop( onImage(image); }); - // listenForImages returned normally → SSE timed out or session expired. - // Loop will reconnect (and refresh session if stale). + // Stop after one active wait window (image, timeout, or expiry). Idle + // agents must not keep refreshing sessions indefinitely. + break; } catch (err: any) { if (signal.aborted) break; - // Transient error — wait and retry - await sleep(RECONNECT_DELAY_MS); + reconnectAttempts += 1; + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) break; + + // Transient error — wait briefly and retry with capped backoff. + await sleep(RECONNECT_DELAY_MS * 2 ** (reconnectAttempts - 1)); } } @@ -150,7 +163,10 @@ async function listenForImages( ): Promise { const res = await fetch(`${BASE_URL}/api/session/${sessionId}/events`, { signal, - headers: { Accept: "text/event-stream" }, + headers: { + Accept: "text/event-stream", + "User-Agent": USER_AGENT, + }, }); if (!res.ok || !res.body) { @@ -191,6 +207,8 @@ async function listenForImages( if (eventType === "image" && dataLines.length > 0) { const data = JSON.parse(dataLines.join("\n")) as ImageEvent; onImage(data); + clearTimeout(timeout); + return; } if (eventType === "expired") { // Session gone — force refresh on next loop iteration @@ -320,19 +338,20 @@ export default function (pi: ExtensionAPI) { pi.registerCommand("glance", { description: "Show the glance.sh session URL (paste screenshots there)", handler: async (_args, ctx) => { - if (!currentSession) { + if (!currentSession || isSessionStale()) { ctx.ui.notify("No active glance session — starting one…", "info"); try { await createSession(); - if (!running) { - backgroundLoop(pi, handleImage).catch(() => {}); - } } catch (err: any) { ctx.ui.notify(`Failed to create session: ${err.message}`, "error"); return; } } + if (!running) { + backgroundLoop(pi, handleImage).catch(() => {}); + } + ctx.ui.notify( `Paste screenshots at ${currentSession!.url}`, "info", @@ -353,12 +372,9 @@ export default function (pi: ExtensionAPI) { async execute(_toolCallId, _params, signal, onUpdate, ctx) { // Ensure session exists - if (!currentSession) { + if (!currentSession || isSessionStale()) { try { await createSession(); - if (!running) { - backgroundLoop(pi, handleImage).catch(() => {}); - } } catch (err: any) { return { content: [{ type: "text", text: `Failed to create session: ${err.message}` }], @@ -368,6 +384,10 @@ export default function (pi: ExtensionAPI) { } } + if (!running) { + backgroundLoop(pi, handleImage).catch(() => {}); + } + const sessionUrl = currentSession!.url; // Show the URL to the user diff --git a/pi/package.json b/pi/package.json index 369e867..47b28db 100644 --- a/pi/package.json +++ b/pi/package.json @@ -1,6 +1,6 @@ { "name": "@modemdev/glance-pi", - "version": "0.1.0", + "version": "0.1.1", "description": "glance.sh extension package for pi", "license": "MIT", "repository": {