Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/calm-dogs-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Restore Claude plugin monitor startup on current Claude Code versions, and clarify MCP and CLI feedback fallbacks when Monitor support is unavailable.
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
{
"name": "sideshow",
"source": "./plugin",
"description": "Stream sideshow browser comments to Claude Code as notifications; publish snippets over MCP.",
"description": "Stream sideshow browser comments to Claude Code as notifications; publish posts over MCP.",
"category": "Integrations",
"keywords": ["sideshow", "visualization", "feedback", "mcp"]
}
Expand Down
19 changes: 12 additions & 7 deletions docs/connecting-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,20 +74,25 @@ cp -r skills/sideshow ~/.claude/skills/
```

**Plugin.** A plugin bundles all three integrations 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:
skill, and a **background monitor**. In an interactive Claude Code session with
Monitor support, it streams 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:8228`, or
your deployed instance) and an optional token. The monitor runs `sideshow watch`
against your workspace; comments are delivered to the agent exactly once. Requires
Claude Code ≥ 2.1.105. The viewer's "connect agent" link (sidebar footer) shows
generic MCP client setup; the Claude Code plugin lives in [`../plugin/`](../plugin/).
your deployed instance) and an optional token. A SessionStart hook stores those
monitor settings in the plugin's private data directory; the monitor then runs
`sideshow watch` against your workspace. Start a new Claude Code session after
installing or updating the plugin so its monitor uses the configured settings;
monitors require a restart to pick up updates. It is independent of MCP, and
comments are delivered exactly once. MCP and CLI checkpoint drains remain
available when Monitor support is unavailable. The sidebar footer's "connect
agent" link shows generic MCP client setup; the Claude Code plugin lives in
[`../plugin/`](../plugin/).

## The design contract

