Initial commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 22:28:13 +00:00
commit 835e446f7d
6 changed files with 482 additions and 0 deletions

111
claude-remote.js Executable file
View File

@@ -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 <text> paste text and submit
claude-remote --no-submit <text> 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 <id> <text> 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();

59
debug-window.js Normal file
View File

@@ -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 <window-id>');
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);

161
find-window.js Normal file
View File

@@ -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();
}

9
package.json Normal file
View File

@@ -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"
}
}

49
paste.js Normal file
View File

@@ -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;
}

93
plan.md Normal file
View File

@@ -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 <window> _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/<pid>/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/<pid>/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-<pid>` 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/<container_pid>/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.