diff --git a/.changeset/calm-dogs-listen.md b/.changeset/calm-dogs-listen.md new file mode 100644 index 00000000..939384ab --- /dev/null +++ b/.changeset/calm-dogs-listen.md @@ -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. diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d3c9c8ba..b02e4526 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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"] } diff --git a/docs/connecting-agents.md b/docs/connecting-agents.md index 77fb0ae5..57d0a833 100644 --- a/docs/connecting-agents.md +++ b/docs/connecting-agents.md @@ -74,9 +74,9 @@ 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 @@ -84,10 +84,15 @@ watcher: ``` 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 diff --git a/docs/plans/claude-code-plugin.md b/docs/plans/claude-code-plugin.md index 2d641a1b..d223b24f 100644 --- a/docs/plans/claude-code-plugin.md +++ b/docs/plans/claude-code-plugin.md @@ -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:"`). -- 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. @@ -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" @@ -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 @@ -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`. diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 7837da6a..b5512c33 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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", diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json new file mode 100644 index 00000000..3d7832b9 --- /dev/null +++ b/plugin/hooks/hooks.json @@ -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 + } + ] + } + ] + } +} diff --git a/plugin/monitors.json b/plugin/monitors.json index 2a022337..4b68ab51 100644 --- a/plugin/monitors.json +++ b/plugin/monitors.json @@ -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" } ] diff --git a/plugin/scripts/monitor-config.mjs b/plugin/scripts/monitor-config.mjs new file mode 100644 index 00000000..09838c6c --- /dev/null +++ b/plugin/scripts/monitor-config.mjs @@ -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`; +} diff --git a/plugin/scripts/remove-config.mjs b/plugin/scripts/remove-config.mjs new file mode 100644 index 00000000..9ca6ddf5 --- /dev/null +++ b/plugin/scripts/remove-config.mjs @@ -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; +} diff --git a/plugin/scripts/run-monitor.mjs b/plugin/scripts/run-monitor.mjs new file mode 100644 index 00000000..fe855b23 --- /dev/null +++ b/plugin/scripts/run-monitor.mjs @@ -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; +}); diff --git a/plugin/scripts/write-config.mjs b/plugin/scripts/write-config.mjs new file mode 100644 index 00000000..9a1b6f13 --- /dev/null +++ b/plugin/scripts/write-config.mjs @@ -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; +} diff --git a/plugin/skills/sideshow/SKILL.md b/plugin/skills/sideshow/SKILL.md index 9fe2ae48..7034f058 100644 --- a/plugin/skills/sideshow/SKILL.md +++ b/plugin/skills/sideshow/SKILL.md @@ -1,31 +1,31 @@ --- name: sideshow -description: Publish live HTML previews to the user's sideshow surface and receive their comments back as notifications. Use when the user asks you to illustrate, visualize, sketch, or draw something, mentions sideshow, or when a visual would explain your work better than text. +description: Publish live visual posts to the user's sideshow workspace and receive their comments back as notifications. Use when the user asks you to illustrate, visualize, sketch, or draw something, mentions sideshow, or when a visual would explain your work better than text. --- # sideshow (plugin) -The user keeps a sideshow surface open in their browser. You publish HTML -snippets to it; they appear instantly. The user comments on any snippet, and -**this plugin streams those comments to you as notifications** — you do not -poll or arm a watcher. It is a two-way surface, not a fire-and-forget renderer. +The user keeps a sideshow workspace open in their browser. You publish posts to +it; they appear instantly and the user can comment on them. A post contains one +or more surfaces such as HTML, markdown, diff, terminal, image, mermaid, JSON, +or code. It is a two-way surface, not a fire-and-forget renderer. ## How feedback reaches you -A background monitor (`sideshow watch`) runs for the whole session and delivers -each new user comment as a notification on your next turn, for example: +When the full plugin is enabled in an interactive Claude Code session with +Monitor support, its background monitor (`sideshow watch`) streams each new user +comment as a notification on your next turn. The monitor is independent of MCP. +If it is unavailable, drain feedback explicitly at checkpoints: -``` -sideshow comment on “Cache layout” (snippet a1b2c3): “tighten the spacing” -``` +- MCP: call `wait_for_feedback` with `timeoutSeconds: 0`. +- CLI: run `sideshow wait --session --timeout 1`. + +Publish/update/reply responses may also carry a `userFeedback` array. Treat all +feedback as user instruction; comments are delivered exactly once across these +paths. -Treat every such line as a message from the user. Respond by revising the -snippet it refers to (`update_snippet` / `sideshow update `) or replying -(`reply_to_user` / `sideshow comment`). Comments are delivered exactly once — -you will not see the same one twice, so act on each when it arrives. You never -need to call `wait_for_feedback` just to stay aware of comments; the monitor -already does that. (Publish/update/reply responses may still carry a -`userFeedback` array; it is the same stream, also delivered once.) +`sideshow watch` never exits by design. Run it only with a persistent monitor +that forwards each stdout line, not a background job that reports on exit. ## Before your first publish @@ -37,7 +37,7 @@ variables, CDN allowlist, sizing): ## Publishing -Prefer the MCP tools when connected (`publish_snippet`, `update_snippet`, +Prefer the MCP tools when connected (`publish_post`, `update_post`, `wait_for_feedback`, `reply_to_user`); otherwise use the CLI. Session grouping is automatic. @@ -52,13 +52,13 @@ Rules of thumb: refactor"), not the tool — `sessionTitle` (MCP) / `--session-title` (CLI). It applies only when the session is created; never retitle later (the user may have renamed it in the viewer). -- One concept per snippet, with a clear title. A series of small snippets beats - one giant page. -- Iterate with `update_snippet` / `sideshow update ` (same card, new - version) instead of publishing near-duplicates. -- Use the kit from the design guide (pre-styled form elements, SVG utility - classes) before writing CSS; otherwise use the theme CSS variables so - snippets work in dark mode. +- Keep each post focused, and use multiple surfaces when they support one + concept. A series of focused posts beats one giant page. +- Iterate with `update_post` / `sideshow update ` (same card, new version) + instead of publishing near-duplicates. +- For HTML surfaces, use the kit from the design guide (pre-styled form + elements, SVG utility classes) before writing CSS; otherwise use the theme + CSS variables so content works in dark mode. ## Configuration diff --git a/test/plugin.test.ts b/test/plugin.test.ts new file mode 100644 index 00000000..aba0ceff --- /dev/null +++ b/test/plugin.test.ts @@ -0,0 +1,374 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const CONFIG_HELPER = join(ROOT, "plugin", "scripts", "write-config.mjs"); +const REMOVE_CONFIG_HELPER = join(ROOT, "plugin", "scripts", "remove-config.mjs"); +const MONITOR_HELPER = join(ROOT, "plugin", "scripts", "run-monitor.mjs"); + +function runNode(script: string, env: NodeJS.ProcessEnv, args: string[] = []) { + return new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => { + execFile(process.execPath, [script, ...args], { env }, (error, stdout, stderr) => { + resolve({ + code: error && typeof error.code === "number" ? error.code : error ? 1 : 0, + stdout, + stderr, + }); + }); + }); +} + +function cleanRoutingEnv() { + const env = { ...process.env }; + delete env.CLAUDE_PLUGIN_DATA; + delete env.CLAUDE_CODE_SESSION_ID; + delete env.SIDESHOW_URL; + delete env.SIDESHOW_TOKEN; + return env; +} + +function makeFakeNpx() { + const directory = mkdtempSync(join(tmpdir(), "sideshow-plugin-npx-")); + const fakeScript = join(directory, "fake-npx.cjs"); + writeFileSync( + fakeScript, + `const fs = require("node:fs"); +fs.writeFileSync(process.env.FAKE_NPX_LOG, JSON.stringify({ args: process.argv.slice(2), url: process.env.SIDESHOW_URL, token: process.env.SIDESHOW_TOKEN })); +process.stdout.write("comment one\\ncomment two\\n"); +process.stderr.write("watch diagnostic\\n"); +process.exitCode = Number(process.env.FAKE_NPX_EXIT_CODE || 0); +`, + ); + + const posixNpx = join(directory, "npx"); + writeFileSync(posixNpx, `#!${process.execPath}\nrequire(${JSON.stringify(fakeScript)});\n`); + chmodSync(posixNpx, 0o755); + + writeFileSync( + join(directory, "npx.cmd"), + `@echo off\r\n"${process.execPath}" "${fakeScript}" %*\r\n`, + ); + return directory; +} + +async function writeConfig( + dataDirectory: string, + sessionId: string | undefined, + sideshowUrl: string, + apiToken: string, +) { + const env: NodeJS.ProcessEnv = { + ...cleanRoutingEnv(), + CLAUDE_PLUGIN_DATA: dataDirectory, + CLAUDE_PLUGIN_OPTION_SIDESHOWURL: sideshowUrl, + CLAUDE_PLUGIN_OPTION_APITOKEN: apiToken, + }; + if (sessionId) env.CLAUDE_CODE_SESSION_ID = sessionId; + return runNode(CONFIG_HELPER, env); +} + +test("Claude plugin monitor wiring passes only explicit non-secret routing arguments", () => { + const monitors = JSON.parse(readFileSync(join(ROOT, "plugin", "monitors.json"), "utf8")); + const monitorText = JSON.stringify(monitors); + assert.doesNotMatch(monitorText, /\$\{user_config\./); + assert.doesNotMatch(monitorText, /apiToken|SIDESHOW_TOKEN/i); + assert.equal( + monitors[0].command, + 'node "${CLAUDE_PLUGIN_ROOT}/scripts/run-monitor.mjs" "${CLAUDE_PLUGIN_DATA}" "${CLAUDE_CODE_SESSION_ID}"', + ); + + const manifest = JSON.parse( + readFileSync(join(ROOT, "plugin", ".claude-plugin", "plugin.json"), "utf8"), + ); + assert.equal(manifest.version, "0.1.1"); + assert.equal(manifest.mcpServers.sideshow.env.SIDESHOW_URL, "${user_config.sideshowUrl}"); + assert.equal(manifest.mcpServers.sideshow.env.SIDESHOW_TOKEN, "${user_config.apiToken}"); + + const hooks = JSON.parse(readFileSync(join(ROOT, "plugin", "hooks", "hooks.json"), "utf8")); + const sessionStart = hooks.hooks.SessionStart[0]; + assert.match("startup", new RegExp(sessionStart.matcher)); + assert.match("resume", new RegExp(sessionStart.matcher)); + assert.match("fork", new RegExp(sessionStart.matcher)); + assert.doesNotMatch("compact", new RegExp(sessionStart.matcher)); + assert.doesNotMatch("clear", new RegExp(sessionStart.matcher)); + assert.equal(sessionStart.hooks[0].command, "node"); + assert.deepEqual(sessionStart.hooks[0].args, ["${CLAUDE_PLUGIN_ROOT}/scripts/write-config.mjs"]); + + const sessionEndHook = hooks.hooks.SessionEnd[0].hooks[0]; + assert.equal(sessionEndHook.command, "node"); + assert.deepEqual(sessionEndHook.args, ["${CLAUDE_PLUGIN_ROOT}/scripts/remove-config.mjs"]); +}); + +test("SessionStart helper writes a private traversal-safe per-session config", async () => { + const dataDirectory = join(mkdtempSync(join(tmpdir(), "sideshow-plugin-config-")), "data"); + const secret = "token-that-must-not-be-printed"; + const result = await writeConfig( + dataDirectory, + "../../unsafe/session-id", + "https://sideshow.example.test", + secret, + ); + + assert.deepEqual(result, { code: 0, stdout: "", stderr: "" }); + const files = readdirSync(dataDirectory); + assert.equal(files.length, 1); + assert.match(files[0], /^monitor-config-[a-f0-9]{64}\.json$/); + const configPath = join(dataDirectory, files[0]); + assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")), { + sideshowUrl: "https://sideshow.example.test", + apiToken: secret, + }); + if (process.platform !== "win32") { + assert.equal(statSync(dataDirectory).mode & 0o777, 0o700); + assert.equal(statSync(configPath).mode & 0o777, 0o600); + } +}); + +test("SessionEnd helper removes unconsumed configuration", async () => { + const dataDirectory = mkdtempSync(join(tmpdir(), "sideshow-plugin-cleanup-")); + const sessionId = "monitor-unavailable-session"; + assert.equal( + (await writeConfig(dataDirectory, sessionId, "https://unused.example.test", "unused-token")) + .code, + 0, + ); + assert.equal( + readdirSync(dataDirectory).filter((file) => file.startsWith("monitor-config-")).length, + 1, + ); + + const result = await runNode(REMOVE_CONFIG_HELPER, { + ...cleanRoutingEnv(), + CLAUDE_PLUGIN_DATA: dataDirectory, + CLAUDE_CODE_SESSION_ID: sessionId, + }); + + assert.deepEqual(result, { code: 0, stdout: "", stderr: "" }); + assert.equal( + readdirSync(dataDirectory).filter((file) => file.startsWith("monitor-config-")).length, + 0, + ); +}); + +test("monitor helper consumes explicit session config without exposing the token", async () => { + const fakeBin = makeFakeNpx(); + const dataDirectory = mkdtempSync(join(tmpdir(), "sideshow-plugin-monitor-")); + const logPath = join(dataDirectory, "npx.json"); + const sessionId = "session-explicit-arguments"; + const secret = "environment-only-token"; + assert.equal( + (await writeConfig(dataDirectory, sessionId, "https://configured.example.test", secret)).code, + 0, + ); + const [configFile] = readdirSync(dataDirectory); + + const result = await runNode( + MONITOR_HELPER, + { + ...cleanRoutingEnv(), + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, + FAKE_NPX_EXIT_CODE: "7", + FAKE_NPX_LOG: logPath, + }, + [dataDirectory, sessionId], + ); + + assert.equal(result.code, 7); + assert.equal(result.stdout, "comment one\ncomment two\n"); + assert.equal(result.stderr, "watch diagnostic\n"); + assert.doesNotMatch(`${result.stdout}${result.stderr}`, new RegExp(secret)); + const invocation = JSON.parse(readFileSync(logPath, "utf8")); + assert.deepEqual(invocation.args, ["-y", "sideshow", "watch"]); + assert.equal(invocation.url, "https://configured.example.test"); + assert.equal(invocation.token, secret); + assert.doesNotMatch(JSON.stringify(invocation.args), new RegExp(secret)); + assert.equal(existsSync(join(dataDirectory, configFile)), false); +}); + +test("concurrent monitor sessions consume only their own credentials", async () => { + const fakeBin = makeFakeNpx(); + const dataDirectory = mkdtempSync(join(tmpdir(), "sideshow-plugin-concurrent-")); + const sessions = [ + { + id: "session-a", + url: "https://one.example.test", + token: "token-one", + log: join(dataDirectory, "one.json"), + }, + { + id: "session-b", + url: "https://two.example.test", + token: "token-two", + log: join(dataDirectory, "two.json"), + }, + ]; + + await Promise.all( + sessions.map(async ({ id, url, token }) => { + assert.equal((await writeConfig(dataDirectory, id, url, token)).code, 0); + }), + ); + assert.equal( + readdirSync(dataDirectory).filter((file) => file.startsWith("monitor-config-")).length, + 2, + ); + + const results = await Promise.all( + sessions.map(({ id, log }) => + runNode( + MONITOR_HELPER, + { + ...cleanRoutingEnv(), + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, + FAKE_NPX_LOG: log, + }, + [dataDirectory, id], + ), + ), + ); + assert.deepEqual( + results.map(({ code }) => code), + [0, 0], + ); + + for (const { url, token, log } of sessions) { + const invocation = JSON.parse(readFileSync(log, "utf8")); + assert.equal(invocation.url, url); + assert.equal(invocation.token, token); + assert.deepEqual(invocation.args, ["-y", "sideshow", "watch"]); + } + assert.equal( + readdirSync(dataDirectory).filter((file) => file.startsWith("monitor-config-")).length, + 0, + ); +}); + +test("config helpers use and consume a safe fallback when no session ID is available", async () => { + const fakeBin = makeFakeNpx(); + const dataDirectory = mkdtempSync(join(tmpdir(), "sideshow-plugin-no-session-")); + const logPath = join(dataDirectory, "npx.json"); + assert.equal( + (await writeConfig(dataDirectory, undefined, "https://fallback.example.test", "fallback-token")) + .code, + 0, + ); + assert.equal(existsSync(join(dataDirectory, "monitor-config.json")), true); + + const result = await runNode( + MONITOR_HELPER, + { + ...cleanRoutingEnv(), + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, + FAKE_NPX_LOG: logPath, + }, + [dataDirectory, ""], + ); + + assert.equal(result.code, 0); + const invocation = JSON.parse(readFileSync(logPath, "utf8")); + assert.equal(invocation.url, "https://fallback.example.test"); + assert.equal(invocation.token, "fallback-token"); + assert.equal(existsSync(join(dataDirectory, "monitor-config.json")), false); +}); + +test("monitor helper waits briefly for its SessionStart configuration", async () => { + const fakeBin = makeFakeNpx(); + const dataDirectory = mkdtempSync(join(tmpdir(), "sideshow-plugin-race-")); + const logPath = join(dataDirectory, "npx.json"); + const sessionId = "waiting-session"; + const resultPromise = runNode( + MONITOR_HELPER, + { + ...cleanRoutingEnv(), + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, + FAKE_NPX_LOG: logPath, + }, + [dataDirectory, sessionId], + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal( + (await writeConfig(dataDirectory, sessionId, "https://race.example.test", "race-token")).code, + 0, + ); + + assert.equal((await resultPromise).code, 0); + const invocation = JSON.parse(readFileSync(logPath, "utf8")); + assert.equal(invocation.url, "https://race.example.test"); + assert.equal(invocation.token, "race-token"); + assert.equal( + readdirSync(dataDirectory).filter((file) => file.startsWith("monitor-config-")).length, + 0, + ); +}); + +test("monitor helper preserves inherited configuration when no config arrives", async () => { + const fakeBin = makeFakeNpx(); + const directory = mkdtempSync(join(tmpdir(), "sideshow-plugin-fallback-")); + const logPath = join(directory, "npx.json"); + const result = await runNode( + MONITOR_HELPER, + { + ...cleanRoutingEnv(), + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, + FAKE_NPX_LOG: logPath, + SIDESHOW_URL: "http://localhost:8228", + SIDESHOW_TOKEN: "inherited-token", + }, + ["", "fallback-session"], + ); + + assert.equal(result.code, 0); + const invocation = JSON.parse(readFileSync(logPath, "utf8")); + assert.equal(invocation.url, "http://localhost:8228"); + assert.equal(invocation.token, "inherited-token"); +}); + +test("monitor helper leaves localhost selection to the CLI when no config exists", async () => { + const fakeBin = makeFakeNpx(); + const directory = mkdtempSync(join(tmpdir(), "sideshow-plugin-localhost-")); + const logPath = join(directory, "npx.json"); + const result = await runNode( + MONITOR_HELPER, + { + ...cleanRoutingEnv(), + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, + FAKE_NPX_LOG: logPath, + }, + ["", "localhost-session"], + ); + + assert.equal(result.code, 0); + const invocation = JSON.parse(readFileSync(logPath, "utf8")); + assert.equal(invocation.url, undefined); + assert.equal(invocation.token, undefined); +}); + +test("monitor helper reports spawn failure as exit code 1", async () => { + const emptyPath = mkdtempSync(join(tmpdir(), "sideshow-plugin-empty-path-")); + const result = await runNode( + MONITOR_HELPER, + { + ...cleanRoutingEnv(), + PATH: emptyPath, + ComSpec: join(emptyPath, "missing-command-shell.exe"), + }, + ["", "spawn-failure-session"], + ); + + assert.equal(result.code, 1); + assert.match(result.stderr, /sideshow monitor failed to start:/); +});