feat: add tmux-style process key helpers
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
45
src/agents/bash-tools.process.send-keys.test.ts
Normal file
45
src/agents/bash-tools.process.send-keys.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
33
src/agents/pty-keys.test.ts
Normal file
33
src/agents/pty-keys.test.ts
Normal 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
245
src/agents/pty-keys.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user