From 0ebe057640750860431e68a9486937a9ed2b5315 Mon Sep 17 00:00:00 2001 From: Ben Vinegar <2153+benvinegar@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:29:30 -0400 Subject: [PATCH 1/2] feat(viewer): render session-level comments in a session thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments not attached to a snippet (sideshow comment without --snippet, or POST /api/comments with only a session) were stored and delivered to agents but never rendered anywhere in the viewer — the agent believed it had replied while the user saw nothing. Found while dogfooding the MCP flow end to end. A session thread now sits at the bottom of each session's stream: it renders snippet-less comments live over SSE and adds a composer so the user can message the agent without picking a snippet (the long-poll already delivers those to agents). New snippet cards insert above the thread; existing e2e selectors scoped to exclude it. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 ++++++ e2e/viewer.spec.ts | 48 +++++++++++++++++++++++++++++++++++++---- viewer/index.html | 53 ++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 98 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a96db0..24be08a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,17 @@ All notable user-visible changes to this project are documented in this file. ### Added +- A session thread at the bottom of each session in the viewer: a composer + for messaging the agent without picking a snippet. + ### Changed ### Fixed +- Comments not attached to a snippet (e.g. `sideshow comment` without + `--snippet`) were stored and delivered to agents but never shown in the + viewer; they now render in the session thread. + ## [0.2.0] - 2026-06-11 ### Added diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts index c9bb6fe..31c5a56 100644 --- a/e2e/viewer.spec.ts +++ b/e2e/viewer.spec.ts @@ -7,7 +7,7 @@ test("snippet published over HTTP appears live via SSE, no reload", async ({ pag await publish(server.url, { html: "

It works

