diff --git a/.npmrc b/.npmrc index 0be5f7894..4b549ab44 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext +allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a31dfdbc..730aa0c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,7 @@ - macOS: log health refresh failures and recovery to make gateway issues easier to diagnose. - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b - macOS codesign: include camera entitlement so permission prompts work in the menu bar app. +- Agent tools: bash tool supports real TTY via `stdinMode: "pty"` with node-pty, warning + fallback on load/start failure. - Agent tools: map `camera.snap` JPEG payloads to `image/jpeg` to avoid MIME mismatch errors. - Tests: cover `camera.snap` MIME mapping to prevent image/png vs image/jpeg mismatches. - macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images. diff --git a/docs/bash.md b/docs/bash.md new file mode 100644 index 000000000..56f787bac --- /dev/null +++ b/docs/bash.md @@ -0,0 +1,44 @@ +--- +summary: "Bash tool usage, stdin modes, and TTY support" +read_when: + - Using or modifying the bash tool + - Debugging stdin or TTY behavior +--- + +# Bash tool + +Run shell commands in the workspace. Supports foreground + background execution via `process`. + +## Parameters + +- `command` (required) +- `yieldMs` (default 20000): auto-background after delay +- `background` (bool): background immediately +- `timeout` (seconds, default 1800): kill on expiry +- `stdinMode` (`pipe` | `pty`): + - `pipe` (default): classic stdin/stdout/stderr pipes + - `pty`: real TTY via node-pty (merged stdout/stderr) + +## TTY mode (`stdinMode: "pty"`) + +- Uses node-pty if available. If node-pty fails to load/start, the tool warns and falls back to `pipe`. +- Output streams are merged (no separate stderr). +- `process write` sends raw input; `eof: true` sends Ctrl-D (`\x04`). + +## Examples + +Foreground: +```json +{"tool":"bash","command":"ls -la"} +``` + +Background + poll: +```json +{"tool":"bash","command":"npm run build","yieldMs":1000} +{"tool":"process","action":"poll","sessionId":""} +``` + +TTY command: +```json +{"tool":"bash","command":"htop","stdinMode":"pty","background":true} +``` diff --git a/src/agents/bash-tools.pty.test.ts b/src/agents/bash-tools.pty.test.ts new file mode 100644 index 000000000..161be25ad --- /dev/null +++ b/src/agents/bash-tools.pty.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; + +describe("bash tool pty mode", () => { + it("falls back to pipe with warning when node-pty fails to load", async () => { + vi.resetModules(); + vi.doMock("node-pty", () => { + throw new Error("boom"); + }); + + const { createBashTool } = await import("./bash-tools.js"); + const tool = createBashTool({ backgroundMs: 10, timeoutSec: 1 }); + const result = await tool.execute("call", { + command: "echo test", + stdinMode: "pty", + }); + + const text = result.content.find((c) => c.type === "text")?.text ?? ""; + expect(text).toContain("Warning: node-pty failed to load"); + expect(text).toContain("falling back to pipe mode."); + + vi.doUnmock("node-pty"); + }); + + it("uses node-pty when available", async () => { + vi.resetModules(); + const spawn = vi.fn(() => { + let onData: ((data: string) => void) | undefined; + let onExit: + | ((event: { exitCode: number | null; signal?: number | null }) => void) + | undefined; + const pty = { + pid: 4321, + onData: (cb: (data: string) => void) => { + onData = cb; + }, + onExit: ( + cb: (event: { exitCode: number | null; signal?: number | null }) => void, + ) => { + onExit = cb; + }, + write: vi.fn(), + kill: vi.fn(), + }; + setTimeout(() => { + onData?.("hello\n"); + onExit?.({ exitCode: 0, signal: null }); + }, 10); + return pty; + }); + vi.doMock("node-pty", () => ({ spawn })); + + const { createBashTool } = await import("./bash-tools.js"); + const tool = createBashTool({ backgroundMs: 10, timeoutSec: 1 }); + const result = await tool.execute("call", { + command: "ignored", + stdinMode: "pty", + }); + + const text = result.content.find((c) => c.type === "text")?.text ?? ""; + expect(text).toContain("hello"); + expect(text).not.toContain("Warning:"); + + vi.doUnmock("node-pty"); + }); +}); diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts index 907bb8c7c..88269175c 100644 --- a/src/agents/bash-tools.ts +++ b/src/agents/bash-tools.ts @@ -34,13 +34,14 @@ const DEFAULT_MAX_OUTPUT = clampNumber( const DEFAULT_PTY_NAME = "xterm-256color"; type PtyModule = typeof import("node-pty"); -let ptyModulePromise: Promise | null = null; +type PtyLoadResult = { module: PtyModule | null; error?: unknown }; +let ptyModulePromise: Promise | null = null; -async function loadPtyModule(): Promise { +async function loadPtyModule(): Promise { if (!ptyModulePromise) { ptyModulePromise = import("node-pty") - .then((mod) => mod) - .catch(() => null); + .then((mod) => ({ module: mod })) + .catch((error) => ({ module: null, error })); } return ptyModulePromise; } @@ -166,10 +167,11 @@ export function createBashTool( let pty: IPty | undefined; if (stdinMode === "pty") { - const ptyModule = await loadPtyModule(); + const { module: ptyModule, error: ptyError } = await loadPtyModule(); if (!ptyModule) { warning = - "Warning: node-pty failed to load; falling back to pipe mode."; + `Warning: node-pty failed to load${formatPtyError(ptyError)}; ` + + "falling back to pipe mode."; stdinMode = "pipe"; } else { const ptyEnv = { @@ -184,9 +186,10 @@ export function createBashTool( cols: 120, rows: 30, }); - } catch { + } catch (error) { warning = - "Warning: node-pty failed to start; falling back to pipe mode."; + `Warning: node-pty failed to start${formatPtyError(error)}; ` + + "falling back to pipe mode."; stdinMode = "pipe"; } } @@ -886,6 +889,20 @@ function killSession(session: { } } +function formatPtyError(error: unknown) { + if (!error) return ""; + if (typeof error === "string") return ` (${error})`; + if (error instanceof Error) { + const firstLine = error.message.split(/\r?\n/)[0]?.trim(); + return firstLine ? ` (${firstLine})` : ""; + } + try { + return ` (${JSON.stringify(error)})`; + } catch { + return ""; + } +} + function clampNumber( value: number | undefined, defaultValue: number,