From b31d8d3b103cb47d01634595333e7c2b2a4aaa3f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 06:12:25 +0000 Subject: [PATCH] feat: add tmux-style process key helpers --- CHANGELOG.md | 1 + docs/tools/exec.md | 12 + .../bash-tools.process.send-keys.test.ts | 45 ++++ src/agents/bash-tools.process.ts | 161 +++++++++++- src/agents/pty-keys.test.ts | 33 +++ src/agents/pty-keys.ts | 245 ++++++++++++++++++ 6 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 src/agents/bash-tools.process.send-keys.test.ts create mode 100644 src/agents/pty-keys.test.ts create mode 100644 src/agents/pty-keys.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f99c56b1..daff7f3a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites. - Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails. - Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`. +- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions. - Status: trim `/status` to current-provider usage only and drop the OAuth/token block. - Directory: unify `clawdbot directory` across channels and plugin channels. - UI: allow deleting sessions from the Control UI. diff --git a/docs/tools/exec.md b/docs/tools/exec.md index afe047e9f..f67e354aa 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -39,6 +39,18 @@ Background + poll: {"tool":"process","action":"poll","sessionId":""} ``` +Send keys (tmux-style): +```json +{"tool":"process","action":"send-keys","sessionId":"","keys":["Enter"]} +{"tool":"process","action":"send-keys","sessionId":"","keys":["C-c"]} +{"tool":"process","action":"send-keys","sessionId":"","keys":["Up","Up","Enter"]} +``` + +Paste (bracketed by default): +```json +{"tool":"process","action":"paste","sessionId":"","text":"line1\nline2\n"} +``` + ## apply_patch (experimental) `apply_patch` is a subtool of `exec` for structured multi-file edits. diff --git a/src/agents/bash-tools.process.send-keys.test.ts b/src/agents/bash-tools.process.send-keys.test.ts new file mode 100644 index 000000000..eac885d5f --- /dev/null +++ b/src/agents/bash-tools.process.send-keys.test.ts @@ -0,0 +1,45 @@ +import { afterEach, expect, test } from "vitest"; + +import { resetProcessRegistryForTests } from "./bash-process-registry"; +import { createExecTool } from "./bash-tools.exec"; +import { createProcessTool } from "./bash-tools.process"; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +afterEach(() => { + resetProcessRegistryForTests(); +}); + +test("process send-keys encodes Enter for pty sessions", async () => { + const execTool = createExecTool(); + const processTool = createProcessTool(); + const result = await execTool.execute("toolcall", { + command: + "node -e \"process.stdin.on('data', d => { process.stdout.write(d); if (d.includes(13)) process.exit(0); });\"", + pty: true, + background: true, + }); + + expect(result.details.status).toBe("running"); + const sessionId = result.details.sessionId; + expect(sessionId).toBeTruthy(); + + await processTool.execute("toolcall", { + action: "send-keys", + sessionId, + keys: ["h", "i", "Enter"], + }); + + for (let i = 0; i < 10; i += 1) { + await wait(50); + const poll = await processTool.execute("toolcall", { action: "poll", sessionId }); + const details = poll.details as { status?: string; aggregated?: string }; + if (details.status !== "running") { + expect(details.status).toBe("completed"); + expect(details.aggregated ?? "").toContain("hi"); + return; + } + } + + throw new Error("PTY session did not exit after send-keys"); +}); diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index e5e37d174..fad5e1109 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -19,6 +19,7 @@ import { sliceLogLines, truncateMiddle, } from "./bash-tools.shared.js"; +import { encodeKeySequence, encodePaste } from "./pty-keys.js"; export type ProcessToolDefaults = { cleanupMs?: number; @@ -29,6 +30,15 @@ const processSchema = Type.Object({ action: Type.String({ description: "Process action" }), sessionId: Type.Optional(Type.String({ description: "Session id for actions other than list" })), data: Type.Optional(Type.String({ description: "Data to write for write" })), + keys: Type.Optional( + Type.Array(Type.String(), { description: "Key tokens to send for send-keys" }), + ), + hex: Type.Optional( + Type.Array(Type.String(), { description: "Hex bytes to send for send-keys" }), + ), + literal: Type.Optional(Type.String({ description: "Literal string for send-keys" })), + text: Type.Optional(Type.String({ description: "Text to paste for paste" })), + bracketed: Type.Optional(Type.Boolean({ description: "Wrap paste in bracketed mode" })), eof: Type.Optional(Type.Boolean({ description: "Close stdin after write" })), offset: Type.Optional(Type.Number({ description: "Log offset" })), limit: Type.Optional(Type.Number({ description: "Log length" })), @@ -48,13 +58,18 @@ export function createProcessTool( return { name: "process", label: "process", - description: "Manage running exec sessions: list, poll, log, write, kill.", + description: "Manage running exec sessions: list, poll, log, write, send-keys, paste, kill.", parameters: processSchema, execute: async (_toolCallId, args) => { const params = args as { - action: "list" | "poll" | "log" | "write" | "kill" | "clear" | "remove"; + action: "list" | "poll" | "log" | "write" | "send-keys" | "paste" | "kill" | "clear" | "remove"; sessionId?: string; data?: string; + keys?: string[]; + hex?: string[]; + literal?: string; + text?: string; + bracketed?: boolean; eof?: boolean; offset?: number; limit?: number; @@ -340,6 +355,148 @@ export function createProcessTool( }; } + case "send-keys": { + if (!scopedSession) { + return { + content: [ + { + type: "text", + text: `No active session found for ${params.sessionId}`, + }, + ], + details: { status: "failed" }, + }; + } + if (!scopedSession.backgrounded) { + return { + content: [ + { + type: "text", + text: `Session ${params.sessionId} is not backgrounded.`, + }, + ], + details: { status: "failed" }, + }; + } + const stdin = scopedSession.stdin ?? scopedSession.child?.stdin; + if (!stdin || stdin.destroyed) { + return { + content: [ + { + type: "text", + text: `Session ${params.sessionId} stdin is not writable.`, + }, + ], + details: { status: "failed" }, + }; + } + const { data, warnings } = encodeKeySequence({ + keys: params.keys, + hex: params.hex, + literal: params.literal, + }); + if (!data) { + return { + content: [ + { + type: "text", + text: "No key data provided.", + }, + ], + details: { status: "failed" }, + }; + } + await new Promise((resolve, reject) => { + stdin.write(data, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + return { + content: [ + { + type: "text", + text: + `Sent ${data.length} bytes to session ${params.sessionId}.` + + (warnings.length ? `\nWarnings:\n- ${warnings.join("\n- ")}` : ""), + }, + ], + details: { + status: "running", + sessionId: params.sessionId, + name: scopedSession ? deriveSessionName(scopedSession.command) : undefined, + }, + }; + } + + case "paste": { + if (!scopedSession) { + return { + content: [ + { + type: "text", + text: `No active session found for ${params.sessionId}`, + }, + ], + details: { status: "failed" }, + }; + } + if (!scopedSession.backgrounded) { + return { + content: [ + { + type: "text", + text: `Session ${params.sessionId} is not backgrounded.`, + }, + ], + details: { status: "failed" }, + }; + } + const stdin = scopedSession.stdin ?? scopedSession.child?.stdin; + if (!stdin || stdin.destroyed) { + return { + content: [ + { + type: "text", + text: `Session ${params.sessionId} stdin is not writable.`, + }, + ], + details: { status: "failed" }, + }; + } + const payload = encodePaste(params.text ?? "", params.bracketed !== false); + if (!payload) { + return { + content: [ + { + type: "text", + text: "No paste text provided.", + }, + ], + details: { status: "failed" }, + }; + } + await new Promise((resolve, reject) => { + stdin.write(payload, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + return { + content: [ + { + type: "text", + text: `Pasted ${params.text?.length ?? 0} chars to session ${params.sessionId}.`, + }, + ], + details: { + status: "running", + sessionId: params.sessionId, + name: scopedSession ? deriveSessionName(scopedSession.command) : undefined, + }, + }; + } + case "kill": { if (!scopedSession) { return { diff --git a/src/agents/pty-keys.test.ts b/src/agents/pty-keys.test.ts new file mode 100644 index 000000000..87c001cf2 --- /dev/null +++ b/src/agents/pty-keys.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "vitest"; + +import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, encodeKeySequence, encodePaste } from "./pty-keys.js"; + +test("encodeKeySequence maps common keys and modifiers", () => { + const enter = encodeKeySequence({ keys: ["Enter"] }); + expect(enter.data).toBe("\r"); + + const ctrlC = encodeKeySequence({ keys: ["C-c"] }); + expect(ctrlC.data).toBe("\x03"); + + const altX = encodeKeySequence({ keys: ["M-x"] }); + expect(altX.data).toBe("\x1bx"); + + const shiftTab = encodeKeySequence({ keys: ["S-Tab"] }); + expect(shiftTab.data).toBe("\x1b[Z"); +}); + +test("encodeKeySequence supports hex + literal with warnings", () => { + const result = encodeKeySequence({ + literal: "hi", + hex: ["0d", "0x0a", "zz"], + keys: ["Enter"], + }); + expect(result.data).toBe("hi\r\n\r"); + expect(result.warnings.length).toBe(1); +}); + +test("encodePaste wraps bracketed sequences by default", () => { + const payload = encodePaste("line1\nline2\n"); + expect(payload.startsWith(BRACKETED_PASTE_START)).toBe(true); + expect(payload.endsWith(BRACKETED_PASTE_END)).toBe(true); +}); diff --git a/src/agents/pty-keys.ts b/src/agents/pty-keys.ts new file mode 100644 index 000000000..cf54d4c92 --- /dev/null +++ b/src/agents/pty-keys.ts @@ -0,0 +1,245 @@ +const ESC = "\x1b"; +const CR = "\r"; +const TAB = "\t"; +const BACKSPACE = "\x7f"; + +export const BRACKETED_PASTE_START = `${ESC}[200~`; +export const BRACKETED_PASTE_END = `${ESC}[201~`; + +type Modifiers = { + ctrl: boolean; + alt: boolean; + shift: boolean; +}; + +const namedKeyMap = new Map([ + ["enter", CR], + ["return", CR], + ["tab", TAB], + ["escape", ESC], + ["esc", ESC], + ["space", " "], + ["bspace", BACKSPACE], + ["backspace", BACKSPACE], + ["up", `${ESC}[A`], + ["down", `${ESC}[B`], + ["right", `${ESC}[C`], + ["left", `${ESC}[D`], + ["home", `${ESC}[1~`], + ["end", `${ESC}[4~`], + ["pageup", `${ESC}[5~`], + ["pgup", `${ESC}[5~`], + ["ppage", `${ESC}[5~`], + ["pagedown", `${ESC}[6~`], + ["pgdn", `${ESC}[6~`], + ["npage", `${ESC}[6~`], + ["insert", `${ESC}[2~`], + ["ic", `${ESC}[2~`], + ["delete", `${ESC}[3~`], + ["del", `${ESC}[3~`], + ["dc", `${ESC}[3~`], + ["btab", `${ESC}[Z`], + ["f1", `${ESC}OP`], + ["f2", `${ESC}OQ`], + ["f3", `${ESC}OR`], + ["f4", `${ESC}OS`], + ["f5", `${ESC}[15~`], + ["f6", `${ESC}[17~`], + ["f7", `${ESC}[18~`], + ["f8", `${ESC}[19~`], + ["f9", `${ESC}[20~`], + ["f10", `${ESC}[21~`], + ["f11", `${ESC}[23~`], + ["f12", `${ESC}[24~`], +]); + +const modifiableNamedKeys = new Set([ + "up", + "down", + "left", + "right", + "home", + "end", + "pageup", + "pgup", + "ppage", + "pagedown", + "pgdn", + "npage", + "insert", + "ic", + "delete", + "del", + "dc", +]); + +export type KeyEncodingRequest = { + keys?: string[]; + hex?: string[]; + literal?: string; +}; + +export type KeyEncodingResult = { + data: string; + warnings: string[]; +}; + +export function encodeKeySequence(request: KeyEncodingRequest): KeyEncodingResult { + const warnings: string[] = []; + let data = ""; + + if (request.literal) { + data += request.literal; + } + + if (request.hex?.length) { + for (const raw of request.hex) { + const byte = parseHexByte(raw); + if (byte === null) { + warnings.push(`Invalid hex byte: ${raw}`); + continue; + } + data += String.fromCharCode(byte); + } + } + + if (request.keys?.length) { + for (const token of request.keys) { + data += encodeKeyToken(token, warnings); + } + } + + return { data, warnings }; +} + +export function encodePaste(text: string, bracketed = true): string { + if (!bracketed) return text; + return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`; +} + +function encodeKeyToken(raw: string, warnings: string[]): string { + const token = raw.trim(); + if (!token) return ""; + + if (token.length === 2 && token.startsWith("^")) { + const ctrl = toCtrlChar(token[1]); + if (ctrl) return ctrl; + } + + const parsed = parseModifiers(token); + const base = parsed.base; + const baseLower = base.toLowerCase(); + + if (baseLower === "tab" && parsed.mods.shift) { + return `${ESC}[Z`; + } + + const baseSeq = namedKeyMap.get(baseLower); + if (baseSeq) { + let seq = baseSeq; + if (modifiableNamedKeys.has(baseLower) && hasAnyModifier(parsed.mods)) { + const mod = xtermModifier(parsed.mods); + if (mod > 1) { + const modified = applyXtermModifier(seq, mod); + if (modified) { + seq = modified; + return seq; + } + } + } + if (parsed.mods.alt) { + return `${ESC}${seq}`; + } + return seq; + } + + if (base.length === 1) { + return applyCharModifiers(base, parsed.mods); + } + + if (parsed.hasModifiers) { + warnings.push(`Unknown key "${base}" for modifiers; sending literal.`); + } + return base; +} + +function parseModifiers(token: string) { + const mods: Modifiers = { ctrl: false, alt: false, shift: false }; + let rest = token; + let sawModifiers = false; + + while (rest.length > 2 && rest[1] === "-") { + const mod = rest[0].toLowerCase(); + if (mod === "c") mods.ctrl = true; + else if (mod === "m") mods.alt = true; + else if (mod === "s") mods.shift = true; + else break; + sawModifiers = true; + rest = rest.slice(2); + } + + return { mods, base: rest, hasModifiers: sawModifiers }; +} + +function applyCharModifiers(char: string, mods: Modifiers): string { + let value = char; + if (mods.shift && value.length === 1 && /[a-z]/.test(value)) { + value = value.toUpperCase(); + } + if (mods.ctrl) { + const ctrl = toCtrlChar(value); + if (ctrl) value = ctrl; + } + if (mods.alt) { + value = `${ESC}${value}`; + } + return value; +} + +function toCtrlChar(char: string): string | null { + if (char.length !== 1) return null; + if (char === "?") return "\x7f"; + const code = char.toUpperCase().charCodeAt(0); + if (code >= 64 && code <= 95) { + return String.fromCharCode(code & 0x1f); + } + return null; +} + +function xtermModifier(mods: Modifiers): number { + let mod = 1; + if (mods.shift) mod += 1; + if (mods.alt) mod += 2; + if (mods.ctrl) mod += 4; + return mod; +} + +function applyXtermModifier(sequence: string, modifier: number): string | null { + const csiNumber = /^\x1b\[(\d+)([~A-Z])$/; + const csiArrow = /^\x1b\[(A|B|C|D|H|F)$/; + + const numberMatch = sequence.match(csiNumber); + if (numberMatch) { + return `${ESC}[${numberMatch[1]};${modifier}${numberMatch[2]}`; + } + + const arrowMatch = sequence.match(csiArrow); + if (arrowMatch) { + return `${ESC}[1;${modifier}${arrowMatch[1]}`; + } + + return null; +} + +function hasAnyModifier(mods: Modifiers): boolean { + return mods.ctrl || mods.alt || mods.shift; +} + +function parseHexByte(raw: string): number | null { + const trimmed = raw.trim().toLowerCase(); + const normalized = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed; + if (!/^[0-9a-f]{1,2}$/.test(normalized)) return null; + const value = Number.parseInt(normalized, 16); + if (Number.isNaN(value) || value < 0 || value > 0xff) return null; + return value; +}