", title: "Live test", agent: "e2e" }); // the card streams in over SSE — the page is never reloaded - await expect(page.locator(".card-title")).toHaveText("Live test"); + await expect(page.locator(".card:not(#sessionThread) .card-title")).toHaveText("Live test"); await expect(page.locator("#onboard")).toBeHidden(); await expect(page.locator(".sess-title")).toContainText("e2e session"); }); @@ -30,13 +30,14 @@ test("comment typed in the composer round-trips to the API", async ({ page, serv const snippet = await publish(server.url, { html: "

v1

", title: "Doc", agent: "e2e" }); await page.goto(server.url); - const input = page.locator(".composer input"); + const card = page.locator(".card:not(#sessionThread)"); + const input = card.locator(".composer input"); await input.fill("ship it"); await input.press("Enter"); // renders in the thread (via SSE) and is persisted server-side - await expect(page.locator(".cmt .txt")).toHaveText("ship it"); - await expect(page.locator(".cmt .who")).toHaveText("you"); + await expect(card.locator(".cmt .txt")).toHaveText("ship it"); + await expect(card.locator(".cmt .who")).toHaveText("you"); await expect .poll(async () => { const res = await fetch(`${server.url}/api/comments?snippet=${snippet.id}`); @@ -46,6 +47,45 @@ test("comment typed in the composer round-trips to the API", async ({ page, serv .toContain("ship it"); }); +test("session thread shows snippet-less comments and messages the agent", async ({ + page, + server, +}) => { + const snippet = await publish(server.url, { html: "

x

", title: "Doc", agent: "e2e" }); + + await page.goto(server.url); + const thread = page.locator("#sessionThread"); + await expect(thread).toBeVisible(); + + // an agent comment with no snippet attached (sideshow comment without --snippet) + await fetch(`${server.url}/api/comments`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ session: snippet.sessionId, text: "agent note", author: "e2e" }), + }); + await expect(thread.locator(".cmt .txt")).toHaveText("agent note"); + + // the user can reply without picking a snippet; it lands as a user comment + const input = thread.locator(".composer input"); + await input.fill("user note"); + await input.press("Enter"); + await expect(thread.locator(".cmt .txt")).toHaveText(["agent note", "user note"]); + await expect + .poll(async () => { + const res = await fetch( + `${server.url}/api/comments?session=${snippet.sessionId}&author=user`, + ); + const data = (await res.json()) as { comments: { snippetId: string | null; text: string }[] }; + return data.comments.filter((c) => !c.snippetId).map((c) => c.text); + }) + .toContain("user note"); + + // snippets published later still appear above the session thread + await publish(server.url, { html: "

y

", title: "Later", session: snippet.sessionId }); + await expect(page.locator("#stream > .card").last()).toHaveId("sessionThread"); + await expect(page.locator("#stream > .card")).toHaveCount(3); +}); + test("version select appears live after an update", async ({ page, server }) => { const snippet = await publish(server.url, { html: "

v1

", title: "Doc", agent: "e2e" }); diff --git a/viewer/index.html b/viewer/index.html index 6831895..f67ee3b 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -596,8 +596,46 @@

or try it yourself

stream.appendChild(e); } for (const meta of snippets) await upsertCard(meta.id, { scroll: false }); + ensureSessionThread(); const { comments } = await api(`/api/comments?session=${id}`); - for (const c of comments) if (c.snippetId) renderComment(c); + for (const c of comments) renderComment(c); + } + + // Comments without a snippet (e.g. `sideshow comment` with no --snippet) + // live in a session-level thread at the bottom of the stream, which also + // lets the user message the agent without picking a snippet. + function ensureSessionThread() { + if ($("sessionThread")) return; + const card = document.createElement("div"); + card.className = "card"; + card.id = "sessionThread"; + card.innerHTML = ` +
+ Session thread + not tied to a snippet +
+
+
+
+ + +
+
`; + const input = card.querySelector(".composer input"); + const send = async () => { + const text = input.value.trim(); + if (!text) return; + input.value = ""; + await api("/api/comments", { + method: "POST", + body: JSON.stringify({ session: state.selected, text, author: "user" }), + }); + }; + input.onkeydown = (e) => { + if (e.key === "Enter") send(); + }; + card.querySelector(".composer button").onclick = send; + $("stream").appendChild(card); } // --- snippet cards --- @@ -651,7 +689,9 @@

or try it yourself

} }; state.cards.set(s.id, card); - $("stream").appendChild(card); + const thread = $("sessionThread"); + if (thread) $("stream").insertBefore(card, thread); + else $("stream").appendChild(card); } card.querySelector(".card-title").textContent = s.title; @@ -688,7 +728,7 @@

or try it yourself

} function renderComment(c) { - const card = state.cards.get(c.snippetId); + const card = c.snippetId ? state.cards.get(c.snippetId) : $("sessionThread"); if (!card) return; const cmts = card.querySelector(".cmts"); if (cmts.querySelector(`[data-cid="${c.id}"]`)) return; @@ -753,10 +793,11 @@

or try it yourself

state.cards.delete(e.id); await refreshSessionsQuiet(); } else if (e.type === "comment-created") { - if (e.sessionId === state.selected && e.snippetId) { - const { comments } = await api(`/api/comments?snippet=${e.snippetId}`); + if (e.sessionId === state.selected) { + const query = e.snippetId ? `snippet=${e.snippetId}` : `session=${e.sessionId}`; + const { comments } = await api(`/api/comments?${query}`); for (const c of comments) renderComment(c); - } else if (e.sessionId !== state.selected) { + } else { state.unread.add(e.sessionId); renderSidebar(); } From 7ade106d22ec2c1cbd3a9a09df5b9d5cba5b2d55 Mon Sep 17 00:00:00 2001 From: Ben Vinegar <2153+benvinegar@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:11:01 -0400 Subject: [PATCH 2/2] feat(server): piggyback unseen user comments on agent writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents previously heard feedback only while blocked on the long-poll. A per-session delivered-to-agent cursor (agentSeq, both stores; SQLite column added via pragma probe for pre-existing boards) now lets every publish/update/agent-reply response carry a userFeedback array with comments the user left since the agent's last call — delivered once. An author=user wait also advances the cursor so consumed feedback is not re-delivered. MCP tool descriptions and instructions teach the field; the stdio server passes it through untouched. Docs now describe all three delivery paths (piggyback, blocking wait, background watch): the skill and /setup teach arming `sideshow wait` as a background process and re-arming on exit instead of polling. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 +++ guide/AGENT_SETUP.md | 17 ++++-- guide/DESIGN_GUIDE.md | 22 ++++++-- mcp/server.ts | 11 ++-- server/app.ts | 68 ++++++++++++++++++++---- server/mcpHttp.ts | 40 ++++++++++---- server/storage.ts | 14 ++++- server/types.ts | 5 ++ skills/sideshow/SKILL.md | 27 +++++++--- test/api.test.ts | 104 +++++++++++++++++++++++++++++++++++++ test/jsonFileStore.test.ts | 3 ++ test/storeContract.ts | 17 ++++++ workers/sqlStore.ts | 22 +++++++- 13 files changed, 314 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24be08a..7a1efe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All notable user-visible changes to this project are documented in this file. - A session thread at the bottom of each session in the viewer: a composer for messaging the agent without picking a snippet. +- Feedback now reaches agents without polling: publish/update/reply responses + carry a `userFeedback` array with any comments the user left since the + agent's last call (delivered once; a consumed `wait` also counts as seen). +- The design guide, setup block, and Claude Code skill teach the background + watch pattern: arm `sideshow wait` as a background process after publishing + and react when it exits, instead of blocking or polling. ### Changed diff --git a/guide/AGENT_SETUP.md b/guide/AGENT_SETUP.md index b2973ec..a0b5fa1 100644 --- a/guide/AGENT_SETUP.md +++ b/guide/AGENT_SETUP.md @@ -21,11 +21,20 @@ snippet instead of posting a new one: curl -s -X PUT http://localhost:4242/api/snippets/ \ -H 'content-type: application/json' -d '{"html": "..."}' -The user can comment on your snippets in their browser. Check for feedback -(blocks up to 60s, returns JSON; use `after` from the previous response's -`lastSeq` to avoid re-reading): +The user can comment on your snippets in their browser. Feedback reaches you +two ways: - curl -s 'http://localhost:4242/api/comments?session=&author=user&after=&wait=60' +1. Publish/update responses may include a `userFeedback` array — comments the + user left since your last call. Treat them as messages from the user; they + are delivered once. +2. To explicitly wait for a reaction (blocks up to 60s, returns JSON; use + `after` from the previous response's `lastSeq` to avoid re-reading): + + curl -s 'http://localhost:4242/api/comments?session=&author=user&after=&wait=60' + + If you can run background processes, run this in the background after your + first publish and keep working — it exits the moment the user comments; + handle the output and re-arm it. If the `sideshow` CLI is installed, these are equivalent and easier: `sideshow publish file.html --title "..."`, `sideshow wait`, `sideshow guide` diff --git a/guide/DESIGN_GUIDE.md b/guide/DESIGN_GUIDE.md index 4bb9fa4..5089dd8 100644 --- a/guide/DESIGN_GUIDE.md +++ b/guide/DESIGN_GUIDE.md @@ -23,11 +23,23 @@ are kept and the user can flip between them. ## The feedback loop -The user can type comments under any snippet. Poll or block on -`wait_for_feedback` (MCP), `sideshow wait` (CLI), or the long-poll endpoint -after publishing something that needs a reaction. You can answer in the thread -with `reply_to_user` / `sideshow comment` — keep replies short; do substantial -revisions as snippet updates instead. +The user can type comments under any snippet, or in the session thread at the +bottom of the stream. Feedback reaches you three ways: + +- **Piggyback (automatic).** Every publish/update/reply response may include a + `userFeedback` array — comments the user left since your last call. Treat + them as messages from the user; they are delivered once. You never need to + poll while you are actively publishing. +- **Blocking wait.** `wait_for_feedback` (MCP), `sideshow wait` (CLI), or the + long-poll endpoint — use at a checkpoint when you explicitly want a reaction + before continuing. +- **Background watch.** If your harness supports background processes, arm + `sideshow wait --timeout 600` in the background after your first publish and + keep working; when it exits with comments, handle them and re-arm. Always arm + it on the session you actually published to. + +You can answer in the thread with `reply_to_user` / `sideshow comment` — keep +replies short; do substantial revisions as snippet updates instead. ## HTML contract diff --git a/mcp/server.ts b/mcp/server.ts index 1f2d661..134c2ba 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -58,7 +58,8 @@ const server = new McpServer( "concepts, sketch UI ideas, or visualize data while you work. Call get_design_guide once before your first " + "publish — it defines the HTML contract. Your snippets are grouped into one session for this conversation. " + "The user can comment on snippets in their browser; check with wait_for_feedback after publishing something " + - "you want a reaction to.", + "you want a reaction to. Any publish/update/reply result may also carry a userFeedback array — comments " + + "the user left since your last call. Treat them as messages from the user; they are delivered once.", }, ); @@ -67,8 +68,9 @@ server.registerTool( { description: "Publish an HTML snippet to the user's sideshow surface. Send a body fragment only (no " + - "doctype/html/head/body). Returns the snippet id and view URL. Call get_design_guide first if you have " + - "not this session.", + "doctype/html/head/body). Returns the snippet id and view URL. If the result includes userFeedback, " + + "those are new comments from the user — read them. Call get_design_guide first if you have not this " + + "session.", inputSchema: { title: z.string().describe("Short human-readable title shown above the snippet"), html: z.string().describe("HTML body fragment to render"), @@ -90,7 +92,8 @@ server.registerTool( "update_snippet", { description: - "Revise an existing snippet in place (same card, new version). Prefer this over publishing a near-duplicate.", + "Revise an existing snippet in place (same card, new version). Prefer this over publishing a " + + "near-duplicate. If the result includes userFeedback, those are new comments from the user — read them.", inputSchema: { id: z.string().describe("Snippet id returned by publish_snippet"), html: z.string().optional().describe("Replacement HTML body fragment"), diff --git a/server/app.ts b/server/app.ts index 87f287a..d4fecad 100644 --- a/server/app.ts +++ b/server/app.ts @@ -39,19 +39,44 @@ export interface CommentWait { waitSeconds: number; } +// Lean comment shape attached to agent-facing responses. +export const feedbackView = (c: Comment) => ({ + snippetId: c.snippetId, + snippetTitle: c.snippetTitle, + text: c.text, + at: c.createdAt, +}); + +export type Feedback = ReturnType; + export function createApp({ store, viewerHtml, guideMarkdown, setupText, authToken }: AppOptions) { const app = new Hono(); const bus = new EventBus(); // --- shared flows (used by both the REST API and the MCP endpoint) --- + // User comments the agent has not seen yet ride along on its next write, so + // agents hear feedback without blocking on the long-poll. The cursor also + // advances past the agent's own comments to keep reads cheap. + async function collectFeedback(sessionId: string): Promise { + const session = await store.getSession(sessionId); + if (!session) return undefined; + const fresh = await store.listComments({ sessionId, afterSeq: session.agentSeq }); + if (fresh.length === 0) return undefined; + await store.markAgentSeen(sessionId, fresh[fresh.length - 1].seq); + const feedback = fresh.filter((cm) => cm.author === "user"); + return feedback.length > 0 ? feedback.map(feedbackView) : undefined; + } + async function publishSnippet(input: { html: string; title?: string; session?: string; agent?: string; cwd?: string; - }): Promise<{ snippet: Snippet } | { error: string; status: 404 | 413 }> { + }): Promise< + { snippet: Snippet; userFeedback?: Feedback[] } | { error: string; status: 404 | 413 } + > { if (input.html.length > MAX_HTML_BYTES) { return { error: `html exceeds ${MAX_HTML_BYTES} bytes`, status: 413 }; } @@ -71,13 +96,15 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok }); if (!snippet) return { error: "session not found", status: 404 }; bus.broadcast({ type: "snippet-created", id: snippet.id, sessionId, version: 1 }); - return { snippet }; + return { snippet, userFeedback: await collectFeedback(sessionId) }; } async function reviseSnippet( id: string, patch: { html?: string; title?: string }, - ): Promise<{ snippet: Snippet } | { error: string; status: 404 | 413 }> { + ): Promise< + { snippet: Snippet; userFeedback?: Feedback[] } | { error: string; status: 404 | 413 } + > { if (typeof patch.html === "string" && patch.html.length > MAX_HTML_BYTES) { return { error: `html exceeds ${MAX_HTML_BYTES} bytes`, status: 413 }; } @@ -89,7 +116,7 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok sessionId: snippet.sessionId, version: snippet.version, }); - return { snippet }; + return { snippet, userFeedback: await collectFeedback(snippet.sessionId) }; } async function createComment(input: { @@ -97,7 +124,9 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok snippet?: string; session?: string; author: string; - }): Promise<{ comment: Comment } | { error: string; status: 400 | 404 }> { + }): Promise< + { comment: Comment; userFeedback?: Feedback[] } | { error: string; status: 400 | 404 } + > { let sessionId = input.session; if (input.snippet) { const snippet = await store.getSnippet(input.snippet); @@ -119,7 +148,11 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok snippetId: comment.snippetId, seq: comment.seq, }); - return { comment }; + // agent replies are writes too — piggyback pending feedback on them, but + // never on the user's own comments + const userFeedback = + input.author === "user" ? undefined : await collectFeedback(comment.sessionId); + return { comment, userFeedback }; } // Long-poll: resolves as soon as a matching comment lands, or at timeout. @@ -150,6 +183,11 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok comments = matches(await store.listComments(query)); } const lastSeq = comments.length > 0 ? comments[comments.length - 1].seq : (q.afterSeq ?? 0); + // An author=user query is the agent listening (the viewer never filters by + // author) — what it receives here should not be re-delivered as piggyback. + if (q.author === "user" && q.sessionId && comments.length > 0) { + await store.markAgentSeen(q.sessionId, lastSeq); + } return { comments, lastSeq }; } @@ -257,7 +295,13 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok cwd: typeof body.cwd === "string" ? body.cwd : undefined, }); if ("error" in result) return c.json({ error: result.error }, result.status); - return c.json(snippetMeta(result.snippet), 201); + return c.json( + { + ...snippetMeta(result.snippet), + ...(result.userFeedback && { userFeedback: result.userFeedback }), + }, + 201, + ); }); app.put("/api/snippets/:id", async (c) => { @@ -268,7 +312,10 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok title: typeof body.title === "string" ? body.title : undefined, }); if ("error" in result) return c.json({ error: result.error }, result.status); - return c.json(snippetMeta(result.snippet)); + return c.json({ + ...snippetMeta(result.snippet), + ...(result.userFeedback && { userFeedback: result.userFeedback }), + }); }); app.delete("/api/snippets/:id", async (c) => { @@ -293,7 +340,10 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok author: typeof body.author === "string" ? body.author : "user", }); if ("error" in result) return c.json({ error: result.error }, result.status); - return c.json(result.comment, 201); + return c.json( + { ...result.comment, ...(result.userFeedback && { userFeedback: result.userFeedback }) }, + 201, + ); }); // Long-poll friendly: ?wait=N holds the request open up to N seconds until diff --git a/server/mcpHttp.ts b/server/mcpHttp.ts index 34dafc6..7701319 100644 --- a/server/mcpHttp.ts +++ b/server/mcpHttp.ts @@ -1,5 +1,5 @@ import type { Hono } from "hono"; -import type { CommentWait } from "./app.ts"; +import type { CommentWait, Feedback } from "./app.ts"; import type { Comment, Snippet, Store } from "./types.ts"; // Stateless MCP over streamable HTTP: every request is self-contained, which @@ -13,16 +13,16 @@ export interface McpDeps { title?: string; session?: string; agent?: string; - }): Promise<{ snippet: Snippet } | { error: string; status: number }>; + }): Promise<{ snippet: Snippet; userFeedback?: Feedback[] } | { error: string; status: number }>; reviseSnippet( id: string, patch: { html?: string; title?: string }, - ): Promise<{ snippet: Snippet } | { error: string; status: number }>; + ): Promise<{ snippet: Snippet; userFeedback?: Feedback[] } | { error: string; status: number }>; createComment(input: { text: string; snippet?: string; author: string; - }): Promise<{ comment: Comment } | { error: string; status: number }>; + }): Promise<{ comment: Comment; userFeedback?: Feedback[] } | { error: string; status: number }>; waitForComments(q: CommentWait): Promise<{ comments: Comment[]; lastSeq: number }>; guide: string; } @@ -33,7 +33,9 @@ const INSTRUCTIONS = "publish — it defines the HTML contract. Your first publish_snippet creates a session and returns its " + "sessionId: pass it as `session` on every later call so your snippets stay grouped. The user can comment on " + "snippets in their browser; call wait_for_feedback (passing the lastSeq cursor from the previous result) " + - "after publishing something you want a reaction to."; + "after publishing something you want a reaction to. Any publish/update/reply result may also carry a " + + "userFeedback array — comments the user left since your last call. Treat them as messages from the user; " + + "they are delivered once."; const TOOLS = [ { @@ -41,7 +43,8 @@ const TOOLS = [ description: "Publish an HTML snippet to the user's sideshow surface. Send a body fragment only (no " + "doctype/html/head/body). Returns the snippet id, view URL, and sessionId — pass sessionId as `session` " + - "on later calls. Call get_design_guide first if you have not this session.", + "on later calls. If the result includes userFeedback, those are new comments from the user — read them. " + + "Call get_design_guide first if you have not this session.", inputSchema: { type: "object", properties: { @@ -66,7 +69,8 @@ const TOOLS = [ { name: "update_snippet", description: - "Revise an existing snippet in place (same card, new version). Prefer this over publishing a near-duplicate.", + "Revise an existing snippet in place (same card, new version). Prefer this over publishing a " + + "near-duplicate. If the result includes userFeedback, those are new comments from the user — read them.", inputSchema: { type: "object", properties: { @@ -146,7 +150,13 @@ export function registerMcp(app: Hono, deps: McpDeps) { if ("error" in result) throw new Error(result.error); const s = result.snippet; return JSON.stringify( - { id: s.id, sessionId: s.sessionId, version: s.version, url: `${origin}/s/${s.id}` }, + { + id: s.id, + sessionId: s.sessionId, + version: s.version, + url: `${origin}/s/${s.id}`, + ...(result.userFeedback && { userFeedback: result.userFeedback }), + }, null, 2, ); @@ -159,7 +169,13 @@ export function registerMcp(app: Hono, deps: McpDeps) { if ("error" in result) throw new Error(result.error); const s = result.snippet; return JSON.stringify( - { id: s.id, sessionId: s.sessionId, version: s.version, url: `${origin}/s/${s.id}` }, + { + id: s.id, + sessionId: s.sessionId, + version: s.version, + url: `${origin}/s/${s.id}`, + ...(result.userFeedback && { userFeedback: result.userFeedback }), + }, null, 2, ); @@ -199,7 +215,11 @@ export function registerMcp(app: Hono, deps: McpDeps) { author: typeof args.author === "string" ? args.author : "agent", }); if ("error" in result) throw new Error(result.error); - return JSON.stringify(result.comment, null, 2); + return JSON.stringify( + { ...result.comment, ...(result.userFeedback && { userFeedback: result.userFeedback }) }, + null, + 2, + ); } case "list_snippets": { const snippets = await deps.store.listSnippets( diff --git a/server/storage.ts b/server/storage.ts index 12ef896..08b6700 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -42,7 +42,10 @@ export class JsonFileStore implements Store { try { const raw = await readFile(this.filePath, "utf8"); const data = JSON.parse(raw) as FileShape; - for (const s of data.sessions ?? []) this.sessions.set(s.id, s); + // agentSeq arrived after 0.2.0 — default it for data files written before + for (const s of data.sessions ?? []) { + this.sessions.set(s.id, { ...s, agentSeq: s.agentSeq ?? 0 }); + } for (const s of data.snippets ?? []) this.snippets.set(s.id, s); this.comments = data.comments ?? []; this.lastSeq = data.lastSeq ?? 0; @@ -93,6 +96,7 @@ export class JsonFileStore implements Store { cwd: input.cwd ?? null, createdAt: now, lastActiveAt: now, + agentSeq: 0, }; this.sessions.set(session.id, session); await this.persist(); @@ -124,6 +128,14 @@ export class JsonFileStore implements Store { if (session) session.lastActiveAt = new Date().toISOString(); } + async markAgentSeen(sessionId: string, seq: number) { + await this.load(); + const session = this.sessions.get(sessionId); + if (!session || seq <= session.agentSeq) return; + session.agentSeq = seq; + await this.persist(); + } + // --- snippets --- async listSnippets(sessionId?: string) { diff --git a/server/types.ts b/server/types.ts index 4045bff..28f5836 100644 --- a/server/types.ts +++ b/server/types.ts @@ -7,6 +7,9 @@ export interface Session { cwd: string | null; createdAt: string; lastActiveAt: string; + // Highest comment seq already delivered to the agent — lets responses to + // agent writes piggyback comments the agent has not seen yet. + agentSeq: number; } export interface SnippetVersion { @@ -76,6 +79,8 @@ export interface Store { createSession(input: CreateSessionInput): Promise; renameSession(id: string, title: string): Promise; removeSession(id: string): Promise; + // Advance the delivered-to-agent comment cursor (never moves backwards). + markAgentSeen(sessionId: string, seq: number): Promise; listSnippets(sessionId?: string): Promise; getSnippet(id: string): Promise; diff --git a/skills/sideshow/SKILL.md b/skills/sideshow/SKILL.md index 83a5585..6f2f167 100644 --- a/skills/sideshow/SKILL.md +++ b/skills/sideshow/SKILL.md @@ -43,15 +43,28 @@ Rules of thumb: ## The feedback loop -After publishing something that needs a reaction: +Feedback reaches you three ways — prefer them in this order: -```sh -sideshow wait --timeout 120 # blocks until the user comments, prints JSON -``` +1. **Piggyback (no action needed).** Publish/update/reply responses may + include a `userFeedback` array: comments the user left since your last + call, delivered once. Read them whenever they appear and treat them as + user instructions. +2. **Background watch (don't block, don't poll).** After your first publish, + arm a listener as a background process and keep working: + + ```sh + sideshow wait --timeout 600 # run in the background (e.g. run_in_background) + ``` + + It exits the moment the user comments, which surfaces the output to you. + Handle the comments, then re-arm it. Always arm it on the session you just + published to — never a guessed one. + +3. **Blocking wait.** Only when you explicitly need a reaction before + continuing: `sideshow wait --timeout 120` in the foreground. -Treat returned comments as user instructions. Acknowledge briefly with -`sideshow comment "..." --snippet ` when useful; do substantial changes -as snippet updates. +Acknowledge briefly with `sideshow comment "..." --snippet ` when useful; +do substantial changes as snippet updates. ## Remote surfaces diff --git a/test/api.test.ts b/test/api.test.ts index 77241bd..047f651 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -257,6 +257,110 @@ test("mcp endpoint requires bearer when token configured", async () => { assert.equal(ok.status, 200); }); +test("agent writes piggyback unseen user comments, delivered once", async () => { + const app = makeApp(); + const s = (await ( + await app.request("/api/snippets", json({ html: "

