diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index cf2e6ab217..6e093a18a4 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -64,7 +64,8 @@ import { createGitMutationService } from "../session/git-mutation/git-mutation-s import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js"; import type { ForgeService } from "../../services/forge-service.js"; import { areEquivalentPaths } from "../../utils/path.js"; -import type { TerminalManager } from "../../terminal/terminal-manager.js"; +import { createTerminalManager, type TerminalManager } from "../../terminal/terminal-manager.js"; +import { createWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index"; import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages"; @@ -1251,6 +1252,168 @@ describe("browser MCP tools", () => { describe("terminal MCP tools", () => { const logger = createTestLogger(); + it.each<[string, string, string, boolean]>([ + ["Down", "\x1b[B", "", false], + ["Up", "\x1b[A", "", false], + ["Left", "\x1b[D", "", false], + ["Right", "\x1b[C", "", false], + ["PageUp", "\x1b[5~", "", false], + ["PageDown", "\x1b[6~", "", false], + ["Home", "\x1b[H", "", false], + ["End", "\x1b[F", "", false], + ["Insert", "\x1b[2~", "", false], + ["Delete", "\x1b[3~", "", false], + ["F1", "\x1bOP", "", false], + ["F5", "\x1b[15~", "", false], + ["F12", "\x1b[24~", "", false], + ["S-Tab", "\x1b[Z", "", false], + ["C-r", "\x12", "", false], + ["M-b", "\x1bb", "", false], + ["C-M-Left", "\x1b[1;7D", "", false], + ["C-S-Up", "\x1b[1;6A", "", false], + ["C-Space", "\x00", "", false], + ["Enter", "\r", "", false], + ["Tab", "\t", "", false], + ["Escape", "\x1b", "", false], + ["Space", " ", "", false], + ["BSpace", "\x7f", "", false], + ["C-c", "\x03", "", false], + ["C-d", "\x04", "", false], + ["C-z", "\x1a", "", false], + ["C-l", "\x0c", "", false], + ["C-a", "\x01", "", false], + ["C-e", "\x05", "", false], + ["Down", "\x1bOB", "\x1b[?1h", false], + ["S-Enter", "\x1b[13;2u", "\x1b[=1;1u", false], + ["C-Enter", "\x1b[13;28;13;1;8;1_", "\x1b[?9001h", false], + ["echo hello\r", "echo hello\r", "", false], + ["\x1b[B", "\x1b[B", "", false], + ["UnknownKey", "UnknownKey", "", false], + ["constructor", "constructor", "", false], + ["__proto__", "__proto__", "", false], + ["", "", "", false], + ["Down", "Down", "\x1b[?1h", true], + ["C-r", "C-r", "", true], + ["S-Tab", "S-Tab", "", true], + ])( + "sends terminal token %j as %j (preamble=%j, literal=%s)", + async (keys, data, preamble, literal) => { + const { agentManager, agentStorage } = createTestDeps(); + const send = vi.fn(); + const terminalManager = createTerminalManagerStub({ + getTerminal: vi.fn().mockReturnValue({ + send, + getReplayPreamble: () => preamble, + }), + }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createClaudeOnlyManager(), + terminalManager, + logger, + }); + + try { + const response = await registeredTool(server, "send_terminal_keys").handler({ + terminalId: "term-1", + keys, + literal, + }); + expect(response.structuredContent).toEqual({ success: true }); + expect(send).toHaveBeenCalledExactlyOnceWith({ type: "input", data }); + } finally { + await server.close(); + } + }, + ); + + it.each([ + ["in-process", createTerminalManager], + ["worker", createWorkerTerminalManager], + ] as const)( + "delivers MCP key tokens through %s after terminal input modes change", + async (_name, createManager) => { + const { agentManager, agentStorage } = createTestDeps(); + const terminalManager = createManager(); + const terminal = await terminalManager.createTerminal({ + cwd: process.cwd(), + workspaceId: "ws-terminal-keys", + command: process.execPath, + args: [ + "-e", + `process.stdin.setRawMode(true); + process.stdout.write("READY\\r\\n"); + process.stdin.on("data", data => { + if (data.toString() === "application") { + process.stdout.write("\\x1b[?1hAPPLICATION_READY\\r\\n"); + return; + } + if (data.toString() === "normal") { + process.stdout.write("\\x1b[?1lNORMAL_READY\\r\\n"); + return; + } + process.stdout.write("HEX:" + data.toString("hex") + "\\r\\n"); + });`, + ], + }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createClaudeOnlyManager(), + terminalManager, + logger, + }); + + let output = ""; + const unsubscribe = terminal.subscribe((message) => { + if (message.type === "output") { + output += message.data; + } + }); + try { + await vi.waitFor( + async () => { + const capture = await terminalManager.captureTerminal(terminal.id); + expect(capture.lines).toContain("READY"); + }, + { timeout: 5000 }, + ); + const tool = registeredTool(server, "send_terminal_keys"); + await tool.handler({ terminalId: terminal.id, keys: "application", literal: true }); + await vi.waitFor(() => expect(output).toContain("APPLICATION_READY")); + for (const [keys, hex] of [ + ["Down", "1b4f42"], + ["Up", "1b4f41"], + ["Left", "1b4f44"], + ["Right", "1b4f43"], + ["PageDown", "1b5b367e"], + ["S-Tab", "1b5b5a"], + ["C-r", "12"], + ["M-b", "1b62"], + ]) { + await tool.handler({ terminalId: terminal.id, keys }); + await vi.waitFor(async () => { + const capture = await terminalManager.captureTerminal(terminal.id); + expect(capture.lines.join("\n")).toContain(`HEX:${hex}`); + }); + } + await tool.handler({ terminalId: terminal.id, keys: "normal", literal: true }); + await vi.waitFor(() => expect(output).toContain("NORMAL_READY")); + await tool.handler({ terminalId: terminal.id, keys: "Down" }); + await vi.waitFor(async () => { + const capture = await terminalManager.captureTerminal(terminal.id); + expect(capture.lines).toContain("HEX:1b5b42"); + }); + } finally { + unsubscribe(); + await server.close(); + await terminalManager.killTerminalAndWait(terminal.id); + terminalManager.killAll(); + } + }, + ); + it("captures terminal output through the terminal manager authority", async () => { const { agentManager, agentStorage } = createTestDeps(); const captureTerminal = vi.fn().mockResolvedValue({ diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 64d36d3b52..34a8d5ebeb 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -39,6 +39,8 @@ import type { FirstAgentContext } from "../../messages.js"; import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence"; import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; +import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input"; +import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode"; import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js"; import { computeNextRunAt, validateScheduleCadence } from "../../schedule/cron.js"; import { @@ -594,37 +596,44 @@ const TerminalSummarySchema = z.object({ cwd: z.string(), }); -function resolveTerminalKeyToken(key: string, literal: boolean): string { +interface TerminalKeyTokenInput { + key: string; + literal: boolean; + preamble: string; +} + +function resolveTerminalKeyToken({ key, literal, preamble }: TerminalKeyTokenInput): string { if (literal) { return key; } - switch (key) { - case "Enter": - return "\r"; - case "Tab": - return "\t"; - case "Escape": - return "\u001b"; - case "Space": - return " "; - case "BSpace": - return "\u007f"; - case "C-c": - return "\u0003"; - case "C-d": - return "\u0004"; - case "C-z": - return "\u001a"; - case "C-l": - return "\u000c"; - case "C-a": - return "\u0001"; - case "C-e": - return "\u0005"; - default: - return key; + const match = /^((?:[CMS]-)*)(.+)$/.exec(key); + if (!match) { + return key; } + const [, modifiers, token] = match; + const aliases: Record = { + Up: "ArrowUp", + Down: "ArrowDown", + Left: "ArrowLeft", + Right: "ArrowRight", + BSpace: "Backspace", + Space: " ", + }; + const inputMode = new TerminalInputModeTracker(); + inputMode.feed(preamble); + const encodedKey = Object.hasOwn(aliases, token) ? aliases[token] : token; + return ( + encodeTerminalKeyInput( + { + key: encodedKey, + ctrl: modifiers.includes("C-"), + alt: modifiers.includes("M-"), + shift: modifiers.includes("S-"), + }, + { inputMode: inputMode.getState() }, + ) || key + ); } export function createPaseoToolCatalog(options: PaseoToolHostDependencies): PaseoToolCatalog { @@ -2591,7 +2600,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase "send_terminal_keys", { title: "Send terminal keys", - description: "Send literal text or special key tokens to a terminal session.", + description: + "Send text or a key token: Enter, Tab, Escape, Space, BSpace, Up, Down, Left, Right, Home, End, Insert, Delete, PageUp, PageDown, or F1-F12. Combine C- (Ctrl), M- (Alt), and S- (Shift) prefixes, e.g. C-r, S-Tab, or C-M-Left. Set literal=true to send the text unchanged.", inputSchema: { terminalId: z.string(), keys: z.string(), @@ -2613,7 +2623,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase terminal.send({ type: "input", - data: resolveTerminalKeyToken(keys, literal), + data: resolveTerminalKeyToken({ + key: keys, + literal, + preamble: terminal.getReplayPreamble(), + }), }); return { diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index 4e16f8de04..a70d37c5b9 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -125,6 +125,7 @@ function watchTerminal(session: TerminalSession): void { type: "terminalMessage", terminalId: session.id, message: { type: "output", data: payload.toString("utf8"), revision }, + replayPreamble: session.getReplayPreamble(), }); }, }); @@ -144,6 +145,7 @@ function watchTerminal(session: TerminalSession): void { type: "terminalMessage", terminalId: session.id, message, + replayPreamble: session.getReplayPreamble(), }); }, // Creation already sends an authoritative state in terminalCreated. A second diff --git a/packages/server/src/terminal/terminal-worker-protocol.ts b/packages/server/src/terminal/terminal-worker-protocol.ts index 6b722c978a..5e7d1a30c0 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.ts @@ -121,6 +121,7 @@ export type TerminalWorkerEvent = type: "terminalMessage"; terminalId: string; message: ServerMessage; + replayPreamble: string; } | { type: "terminalExit"; diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index ca84cfe622..0a1adb2c44 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -67,8 +67,7 @@ interface WorkerTerminalRecord { state: TerminalState; activity: TerminalActivity | null; // Cached input-mode preamble from the worker (the authoritative tracker lives - // in the worker process). Refreshed on every getTerminalState response and on - // the snapshotReady event that precedes a live-restore replay. + // in the worker process). Updated with streamed output and snapshot responses. replayPreamble: string; exitInfo: TerminalExitInfo | null; messageListeners: Set<(msg: ServerMessage) => void>; @@ -356,11 +355,6 @@ export function createWorkerTerminalManager( }; }, getReplayPreamble(): string { - // Refreshed from every getTerminalState response, which the controller fetches - // before every snapshot replay (legacy + visible-snapshot restore). The one - // gap is restore.mode === "live", which replays without fetching state — there - // this can be stale/empty. No client sends "live" today; revisit (ship the - // preamble on the worker's snapshotReady) if one ever does. return record.replayPreamble; }, getTitle(): string | undefined { @@ -429,9 +423,7 @@ export function createWorkerTerminalManager( if (message.message.type === "snapshot") { record.state = message.message.state; } - if (message.message.type === "snapshotReady" && message.message.replayPreamble !== undefined) { - record.replayPreamble = message.message.replayPreamble; - } + record.replayPreamble = message.replayPreamble; for (const listener of Array.from(record.messageListeners)) { listener(message.message); }