commit 835e446f7d9258783d55c6eb70bd77728c0f1a12 Author: mikael-lovqvists-claude-agent Date: Wed Mar 18 22:28:13 2026 +0000 Initial commit Co-Authored-By: Claude Sonnet 4.6 diff --git a/claude-remote.js b/claude-remote.js new file mode 100755 index 0000000..2464082 --- /dev/null +++ b/claude-remote.js @@ -0,0 +1,111 @@ +#!/usr/bin/env node +import { createInterface } from 'readline'; +import { readdirSync, readlinkSync, readFileSync } from 'fs'; +import { send_to_claude } from './paste.js'; + +const args = process.argv.slice(2); + +function print_usage() { + console.error(` +Usage: + claude-remote paste text and submit + claude-remote --no-submit paste text without pressing Enter + claude-remote --focus-only focus the window without pasting + claude-remote --find-window print the detected window ID and exit + claude-remote --list-procs list all running processes with exe paths + claude-remote --window-id use specific window ID (decimal or 0x hex) + echo "text" | claude-remote read text from stdin +`.trim()); +} + + +async function read_stdin() { + const rl = createInterface({ input: process.stdin }); + const lines = []; + for await (const line of rl) { + lines.push(line); + } + return lines.join('\n'); +} + +async function main() { + if (args.includes('--help') || args.includes('-h')) { + print_usage(); + process.exit(0); + } + + if (args.includes('--list-procs')) { + for (const entry of readdirSync('/proc')) { + if (!/^\d+$/.test(entry)) { + continue; + } + try { + const exe = readlinkSync(`/proc/${entry}/exe`); + const raw = readFileSync(`/proc/${entry}/cmdline`, 'utf8'); + const cmdline = raw.replace(/\0/g, ' ').trim(); + console.log(`${entry}\t${exe}\t${cmdline}`); + } catch { + // process may have exited + } + } + return; + } + + if (args.includes('--find-window')) { + try { + const id = await send_to_claude('', { focus_only: true, debug: true }); + console.log(id); + } catch (err) { + console.error(`No Claude window found: ${err.message}`); + process.exit(1); + } + return; + } + + const focus_only = args.includes('--focus-only'); + const submit = !args.includes('--no-submit'); + const window_id_idx = args.indexOf('--window-id'); + const window_id = window_id_idx !== -1 ? args[window_id_idx + 1] : undefined; + const text_args = args.filter((a, i) => { + if (a === '--no-submit' || a === '--focus-only') { return false; } + if (a === '--window-id') { return false; } + if (i > 0 && args[i - 1] === '--window-id') { return false; } + return true; + }); + + if (focus_only) { + try { + const window_id = await send_to_claude('', { focus_only: true }); + console.error(`Focused window ${window_id}`); + } catch (err) { + console.error(`Error: ${err.message}`); + process.exit(1); + } + return; + } + + let text; + if (text_args.length > 0) { + text = text_args.join(' '); + } else if (!process.stdin.isTTY) { + text = await read_stdin(); + } else { + print_usage(); + process.exit(1); + } + + if (!text.trim()) { + console.error('No text provided'); + process.exit(1); + } + + try { + const used_id = await send_to_claude(text, { submit, window_id }); + console.error(`Sent to window ${used_id}`); + } catch (err) { + console.error(`Error: ${err.message}`); + process.exit(1); + } +} + +main(); diff --git a/debug-window.js b/debug-window.js new file mode 100644 index 0000000..ea1b3cd --- /dev/null +++ b/debug-window.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import { readdirSync, readFileSync, readlinkSync } from 'fs'; +import { execFileSync } from 'child_process'; + +const window_id = process.argv[2]; +if (!window_id) { + console.error('Usage: debug-window.js '); + process.exit(1); +} + +function read_file(path) { + try { return readFileSync(path, 'utf8'); } catch { return null; } +} + +function get_ppid(pid) { + const status = read_file(`/proc/${pid}/status`); + const match = status?.match(/^PPid:\s+(\d+)/m); + return match ? match[1] : null; +} + +function get_comm(pid) { + return read_file(`/proc/${pid}/comm`)?.trim() ?? null; +} + +function get_cmdline(pid) { + return read_file(`/proc/${pid}/cmdline`)?.replace(/\0/g, ' ').trim() ?? null; +} + +// Get PID of the window +const out = execFileSync('xprop', ['-id', window_id, '_NET_WM_PID'], { encoding: 'utf8' }); +const match = out.match(/= (\d+)/); +if (!match) { + console.error('Could not get PID for window', window_id); + process.exit(1); +} +const root_pid = match[1]; +console.log(`Window ${window_id} → PID ${root_pid} (${get_comm(root_pid)})`); + +// Find all descendants +const children = new Map(); // pid → [child pids] +for (const entry of readdirSync('/proc')) { + if (!/^\d+$/.test(entry)) continue; + const ppid = get_ppid(entry); + if (!ppid) continue; + if (!children.has(ppid)) children.set(ppid, []); + children.get(ppid).push(entry); +} + +function print_tree(pid, depth = 0) { + const indent = ' '.repeat(depth); + const comm = get_comm(pid) ?? '?'; + const cmdline = get_cmdline(pid) ?? ''; + console.log(`${indent}${pid} [${comm}] ${cmdline.slice(0, 120)}`); + for (const child of children.get(pid) ?? []) { + print_tree(child, depth + 1); + } +} + +print_tree(root_pid); diff --git a/find-window.js b/find-window.js new file mode 100644 index 0000000..3f7d909 --- /dev/null +++ b/find-window.js @@ -0,0 +1,161 @@ +import { readFileSync, readdirSync, readlinkSync } from 'fs'; +import { execFileSync } from 'child_process'; + +function read_file(path) { + try { + return readFileSync(path, 'utf8'); + } catch { + return null; + } +} + +function get_exe(pid) { + try { + return readlinkSync(`/proc/${pid}/exe`); + } catch { + return null; + } +} + +function get_cmdline(pid) { + const raw = read_file(`/proc/${pid}/cmdline`); + if (!raw) { + return null; + } + return raw.replace(/\0/g, ' ').trim(); +} + +function get_ppid(pid) { + const status = read_file(`/proc/${pid}/status`); + if (!status) { + return null; + } + const match = status.match(/^PPid:\s+(\d+)/m); + return match ? parseInt(match[1]) : null; +} + +//const DOCKER_COMM_PATTERN = /^docker-compose$/; +const CLAUDE_CMDLINE_SIGNATURE = 'claude --dangerously-skip-permissions'; +const DOCKER_COMM_PATTERN = /^docker-compose$/; + +function get_comm(pid) { + return read_file(`/proc/${pid}/comm`)?.trim() ?? null; +} + +export function list_docker_pids() { + const results = []; + for (const entry of readdirSync('/proc')) { + if (!/^\d+$/.test(entry)) { + continue; + } + const comm = get_comm(entry); + if (!comm || !DOCKER_COMM_PATTERN.test(comm)) { + continue; + } + const cmdline = get_cmdline(entry); + if (cmdline && cmdline.includes(CLAUDE_CMDLINE_SIGNATURE)) { + results.push({ pid: entry, comm, cmdline }); + } + } + return results; +} + +export function walk_to_konsole(pid) { + let current = String(pid); + const visited = new Set(); + const chain = []; + while (current && current !== '0' && !visited.has(current)) { + visited.add(current); + const exe = get_exe(current); + chain.push({ pid: current, exe }); + if (exe === '/usr/bin/konsole') { + return { konsole_pid: current, chain }; + } + current = String(get_ppid(current)); + } + return { konsole_pid: null, chain }; +} + +function get_net_client_list() { + try { + const out = execFileSync('xprop', ['-root', '_NET_CLIENT_LIST'], { encoding: 'utf8' }); + // Format: _NET_CLIENT_LIST(WINDOW): window id # 0x..., 0x..., ... + const match = out.match(/window id # (.+)/); + if (!match) { + return []; + } + return match[1].split(',').map(s => parseInt(s.trim(), 16)).filter(Boolean); + } catch { + return []; + } +} + +function get_window_pid(window_id) { + try { + const hex = '0x' + window_id.toString(16); + const out = execFileSync('xprop', ['-id', hex, '_NET_WM_PID'], { encoding: 'utf8' }); + const match = out.match(/= (\d+)/); + return match ? parseInt(match[1]) : null; + } catch { + return null; + } +} + +export function window_id_for_pid(pid) { + const target_pid = parseInt(pid); + for (const wid of get_net_client_list()) { + if (get_window_pid(wid) === target_pid) { + return '0x' + wid.toString(16); + } + } + return null; +} + +export function find_claude_window_by_process_tree({ debug = false } = {}) { + const docker_pids = list_docker_pids(); + if (debug) { + console.error(`[find-window] docker pids (${docker_pids.length}):`); + for (const { pid, comm, cmdline } of docker_pids) { + console.error(` pid=${pid} comm=${comm} cmdline=${cmdline}`); + } + } + + for (const { pid } of docker_pids) { + const { konsole_pid, chain } = walk_to_konsole(pid); + if (debug) { + const chain_str = chain.map(e => `${e.pid}(${e.exe?.split('/').pop() ?? '?'})`).join(' → '); + console.error(`[find-window] pid=${pid} chain: ${chain_str}`); + } + if (!konsole_pid) { + continue; + } + if (debug) { + console.error(`[find-window] found konsole pid=${konsole_pid}, looking up window...`); + } + const window_id = window_id_for_pid(konsole_pid); + if (debug) { + console.error(`[find-window] window id: ${window_id ?? 'not found'}`); + } + if (window_id) { + return window_id; + } + } + return null; +} + +export function find_claude_window_by_title(title = 'claude-docker') { + try { + const result = execFileSync( + 'xdotool', ['search', '--name', title], + { encoding: 'utf8' } + ); + const ids = result.trim().split('\n').filter(Boolean); + return ids[0] ?? null; + } catch { + return null; + } +} + +export function find_claude_window({ debug = false } = {}) { + return find_claude_window_by_process_tree({ debug }) ?? find_claude_window_by_title(); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d27673b --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "claude-remote", + "version": "0.1.0", + "type": "module", + "description": "Paste text into the Claude Code terminal window via xdotool", + "bin": { + "claude-remote": "./claude-remote.js" + } +} diff --git a/paste.js b/paste.js new file mode 100644 index 0000000..3f6c428 --- /dev/null +++ b/paste.js @@ -0,0 +1,49 @@ +import { execFileSync, spawn } from 'child_process'; +import { setTimeout as sleep } from 'timers/promises'; +import { find_claude_window } from './find-window.js'; + +export async function copy_to_clipboard(text) { + await new Promise((resolve, reject) => { + const proc = spawn('xclip', ['-selection', 'clipboard'], { stdio: ['pipe', 'ignore', 'ignore'] }); + proc.on('error', reject); + proc.on('close', code => code === 0 ? resolve() : reject(new Error(`xclip exited ${code}`))); + proc.stdin.write(text); + proc.stdin.end(); + }); +} + +export function focus_window(window_id) { + execFileSync('xdotool', ['windowactivate', '--sync', window_id]); +} + +export async function paste_to_window(window_id, { submit = true } = {}) { + execFileSync('xdotool', ['windowactivate', '--sync', window_id]); + execFileSync('xdotool', ['key', '--window', window_id, 'shift+Insert']); + if (submit) { + await sleep(200); + execFileSync('xdotool', ['key', '--window', window_id, 'Return']); + } +} + +function normalise_window_id(raw) { + // Accept decimal (25165857) or hex (0x1800021) — always return 0x hex string + const n = raw.startsWith('0x') ? parseInt(raw, 16) : parseInt(raw, 10); + return '0x' + n.toString(16); +} + +export async function send_to_claude(text, options = {}) { + const window_id = options.window_id + ? normalise_window_id(String(options.window_id)) + : find_claude_window({ debug: options.debug }); + if (!window_id) { + throw new Error('Could not find Claude Code window'); + } + console.error(`[send] using window ${window_id}`); + if (options.focus_only) { + focus_window(window_id); + return window_id; + } + await copy_to_clipboard(text); + await paste_to_window(window_id, options); + return window_id; +} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..d97a8cd --- /dev/null +++ b/plan.md @@ -0,0 +1,93 @@ +# claude-remote — Plan + +## Purpose + +Send text to the Claude Code instance running inside a Docker container from the host, +by locating the correct Konsole window and using xdotool to focus it and paste input. + +## Current Status + +Core paste/focus machinery works. The remaining problem is **reliably identifying the correct Konsole window**. + +--- + +## Window Detection: What We Tried and Why It Failed + +### Approach 1: `_NET_WM_PID` via xprop + +Walk up the process tree from the `docker-compose` process to find the Konsole ancestor, +then call `xprop -id _NET_WM_PID` to match that PID to a window. + +**Why it failed:** Konsole runs as a single process managing multiple windows. All windows +owned by the same Konsole instance report the same `_NET_WM_PID`. With 15+ Konsole windows +running, `get_net_client_list()` returns the first matching window — which is the wrong one. + +### Approach 2: Read `/proc//environ` for `WINDOWID` + +Konsole sets `WINDOWID` in the environment of each shell it spawns. That variable is +inherited by child processes (bash → sudo → docker → docker-compose). Reading it from +the right process in the chain would give us the exact window ID without any ambiguity. + +**Why it failed:** `/proc//environ` is not readable for processes owned by other +users (or protected by `ptrace_scope`). Permission denied even for same-user processes +in this environment. + +### Approach 3: Konsole D-Bus API + +Query `org.kde.konsole-` via `qdbus` to enumerate sessions and find which one owns +the relevant pts/tty. + +**Why it was ruled out:** D-Bus is not available in this environment. + +--- + +## Planned Fix: Pass `WINDOWID` into Docker + +Modify the docker launch command to pass `WINDOWID` as an environment variable: + +```bash +docker compose run --rm -e WINDOWID=$WINDOWID claude-code claude --dangerously-skip-permissions +``` + +Inside the container, `WINDOWID` is then available to the Claude process. It can write +this value to a known path on a bind-mounted volume (e.g. `/workspace/.claude-windowid`) +at startup. + +`find-window.js` on the host reads that file as its primary window detection strategy, +falling back to the existing process-tree approach for cases where the file is absent. + +### Steps to implement + +1. Update the docker-compose config (or launch script) to pass `-e WINDOWID=$WINDOWID` +2. Add startup logic inside the container to write `$WINDOWID` to `/workspace/.claude-windowid` +3. Update `find_claude_window` in [find-window.js](find-window.js) to check that file first +4. Clean up the file on container exit (optional) + +--- + +## Future: Replace xdotool with Direct PTY Write + +The current xdotool approach has two significant drawbacks: +- **Focus stealing** — every paste steals window focus from whatever the user is doing, + which is especially disruptive when input arrives from background sources like mail-buddy +- **Fragility** — depends on X11, clipboard state, and window geometry + +Docker containers launched with `-t` are backed by a `/dev/pts/X` device on the host. +Writing directly to that device would send input to the container's Claude process with +no window manager involvement at all — completely invisible to the desktop. + +**Approach:** +1. Find the pts device by reading `/proc//fd/0` symlink on the host + (points to e.g. `/dev/pts/7`) +2. Write the text + newline directly: `echo "text" > /dev/pts/7` +3. May require membership in the `tty` group or a small sudo helper for write permission + +This would make [claude-remote](claude-remote.mjs) dramatically simpler and eliminate +focus stealing entirely. + +**Important caveat — keep PTY input short:** +Writing large payloads (e.g. full email bodies) directly to the PTY risks triggering +terminal control sequence interpretation, line length limits, and input buffer overflows. +The PTY should only carry short trigger commands like `check email` or `new message from mikael`. +Actual message content should be fetched by Claude via a CCC action, keeping the PTY +as a lightweight signalling channel only.