v1

", title: "Doc" })) + ).json()) as any; + assert.equal(s.userFeedback, undefined); + + // the user comments while the agent works on something else + await app.request("/api/comments", json({ snippet: s.id, text: "wrong color", author: "user" })); + await app.request("/api/comments", json({ session: s.sessionId, text: "also add a key" })); + + // the agent's next write carries the feedback + const updated = (await ( + await app.request(`/api/snippets/${s.id}`, { ...json({ html: "

v2

" }), method: "PUT" }) + ).json()) as any; + assert.deepEqual( + updated.userFeedback.map((f: any) => f.text), + ["wrong color", "also add a key"], + ); + assert.equal(updated.userFeedback[0].snippetTitle, "Doc"); + + // delivered once — the next write is clean + const again = (await ( + await app.request(`/api/snippets/${s.id}`, { ...json({ html: "

v3

" }), method: "PUT" }) + ).json()) as any; + assert.equal(again.userFeedback, undefined); + + // agent replies piggyback too; the user's own comments never do + await app.request("/api/comments", json({ snippet: s.id, text: "more", author: "user" })); + const userPost = (await ( + await app.request("/api/comments", json({ snippet: s.id, text: "and more", author: "user" })) + ).json()) as any; + assert.equal(userPost.userFeedback, undefined); + const reply = (await ( + await app.request("/api/comments", json({ snippet: s.id, text: "on it", author: "claude" })) + ).json()) as any; + assert.deepEqual( + reply.userFeedback.map((f: any) => f.text), + ["more", "and more"], + ); +}); + +test("a consumed wait is not re-delivered as piggyback", async () => { + const app = makeApp(); + const s = (await (await app.request("/api/snippets", json({ html: "