Expand Down
30 changes: 16 additions & 14 deletions docs/plans/claude-code-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,12 @@ monitors are an `experimental.monitors` feature and may shift.
- Fields: `name` (req), `command` (req, shell command run as a persistent bg process in the
session working dir), `description` (req), `when` (opt: `"always"` default, or
`"on-skill-invoke:<skill>"`).
- Command supports `${CLAUDE_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_DATA}`, `${CLAUDE_PROJECT_DIR}`,
`${user_config.*}`, `${ENV_VAR}`.
- Command supports `${CLAUDE_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_DATA}`,
`${CLAUDE_PROJECT_DIR}`, `${CLAUDE_CODE_SESSION_ID}`, and `${ENV_VAR}`. Support for
`${user_config.*}` interpolation existed before Claude Code 2.1.207; current versions
reject it in shell-form monitor commands. Sideshow passes only non-secret plugin paths
and the session ID there, then reads configuration from a plugin-owned file written at
SessionStart.
- **Each stdout line → one notification to Claude**, delivered on the next turn (no batching
/ no documented rate limit). Runs for the session lifetime; stops when the session ends;
disabling the plugin mid-session does NOT stop an already-running monitor.
Expand Down Expand Up @@ -128,11 +132,13 @@ https://host/marketplace.json`) BUT relative plugin sources won't work there —
`author=user` agent cursor (`waitForComments` → `markAgentSeen`).
2. ✅ DONE — Plugin package in `plugin/`: `.claude-plugin/plugin.json` (name
`sideshow`, `userConfig` for `sideshowUrl`/`apiToken`, inline `mcpServers`
running `npx sideshow@latest mcp`, `experimental.monitors` → `./monitors.json`,
`skills` → `./skills/`). `monitors.json` runs `sideshow watch` with the config
piped in via `SIDESHOW_URL`/`SIDESHOW_TOKEN`. Plugin skill at
running `npx -y sideshow mcp`, `experimental.monitors` → `./monitors.json`,
`skills` → `./skills/`). A SessionStart hook writes user options to the private
plugin data directory, and `monitors.json` launches a bundled helper that passes
`SIDESHOW_URL`/`SIDESHOW_TOKEN` to `sideshow watch` through its child environment.
Plugin skill at
`plugin/skills/sideshow/SKILL.md` teaches the notification workflow. Validated
with `claude plugin validate ./plugin` on Claude Code 2.1.177 (✔ passed).
with `claude plugin validate ./plugin --strict` on Claude Code 2.1.233 (✔ passed).
3. ✅ DONE — Repo-hosted marketplace at `.claude-plugin/marketplace.json` (name
`sideshow`, plugin source relative `./plugin` — works for git-hosted
marketplaces). Validated ✔. Docs in `README.md` ("Claude Code plugin"
Expand All @@ -159,9 +165,8 @@ https://host/marketplace.json`) BUT relative plugin sources won't work there —
the viewer modal and README as an explicit caveat. Re-verify the manifest
contract on each Claude Code bump.
- **Don't double-run.** The plugin skill steers the agent to rely on the monitor
rather than arming a separate `sideshow wait` loop. NOTE: `watch` is unreleased
on npm — the plugin's `npx sideshow@latest watch` only works once a release
including `watch` ships.
rather than arming a separate `sideshow wait` loop. The helper runs
`npx -y sideshow watch`, resolving the installed package from npm.

## Key code references

Expand All @@ -181,12 +186,9 @@ https://host/marketplace.json`) BUT relative plugin sources won't work there —
All four phases are implemented on `feat/comment-and-copy` (PR #16). Remaining
before this is usable end-to-end:

1. **Publish a sideshow release that includes `sideshow watch`** — the plugin's
`npx sideshow@latest watch`/`mcp` resolve to the published package, and
`watch` is currently unreleased.
2. **Live smoke test** with a real Claude Code session: `/plugin marketplace add`
1. **Live smoke test** with a real Claude Code session: `/plugin marketplace add`
the branch/repo, install, publish a snippet, comment in the browser, and
confirm the comment arrives as a notification (verifies the monitor's spawn
tree resolves the session — `resolveSessionByCwd` is the safety net).
3. Consider pinning the marketplace plugin `source` to a tagged `ref`/`sha`
2. Consider pinning the marketplace plugin `source` to a tagged `ref`/`sha`
once released, instead of tracking `main`.
4 changes: 2 additions & 2 deletions plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "sideshow",
"displayName": "sideshow",
"version": "0.1.0",
"description": "Stream sideshow browser comments to Claude Code as notifications, and publish snippets over MCP.",
"version": "0.1.1",
"description": "Stream sideshow browser comments to Claude Code as notifications, and publish posts over MCP.",
"author": { "name": "modem", "email": "ben@modem.dev" },
"homepage": "https://github.com/modem-dev/sideshow",
"repository": "https://github.com/modem-dev/sideshow",
Expand Down
30 changes: 30 additions & 0 deletions plugin/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"description": "Securely prepares sideshow monitor configuration at session start.",
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|fork",
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/write-config.mjs"],
"timeout": 5
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/remove-config.mjs"],
"timeout": 5
}
]
}
]
}
}
2 changes: 1 addition & 1 deletion plugin/monitors.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
{
"name": "sideshow-comments",
"description": "Streams new sideshow comments from your browser to Claude as they are posted.",
"command": "SIDESHOW_URL=\"${user_config.sideshowUrl}\" SIDESHOW_TOKEN=\"${user_config.apiToken}\" npx -y sideshow watch",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/run-monitor.mjs\" \"${CLAUDE_PLUGIN_DATA}\" \"${CLAUDE_CODE_SESSION_ID}\"",
"when": "always"
}
]
9 changes: 9 additions & 0 deletions plugin/scripts/monitor-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createHash } from "node:crypto";

const FALLBACK_CONFIG_FILE = "monitor-config.json";

export function monitorConfigFile(sessionId) {
if (!sessionId) return FALLBACK_CONFIG_FILE;
const digest = createHash("sha256").update(sessionId).digest("hex");
return `monitor-config-${digest}.json`;
}
17 changes: 17 additions & 0 deletions plugin/scripts/remove-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { rm } from "node:fs/promises";
import { join } from "node:path";

import { monitorConfigFile } from "./monitor-config.mjs";

export async function removeMonitorConfig(env = process.env) {
const dataDir = env.CLAUDE_PLUGIN_DATA;
if (!dataDir) return;
await rm(join(dataDir, monitorConfigFile(env.CLAUDE_CODE_SESSION_ID)), { force: true });
}

try {
await removeMonitorConfig();
} catch (error) {
console.error(`sideshow plugin configuration cleanup failed: ${error.message}`);
process.exitCode = 1;
}
130 changes: 130 additions & 0 deletions plugin/scripts/run-monitor.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { spawn, spawnSync } from "node:child_process";
import { readFile, rm } from "node:fs/promises";
import { join } from "node:path";

import { monitorConfigFile } from "./monitor-config.mjs";

const CONFIG_WAIT_MS = 1000;
const RETRY_MS = 25;

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

async function readConfig(dataDir, sessionId) {
if (!dataDir) return null;

const configPath = join(dataDir, monitorConfigFile(sessionId));
const deadline = Date.now() + CONFIG_WAIT_MS;

do {
try {
const text = await readFile(configPath, "utf8");
await rm(configPath);
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" ? parsed : null;
} catch (error) {
if (error.code !== "ENOENT") throw error;
// The atomic SessionStart writer may not have run yet. Retry briefly,
// then preserve inherited SIDESHOW_* values and the CLI's localhost default.
}
await sleep(RETRY_MS);
} while (Date.now() < deadline);

return null;
}

