diff --git a/CHANGELOG.md b/CHANGELOG.md index 083967884..faa93275f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - 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. +- Tools: add `process submit` helper to send CR 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 f67e354aa..4a6c3d372 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -46,6 +46,11 @@ Send keys (tmux-style): {"tool":"process","action":"send-keys","sessionId":"","keys":["Up","Up","Enter"]} ``` +Submit (send CR only): +```json +{"tool":"process","action":"submit","sessionId":""} +``` + Paste (bracketed by default): ```json {"tool":"process","action":"paste","sessionId":"","text":"line1\nline2\n"} diff --git a/src/agents/bash-tools.process.send-keys.test.ts b/src/agents/bash-tools.process.send-keys.test.ts index 1f4ab44a9..1c120b615 100644 --- a/src/agents/bash-tools.process.send-keys.test.ts +++ b/src/agents/bash-tools.process.send-keys.test.ts @@ -44,3 +44,36 @@ test("process send-keys encodes Enter for pty sessions", async () => { throw new Error("PTY session did not exit after send-keys"); }); + +test("process submit sends CR for pty sessions", async () => { + const execTool = createExecTool(); + const processTool = createProcessTool(); + const result = await execTool.execute("toolcall", { + command: + "node -e \"process.stdin.on('data', d => { if (d.includes(13)) { process.stdout.write('submitted'); 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: "submit", + sessionId, + }); + + 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("submitted"); + return; + } + } + + throw new Error("PTY session did not exit after submit"); +}); diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index 88c0b37ea..6322fd202 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -58,11 +58,22 @@ export function createProcessTool( return { name: "process", label: "process", - description: "Manage running exec sessions: list, poll, log, write, send-keys, paste, kill.", + description: + "Manage running exec sessions: list, poll, log, write, send-keys, submit, paste, kill.", parameters: processSchema, execute: async (_toolCallId, args) => { const params = args as { - action: "list" | "poll" | "log" | "write" | "send-keys" | "paste" | "kill" | "clear" | "remove"; + action: + | "list" + | "poll" + | "log" + | "write" + | "send-keys" + | "submit" + | "paste" + | "kill" + | "clear" + | "remove"; sessionId?: string; data?: string; keys?: string[]; @@ -429,6 +440,62 @@ export function createProcessTool( }; } + case "submit": { + 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" }, + }; + } + await new Promise((resolve, reject) => { + stdin.write("\r", (err) => { + if (err) reject(err); + else resolve(); + }); + }); + return { + content: [ + { + type: "text", + text: `Submitted session ${params.sessionId} (sent CR).`, + }, + ], + details: { + status: "running", + sessionId: params.sessionId, + name: scopedSession ? deriveSessionName(scopedSession.command) : undefined, + }, + }; + } + case "paste": { if (!scopedSession) { return {