x

" }))).json()) as any; + await app.request( + "/api/comments", + json({ snippet: s.id, text: "seen via wait", author: "user" }), + ); + + // the agent receives it through the long-poll... + const waited = (await ( + await app.request(`/api/comments?session=${s.sessionId}&author=user`) + ).json()) as any; + assert.equal(waited.comments.length, 1); + + // ...so the next write carries nothing + const updated = (await ( + await app.request(`/api/snippets/${s.id}`, { ...json({ html: "

v2

" }), method: "PUT" }) + ).json()) as any; + assert.equal(updated.userFeedback, undefined); + + // the viewer's unfiltered reads do NOT consume the cursor + await app.request("/api/comments", json({ snippet: s.id, text: "fresh", author: "user" })); + await app.request(`/api/comments?session=${s.sessionId}`); // viewer-style read + const next = (await ( + await app.request(`/api/snippets/${s.id}`, { ...json({ html: "

v3

" }), method: "PUT" }) + ).json()) as any; + assert.deepEqual( + next.userFeedback.map((f: any) => f.text), + ["fresh"], + ); +}); + +test("mcp publish result carries userFeedback", async () => { + const app = makeApp(); + const published = (await ( + await app.request( + "/mcp", + mcpCall(1, "tools/call", { + name: "publish_snippet", + arguments: { title: "One", html: "

