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
15 changes: 15 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/claude-code-marketplace-manifest.json",
"name": "sideshow",
"owner": { "name": "modem", "email": "ben@modem.dev" },
"description": "The sideshow Claude Code plugin.",
"plugins": [
{
"name": "sideshow",
"source": "./plugin",
"description": "Stream sideshow browser comments to Claude Code as notifications; publish snippets over MCP.",
"category": "Integrations",
"keywords": ["sideshow", "visualization", "feedback", "mcp"]
}
]
}
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,35 @@ All notable user-visible changes to this project are documented in this file.
dedupe to one blob, and an asset lives as long as any surface references it
(even across sessions), so a referenced upload is never lost to a session
delete. Capped at 5 MB each.
- A copy button on each posted comment puts an agent-ready paste block on the
clipboard (surface title + id + the comment), for handing a comment straight
to a terminal agent.
- `sideshow watch` streams user comments to stdout one per line, re-arming the
long-poll forever and waiting for the first publish if no session exists yet.
It rides the shared server-side agent cursor (exactly-once across watch,
wait, and piggyback), and falls back to resolving the most recently active
session matching the current directory when no local session state is shared.
- A Claude Code plugin (`plugin/`, published via a repo-hosted marketplace)
bundles the sideshow MCP server, the skill, and a background monitor that
runs `sideshow watch` — browser comments arrive in the agent as notifications
without pasting or re-arming a watcher. Install with
`/plugin marketplace add modem-dev/sideshow` then
`/plugin install sideshow@sideshow`.
- A "connect Claude Code" link in the viewer (sidebar footer and the onboarding
screen) opens an integrations panel with the plugin install commands, what
the monitor runs, and the honest caveats (two commands, not a one-click;
needs Claude Code ≥ 2.1.105).

### Changed

- The session sidebar now groups sessions by recency (Today / Yesterday /
Earlier) so the freshest work stays on top, and sessions with no surfaces yet
are dimmed and sunk to the bottom of their group — keeping a long history
scannable.
- The viewer is framed around leaving comments rather than messaging an agent:
composers read "Leave a comment…" with a "Comment" button. No delivery
receipts or "listening" indicators — a comment is an annotation the agent
picks up through the feedback loop, not a synchronous message.
- A surface card's open and delete actions are now minimal Lucide icons
(external-link and trash) instead of text labels; delete turns red on hover.
- `sideshow-term watch` now starts a local server in the background when needed,
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,25 @@ MCP agents get usage instructions automatically; everything else uses the
`/setup` block above. Claude Code users can also install the skill in
`skills/sideshow/` (`cp -r skills/sideshow ~/.claude/skills/`).

### Claude Code plugin

Claude Code users can install a plugin that bundles all three at once — the
MCP server, the skill, and a **background monitor** that streams your browser
comments to the agent as notifications, so feedback arrives without pasting or
re-arming a watcher:

```text
/plugin marketplace add modem-dev/sideshow
/plugin install sideshow@sideshow
```

On install it asks for your **Sideshow URL** (default `http://localhost:4242`,
or your deployed instance) and an optional token. The monitor runs
`sideshow watch` against your board; comments are delivered to the agent
exactly once. Requires Claude Code ≥ 2.1.105. The viewer's "connect Claude
Code" link (sidebar footer) shows the same steps. The plugin lives in
[`plugin/`](plugin/).

## Concepts

- **Session** — one agent conversation. Sessions appear in the viewer sidebar;
Expand Down
92 changes: 92 additions & 0 deletions bin/sideshow.js
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ usage:
--timeout <sec> max seconds to wait (default 120)
--after <seq> re-read comments after this cursor (default: where the
agent left off, tracked server-side across CLI/MCP)
sideshow watch [options] stream user comments forever, one per
line (re-arms the long-poll; for a
background monitor)
--session <id> session to watch (default: auto, waits for the first
publish to create one)
--after <seq> re-read comments after this cursor on the first poll
(default: resume where the agent left off, server-side)
sideshow comment <text> [options] post a reply comment
--surface <id> | --session <id> attach point (default: auto session)
--author <name> defaults to agent name
Expand Down Expand Up @@ -208,6 +215,29 @@ async function resolveSession(flags, { create = false } = {}) {
return session.id;
}

// A monitor process (e.g. the Claude Code plugin) may not share the local
// state file written by the agent's CLI calls — different spawn tree, so
// `agentPid()` can hash to a different key. Fall back to asking the server for
// the most recently active session whose cwd matches ours. Uses raw fetch (not
// `api()`) so a transient failure returns null instead of exiting the process.
async function resolveSessionByCwd() {
try {
const res = await fetch(`${BASE}/api/sessions`, {
headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {},
});
if (!res.ok) return null;
const sessions = await res.json();
const cwd = process.cwd();
return (
sessions
.filter((s) => s.cwd === cwd)
.sort((a, b) => String(b.lastActiveAt).localeCompare(String(a.lastActiveAt)))[0]?.id ?? null
);
} catch {
return null;
}
}

function readContent(arg) {
if (!arg || arg === "-") {
try {
Expand Down Expand Up @@ -296,6 +326,20 @@ async function publishSurface(parts, flags) {
});
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// One comment → one line (one monitor notification). Newlines are collapsed so
// a multi-line comment stays a single notification.
function watchLine(c) {
const text = String(c.text ?? "")
.replace(/\s+/g, " ")
.trim();
const where = c.surfaceId
? `on “${c.surfaceTitle ?? "a surface"}” (surface ${c.surfaceId})`
: "in the session thread";
return `sideshow comment ${where}: “${text}”`;
}

const [cmd, ...rest] = process.argv.slice(2);

// Subcommand flag parsing. parseArgs is strict, so without this --help (or
Expand Down Expand Up @@ -582,6 +626,54 @@ const commands = {
);
},

async watch() {
const { values: flags } = parse({
options: {
session: { type: "string" },
after: { type: "string" },
},
});
// A continuous long-poll that streams each new user comment as one line —
// one line is one Claude Code monitor notification. It re-arms forever and
// never exits on its own; a transient network error backs off and retries
// rather than failing (unlike `api()`, which would exit the process).
//
// After the first poll it carries no client cursor: reading with
// author=user resumes from the session's server-side agent cursor and
// advances it, so a comment is delivered exactly once across watch, wait,
// and piggyback. Honoring a local cursor here would re-deliver anything a
// piggybacked write had already consumed.
let firstAfter = flags.after;
for (;;) {
const session = (await resolveSession(flags)) ?? (await resolveSessionByCwd());
if (!session) {
// No session yet — the agent hasn't published. Wait and retry.
await sleep(2000);
continue;
}
let result;
try {
const afterParam = firstAfter === undefined ? "" : `&after=${firstAfter}`;
const res = await fetch(
`${BASE}/api/comments?session=${session}&author=user${afterParam}&wait=60`,
{ headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {} },
);
if (!res.ok) {
await sleep(2000);
continue;
}
result = await res.json();
} catch {
await sleep(2000);
continue;
}
firstAfter = undefined;
for (const c of result.comments ?? []) {
console.log(watchLine(c));
}
}
},

async comment() {
const { values: flags, positionals } = parse({
allowPositionals: true,
Expand Down
Loading
Loading