const dataDir = process.argv[2] || process.env.CLAUDE_PLUGIN_DATA;
const sessionId = process.argv[3] || process.env.CLAUDE_CODE_SESSION_ID;
const childEnv = { ...process.env };
let config;
try {
config = await readConfig(dataDir, sessionId);
} catch (error) {
console.error(`sideshow monitor configuration failed: ${error.message}`);
process.exit(1);
}

if (config) {
if (typeof config.sideshowUrl === "string" && config.sideshowUrl) {
childEnv.SIDESHOW_URL = config.sideshowUrl;
}
if (typeof config.apiToken === "string" && config.apiToken) {
childEnv.SIDESHOW_TOKEN = config.apiToken;
} else {
delete childEnv.SIDESHOW_TOKEN;
}
}

const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "npx";
const args =
process.platform === "win32"
? ["/d", "/s", "/c", "npx -y sideshow watch"]
: ["-y", "sideshow", "watch"];
const child = spawn(executable, args, {
detached: process.platform !== "win32",
env: childEnv,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});

child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);

let shutdownSignal;
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];

function terminateChild(signal) {
if (child.exitCode !== null || child.signalCode !== null) return;
if (process.platform === "win32" && child.pid) {
// npx is behind cmd.exe on Windows. Terminate the complete process tree so
// the long-running watch process cannot outlive this monitor helper.
spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
stdio: "ignore",
windowsHide: true,
});
return;
}
if (child.pid) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// Fall back if the platform did not create the requested process group.
}
}
child.kill(signal);
}

for (const signal of signals) {
process.on(signal, () => {
if (shutdownSignal) return;
shutdownSignal = signal;
terminateChild(signal);
});
}

process.on("exit", () => terminateChild());

let spawnFailed = false;
child.on("error", (error) => {
spawnFailed = true;
console.error(`sideshow monitor failed to start: ${error.message}`);
process.exitCode = 1;
});

child.on("close", (code, signal) => {
if (spawnFailed) {
process.exitCode = 1;
return;
}
if (shutdownSignal || signal) {
const forwardedSignal = shutdownSignal ?? signal;
if (process.platform !== "win32" && forwardedSignal) {
for (const handledSignal of signals) process.removeAllListeners(handledSignal);
process.kill(process.pid, forwardedSignal);
return;
}
process.exitCode = 1;
return;
}
process.exitCode = code ?? 1;
});
60 changes: 60 additions & 0 deletions plugin/scripts/write-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { randomBytes } from "node:crypto";
import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";

import { monitorConfigFile } from "./monitor-config.mjs";

async function replaceFile(tempPath, configPath) {
try {
await rename(tempPath, configPath);
} catch (error) {
if (
process.platform !== "win32" ||
(error.code !== "EEXIST" && error.code !== "EPERM" && error.code !== "EACCES")
) {
throw error;
}

// Node does not expose Windows' atomic replace-file primitive. Keep the
// same-directory rename path everywhere, with this replacement fallback
// for an existing destination on Windows.
await rm(configPath, { force: true });
await rename(tempPath, configPath);
}
}

export async function writeMonitorConfig(env = process.env) {
const dataDir = env.CLAUDE_PLUGIN_DATA;
if (!dataDir) throw new Error("CLAUDE_PLUGIN_DATA is not set");

await mkdir(dataDir, { recursive: true, mode: 0o700 });
if (process.platform !== "win32") await chmod(dataDir, 0o700);

const configFile = monitorConfigFile(env.CLAUDE_CODE_SESSION_ID);
const configPath = join(dataDir, configFile);
const tempPath = join(dataDir, `.${configFile}.${process.pid}.${randomBytes(6).toString("hex")}`);
const config = {
sideshowUrl: env.CLAUDE_PLUGIN_OPTION_SIDESHOWURL ?? "",
apiToken: env.CLAUDE_PLUGIN_OPTION_APITOKEN ?? "",
};

try {
await writeFile(tempPath, `${JSON.stringify(config)}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o600,
});
await replaceFile(tempPath, configPath);
if (process.platform !== "win32") await chmod(configPath, 0o600);
} catch (error) {
await rm(tempPath, { force: true }).catch(() => {});
throw error;
}
}

try {
await writeMonitorConfig();
} catch (error) {
console.error(`sideshow plugin configuration failed: ${error.message}`);
process.exitCode = 1;
}
Loading
Loading