1

", agent: "mcp-agent" }, + }), + ) + ).json()) as any; + const first = JSON.parse(published.result.content[0].text); + await app.request("/api/comments", json({ snippet: first.id, text: "neat", author: "user" })); + + const second = (await ( + await app.request( + "/mcp", + mcpCall(2, "tools/call", { + name: "publish_snippet", + arguments: { title: "Two", html: "

2

", session: first.sessionId }, + }), + ) + ).json()) as any; + const payload = JSON.parse(second.result.content[0].text); + assert.deepEqual( + payload.userFeedback.map((f: any) => f.text), + ["neat"], + ); +}); + test("rejects empty and oversized html", async () => { const app = makeApp(); assert.equal((await app.request("/api/snippets", json({ html: "" }))).status, 400); diff --git a/test/jsonFileStore.test.ts b/test/jsonFileStore.test.ts index ccc74df..5c24e62 100644 --- a/test/jsonFileStore.test.ts +++ b/test/jsonFileStore.test.ts @@ -23,8 +23,11 @@ test("JsonFileStore: data survives a reload from disk", async () => { text: "hi", }); + await store.markAgentSeen(session.id, 1); + const reloaded = new JsonFileStore(path); assert.equal((await reloaded.getSession(session.id))?.title, "Persisted"); + assert.equal((await reloaded.getSession(session.id))?.agentSeq, 1); const got = await reloaded.getSnippet(snippet?.id ?? ""); assert.equal(got?.version, 2); assert.equal(got?.history.length, 1); diff --git a/test/storeContract.ts b/test/storeContract.ts index 16550ab..a22cd4c 100644 --- a/test/storeContract.ts +++ b/test/storeContract.ts @@ -67,6 +67,23 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< ); }); + contract("tracks the delivered-to-agent comment cursor", async (store) => { + const session = await store.createSession({ agent: "pi" }); + assert.equal(session.agentSeq, 0); + + await store.markAgentSeen(session.id, 5); + assert.equal((await store.getSession(session.id))?.agentSeq, 5); + + // never moves backwards + await store.markAgentSeen(session.id, 3); + assert.equal((await store.getSession(session.id))?.agentSeq, 5); + await store.markAgentSeen(session.id, 9); + assert.equal((await store.getSession(session.id))?.agentSeq, 9); + + // unknown session is a no-op, not an error + await store.markAgentSeen("missing", 1); + }); + contract("removeSession returns false for unknown ids", async (store) => { assert.equal(await store.removeSession("missing"), false); const session = await store.createSession({ agent: "pi" }); diff --git a/workers/sqlStore.ts b/workers/sqlStore.ts index 79d439e..2aa699f 100644 --- a/workers/sqlStore.ts +++ b/workers/sqlStore.ts @@ -22,7 +22,8 @@ export class SqlStore implements Store { this.sql.exec(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, agent TEXT NOT NULL, title TEXT, cwd TEXT, - createdAt TEXT NOT NULL, lastActiveAt TEXT NOT NULL + createdAt TEXT NOT NULL, lastActiveAt TEXT NOT NULL, + agentSeq INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS snippets ( id TEXT PRIMARY KEY, sessionId TEXT NOT NULL, title TEXT NOT NULL, @@ -35,6 +36,12 @@ export class SqlStore implements Store { author TEXT NOT NULL, text TEXT NOT NULL, createdAt TEXT NOT NULL ); `); + // Boards created before agentSeq existed need the column added; SQLite + // has no ADD COLUMN IF NOT EXISTS, so probe and patch. + const cols = this.sql.exec("SELECT name FROM pragma_table_info('sessions')").toArray(); + if (!cols.some((c) => c.name === "agentSeq")) { + this.sql.exec("ALTER TABLE sessions ADD COLUMN agentSeq INTEGER NOT NULL DEFAULT 0"); + } } private rowToSession(r: Record): Session { @@ -45,6 +52,7 @@ export class SqlStore implements Store { cwd: (r.cwd as string) ?? null, createdAt: r.createdAt as string, lastActiveAt: r.lastActiveAt as string, + agentSeq: (r.agentSeq as number) ?? 0, }; } @@ -97,9 +105,10 @@ export class SqlStore implements Store { cwd: input.cwd ?? null, createdAt: now, lastActiveAt: now, + agentSeq: 0, }; this.sql.exec( - "INSERT INTO sessions (id, agent, title, cwd, createdAt, lastActiveAt) VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO sessions (id, agent, title, cwd, createdAt, lastActiveAt, agentSeq) VALUES (?, ?, ?, ?, ?, ?, 0)", session.id, session.agent, session.title, @@ -134,6 +143,15 @@ export class SqlStore implements Store { ); } + async markAgentSeen(sessionId: string, seq: number) { + this.sql.exec( + "UPDATE sessions SET agentSeq = ? WHERE id = ? AND agentSeq < ?", + seq, + sessionId, + seq, + ); + } + // --- snippets --- async listSnippets(sessionId?: string) {