From 791f02175416e98f19b5fff8d73c520968af7d0a Mon Sep 17 00:00:00 2001 From: Dave Kerr Date: Wed, 14 Jan 2026 12:30:20 +0000 Subject: [PATCH 1/3] docs: add shell_attach documentation and MCP test scripts - Add docs/shell-attach.md explaining how to attach to existing terminals - Add scripts/mcp-init.sh for initializing MCP sessions - Add scripts/mcp-tool-call.sh for calling MCP tools from CLI - Add removal command hint to README --- README.md | 2 + docs/shell-attach.md | 80 ++++++++++++++++++++++++++++++++++++++++ scripts/mcp-init.sh | 19 ++++++++++ scripts/mcp-tool-call.sh | 28 ++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 docs/shell-attach.md create mode 100755 scripts/mcp-init.sh create mode 100755 scripts/mcp-tool-call.sh diff --git a/README.md b/README.md index 9cab3f7..f3bfc8c 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,8 @@ To test local development changes with Claude Code, add the local build as an MC # From the shellwright repo root - build first! npm run build claude mcp add shellwright-dev --scope project -- node "${PWD}/dist/index.js" +# remove with: +claude mcp remove shellwright-dev --scope project ``` This registers your local build so you can test changes before publishing. diff --git a/docs/shell-attach.md b/docs/shell-attach.md new file mode 100644 index 0000000..85e8861 --- /dev/null +++ b/docs/shell-attach.md @@ -0,0 +1,80 @@ +# shell_attach + +The `shell_attach` tool allows you to connect Shellwright to an existing terminal session. This makes it easier to create recordings from your own interactive sessions (rather than instructing an AI agent to do it). + +Being able to attach to a shell allows you to write commands and scripts that start recording your screen - which can be trigged by things like [Claude Code slash commands](https://code.claude.com/docs/en/slash-commands). + +Shell sessions must be running in [`tmux`](https://github.com/tmux/tmux/wiki) for this to work. When tmux wraps a shell it allows content to be captures - which is how this shell attach command works. Support for `screen` and vanilla shells is tracked in [#51](https://github.com/dwmkerr/shellwright/issues/51). + +Technically, the process is: + +```bash +shell_attach # walk up process tree to find PID with tty +ps -p $PID -o tty= # get tty device for that process +tmux list-panes -a | grep $TTY # find tmux pane for the tty +tmux capture-pane -t $PANE -p -e # capture pane content with ANSI codes +``` + +## Usage + +Start a tmux session, then run something like Claude code: + +```bash +# Start tmux, then run Claude Code inside it +tmux new -s main +claude +``` + +Then attach to the terminal: + +```bash +# Initialize MCP session (see scripts/mcp-init.sh) +SESSION=$(./scripts/mcp-init.sh) + +# Attach to current terminal (see scripts/mcp-tool-call.sh) +./scripts/mcp-tool-call.sh $SESSION shell_attach +``` + +Example response: + +```json +{ "session_id": "shell-session-abc123" } +``` + +The `session_id` can be used with `shell_screenshot`, `shell_record_start`, `shell_record_stop`, and `shell_read`. + +With the session successfully attached, you can start and stop a recording: + +```bash +./scripts/mcp-tool-call.sh $SESSION shell_record_start '{"session_id": "shell-session-abc123"}' +``` + +Example response: + +```json +{ "recording": true, "fps": 10 } +``` + +To stop recording: + +```bash +./scripts/mcp-tool-call.sh $SESSION shell_record_stop '{"session_id": "shell-session-abc123"}' +``` + +Example response: + +```json +{ "filename": "recording_1234567890.gif", "download_url": "http://localhost:7498/files/..." } +``` + +Detach the session with: + +```bash +./scripts/mcp-tool-call.sh $SESSION shell_detach '{"session_id": "shell-session-abc123"}' +``` + +Example response: + +```json +{ "success": true } +``` diff --git a/scripts/mcp-init.sh b/scripts/mcp-init.sh new file mode 100755 index 0000000..5683cc4 --- /dev/null +++ b/scripts/mcp-init.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Initialize MCP session and output session ID +# Usage: ./scripts/mcp-init.sh [port] + +PORT="${1:-7498}" +URL="http://localhost:${PORT}/mcp" + +SESSION_ID=$(curl -s -D - -X POST "$URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"mcp-cli","version":"1.0"}}}' \ + 2>/dev/null | grep -i "mcp-session-id:" | cut -d' ' -f2 | tr -d '\r') + +if [ -z "$SESSION_ID" ]; then + echo "[mcp-init] error: failed to get session ID" >&2 + exit 1 +fi + +echo "$SESSION_ID" diff --git a/scripts/mcp-tool-call.sh b/scripts/mcp-tool-call.sh new file mode 100755 index 0000000..469de9c --- /dev/null +++ b/scripts/mcp-tool-call.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Call an MCP tool +# Usage: ./scripts/mcp-tool-call.sh [args_json] +# Example: ./scripts/mcp-tool-call.sh $SESSION shell_start '{"command":"bash"}' + +SESSION_ID="$1" +TOOL_NAME="$2" +ARGS_JSON="${3:-{}}" +PORT="${4:-7498}" + +if [ -z "$SESSION_ID" ] || [ -z "$TOOL_NAME" ]; then + echo "Usage: $0 [args_json] [port]" >&2 + exit 1 +fi + +URL="http://localhost:${PORT}/mcp" + +REQUEST=$(cat </dev/null | grep "^data:" | sed 's/^data: //' | jq -r '.result.content[0].text // .error.message // .' From 835f06c1c3e4d689a09b31d4e14bdd24c4099699 Mon Sep 17 00:00:00 2001 From: Dave Kerr Date: Wed, 14 Jan 2026 20:33:29 +0000 Subject: [PATCH 2/3] feat: add shell_attach and shell_detach tools - shell_attach: attach to current terminal via tmux for screenshots/recordings - shell_detach: clean up attached sessions - shell_stop now rejects attached sessions (use shell_detach instead) - shell_send now rejects attached sessions (read-only capture) - Add tmux helper functions for pane detection and capture - Add inspect script to package.json --- package.json | 3 +- src/index.ts | 187 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 184 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index cc22510..5720c31 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "test:k9s": "tsx tests/04-k9s/run.ts", "lint": "eslint src/**/*.ts", "eval": "cd evaluations && npm start", - "eval:compare": "cd evaluations && npm run compare" + "eval:compare": "cd evaluations && npm run compare", + "inspect": "npx @modelcontextprotocol/inspector" }, "repository": { "type": "git", diff --git a/src/index.ts b/src/index.ts index c62815e..0b0640e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import express, { Request, Response } from "express"; import { randomUUID } from "crypto"; +import { execSync } from "child_process"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; @@ -147,18 +148,80 @@ interface RecordingState { interface Session { id: string; - pty: pty.IPty; + pty?: pty.IPty; cols: number; rows: number; buffer: string[]; terminal: InstanceType; theme: Theme; recording?: RecordingState; + attached?: { + tmuxPane: string; + tty: string; + captureInterval?: ReturnType; + }; } const sessions = new Map(); const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; +// Find TTY by walking up the process tree +function findParentTty(): string | null { + try { + let pid = process.ppid; + for (let i = 0; i < 10; i++) { + const tty = execSync(`ps -p ${pid} -o tty=`, { encoding: "utf-8" }).trim(); + if (tty && tty !== "??" && !tty.startsWith("?")) { + return tty.startsWith("/dev/") ? tty : `/dev/${tty}`; + } + const ppid = execSync(`ps -p ${pid} -o ppid=`, { encoding: "utf-8" }).trim(); + if (!ppid || ppid === "0" || ppid === "1") break; + pid = parseInt(ppid, 10); + } + } catch { + return null; + } + return null; +} + +// Find tmux pane for a TTY +function findTmuxPane(tty: string): string | null { + try { + const ttyShort = tty.replace("/dev/", ""); + const panes = execSync("tmux list-panes -a -F '#{pane_tty} #{session_name}:#{window_index}.#{pane_index}'", { encoding: "utf-8" }); + for (const line of panes.split("\n")) { + const [paneTty, paneId] = line.trim().split(" "); + if (paneTty === tty || paneTty === `/dev/${ttyShort}` || paneTty.endsWith(ttyShort)) { + return paneId; + } + } + } catch { + return null; + } + return null; +} + +// Get tmux pane dimensions +function getTmuxPaneDimensions(pane: string): { cols: number; rows: number } | null { + try { + const output = execSync(`tmux display-message -t "${pane}" -p "#{pane_width} #{pane_height}"`, { encoding: "utf-8" }).trim(); + const [cols, rows] = output.split(" ").map(Number); + if (cols && rows) return { cols, rows }; + } catch { + return null; + } + return null; +} + +// Capture tmux pane content with ANSI codes +function captureTmuxPane(pane: string): string { + try { + return execSync(`tmux capture-pane -t "${pane}" -p -e`, { encoding: "utf-8" }); + } catch { + return ""; + } +} + // Build temp path: /tmp/shellwright/mcp-session-{mcpId}/{shellId} function getSessionDir(mcpSessionId: string | undefined, shellSessionId: string): string { const mcpPart = mcpSessionId ? `mcp-session-${mcpSessionId}` : "mcp-session-unknown"; @@ -299,7 +362,11 @@ Tips: async ({ session_id, input, delay_ms }) => { const session = sessions.get(session_id); if (!session) { - throw new Error(`Session not found: ${session_id}`); + throw new Error(`[shell_send] error: session not found: ${session_id}`); + } + + if (session.attached || !session.pty) { + throw new Error(`[shell_send] error: cannot send input to attached sessions`); } const bufferBefore = bufferToText(session.terminal, session.cols, session.rows); @@ -407,14 +474,18 @@ Tips: server.tool( "shell_stop", - "Stop a PTY session", + "Stop a PTY session. For attached sessions, use shell_detach instead.", { session_id: z.string().describe("Session ID"), }, async ({ session_id }) => { const session = sessions.get(session_id); if (!session) { - throw new Error(`Session not found: ${session_id}`); + throw new Error(`[shell_stop] error: session not found: ${session_id}`); + } + + if (session.attached) { + throw new Error(`[shell_stop] error: use shell_detach for attached sessions`); } // Stop recording if active @@ -422,7 +493,7 @@ Tips: clearInterval(session.recording.interval); } - session.pty.kill(); + session.pty?.kill(); sessions.delete(session_id); log(`[shellwright] Stopped session ${session_id}`); @@ -435,6 +506,112 @@ Tips: } ); + server.tool( + "shell_attach", + "Attach to the current terminal for screenshots and recordings. Requires tmux.", + {}, + async () => { + // Find parent TTY + const tty = findParentTty(); + if (!tty) { + throw new Error(`[shell_attach] error: could not find parent tty`); + } + + // Find tmux pane + const tmuxPane = findTmuxPane(tty); + if (!tmuxPane) { + throw new Error(`[shell_attach] error: tmux not detected. Run Claude Code inside tmux.`); + } + + // Get pane dimensions + const dims = getTmuxPaneDimensions(tmuxPane); + const cols = dims?.cols || COLS; + const rows = dims?.rows || ROWS; + + const id = `shell-session-${randomUUID().slice(0, 6)}`; + + const terminal = new Terminal({ + cols, + rows, + allowProposedApi: true, + }); + + const session: Session = { + id, + cols, + rows, + buffer: [], + terminal, + theme: currentTheme, + attached: { + tmuxPane, + tty, + }, + }; + + // Start continuous capture from tmux pane + const captureAndUpdate = () => { + const content = captureTmuxPane(tmuxPane); + if (content) { + session.buffer = [content]; + terminal.reset(); + terminal.write(content); + } + }; + + captureAndUpdate(); + session.attached!.captureInterval = setInterval(captureAndUpdate, 100); + + sessions.set(id, session); + log(`[shellwright] Attached session ${id} to ${tmuxPane} (${tty})`); + + const output = { session_id: id }; + logToolCall("shell_attach", {}, output); + + return { + content: [{ type: "text" as const, text: JSON.stringify(output) }], + }; + } + ); + + server.tool( + "shell_detach", + "Detach from an attached terminal session. For spawned sessions, use shell_stop instead.", + { + session_id: z.string().describe("Session ID"), + }, + async ({ session_id }) => { + const session = sessions.get(session_id); + if (!session) { + throw new Error(`[shell_detach] error: session not found: ${session_id}`); + } + + if (!session.attached) { + throw new Error(`[shell_detach] error: use shell_stop for spawned sessions`); + } + + // Stop capture interval + if (session.attached.captureInterval) { + clearInterval(session.attached.captureInterval); + } + + // Stop recording if active + if (session.recording) { + clearInterval(session.recording.interval); + } + + sessions.delete(session_id); + log(`[shellwright] Detached session ${session_id}`); + + const output = { success: true }; + logToolCall("shell_detach", { session_id }, output); + + return { + content: [{ type: "text" as const, text: JSON.stringify(output) }], + }; + } + ); + server.tool( "shell_record_start", "Start recording a terminal session (captures frames for GIF/video export)", From b48ff736f84e0383aade0fef406e9b1f80c66873 Mon Sep 17 00:00:00 2001 From: Dave Kerr Date: Fri, 13 Feb 2026 09:29:21 +0800 Subject: [PATCH 3/3] refactor: require explicit tty parameter for shell_attach Changed shell_attach to require the tty parameter instead of auto-detecting the parent TTY. More reliable since the MCP server may run in a different process context. --- docs/shell-attach.md | 30 +++++++++++++++++++++++++++--- src/index.ts | 12 ++++++------ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/docs/shell-attach.md b/docs/shell-attach.md index 85e8861..6608bcb 100644 --- a/docs/shell-attach.md +++ b/docs/shell-attach.md @@ -15,6 +15,16 @@ tmux list-panes -a | grep $TTY # find tmux pane for the tty tmux capture-pane -t $PANE -p -e # capture pane content with ANSI codes ``` +For a screenshot or recording: + +```bash +shell_attach {tty} # starts capture loop (tmux → xterm buffer, polls every 100ms) +shell_screenshot # reads xterm buffer once +shell_record_start # starts recording loop (xterm → PNG frames at configured FPS) +shell_record_stop # stops recording loop, renders GIF +shell_detach # stops capture loop, cleans up +``` + ## Usage Start a tmux session, then run something like Claude code: @@ -31,8 +41,8 @@ Then attach to the terminal: # Initialize MCP session (see scripts/mcp-init.sh) SESSION=$(./scripts/mcp-init.sh) -# Attach to current terminal (see scripts/mcp-tool-call.sh) -./scripts/mcp-tool-call.sh $SESSION shell_attach +# Attach to current terminal - pass your shell's TTY +./scripts/mcp-tool-call.sh $SESSION shell_attach "{\"tty\":\"$(tty)\"}" ``` Example response: @@ -41,9 +51,23 @@ Example response: { "session_id": "shell-session-abc123" } ``` +Note: `shell_attach` will only work when the Shellwright MCP server is running on the same host as the target terminal. Using the `stdio` transport should always work, and the `http` transport will work if running on the same host. The `http` transport on a remote host will not be able to access the local shell session. + The `session_id` can be used with `shell_screenshot`, `shell_record_start`, `shell_record_stop`, and `shell_read`. -With the session successfully attached, you can start and stop a recording: +Take a screenshot: + +```bash +./scripts/mcp-tool-call.sh $SESSION shell_screenshot '{"session_id": "shell-session-abc123"}' +``` + +Example response: + +```json +{ "filename": "screenshot.png", "download_url": "http://localhost:7498/files/..." } +``` + +Start a recording: ```bash ./scripts/mcp-tool-call.sh $SESSION shell_record_start '{"session_id": "shell-session-abc123"}' diff --git a/src/index.ts b/src/index.ts index 0b0640e..afd91ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -508,13 +508,13 @@ Tips: server.tool( "shell_attach", - "Attach to the current terminal for screenshots and recordings. Requires tmux.", - {}, - async () => { - // Find parent TTY - const tty = findParentTty(); + "Attach to a terminal for screenshots and recordings. Requires tmux. Pass the TTY of the target terminal.", + { + tty: z.string().describe("TTY device path (e.g., $(tty) in bash gives /dev/ttys001)"), + }, + async ({ tty }) => { if (!tty) { - throw new Error(`[shell_attach] error: could not find parent tty`); + throw new Error(`[shell_attach] error: tty parameter required`); } // Find tmux pane