feat: add tmux-style process key helpers

This commit is contained in:
Peter Steinberger
2026-01-17 06:12:25 +00:00
parent 331141ad77
commit b31d8d3b10
6 changed files with 495 additions and 2 deletions

View File

@@ -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.

View File

@@ -39,6 +39,18 @@ Background + poll:
{"tool":"process","action":"poll","sessionId":"<id>"}
```
Send keys (tmux-style):
```json
{"tool":"process","action":"send-keys","sessionId":"<id>","keys":["Enter"]}
{"tool":"process","action":"send-keys","sessionId":"<id>","keys":["C-c"]}
{"tool":"process","action":"send-keys","sessionId":"<id>","keys":["Up","Up","Enter"]}
```
Paste (bracketed by default):
```json
{"tool":"process","action":"paste","sessionId":"<id>","text":"line1\nline2\n"}
```
## apply_patch (experimental)
`apply_patch` is a subtool of `exec` for structured multi-file edits.

View File

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

View File

@@ -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<void>((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<void>((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 {

View File

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

245
src/agents/pty-keys.ts Normal file
View File

@@ -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<string, string>([
["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;
}