Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
111
claude-remote.js
Executable file
111
claude-remote.js
Executable 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
59
debug-window.js
Normal 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
161
find-window.js
Normal 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
9
package.json
Normal 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
49
paste.js
Normal 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
93
plan.md
Normal 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.
|
||||
Reference in New Issue
Block a user