From 3450bbaf31a0d1f7f577c7edede0a1c71b6d400d Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Sat, 3 Jan 2026 15:02:12 +0200 Subject: [PATCH 01/14] chore(deps): add bun-pty for PTY support --- bun.lock | 3 +++ package.json | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 8b80a7b..d83a4ab 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "@vtemian/opencode-config", "dependencies": { "@opencode-ai/plugin": "^1.0.224", + "bun-pty": "^0.4.5", }, "devDependencies": { "@biomejs/biome": "^2.3.10", @@ -39,6 +40,8 @@ "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "bun-pty": ["bun-pty@0.4.5", "", {}, "sha512-r8NL1C+z0Dicl9gyi0QV0DAPEBgoKO5CJuecbeS8fpfEkxBHy8XrJ7ibVBS+YRLWjcky3EKl8BY7nY+l4Jv8DQ=="], + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/package.json b/package.json index b639bd6..4ef0142 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "url": "https://github.com/vtemian/micode/issues" }, "dependencies": { - "@opencode-ai/plugin": "^1.0.224" + "@opencode-ai/plugin": "^1.0.224", + "bun-pty": "^0.4.5" }, "devDependencies": { "@biomejs/biome": "^2.3.10", From ba8e767439f6ebfa45ecef82c454777eedf4e9f5 Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Sat, 3 Jan 2026 15:30:09 +0200 Subject: [PATCH 02/14] feat(pty): implement PTY tools for background terminal sessions - Add RingBuffer for output capture with search support - Add PTYManager for session lifecycle management - Add pty_spawn, pty_write, pty_read, pty_list, pty_kill tools - Add comprehensive test coverage for all components --- src/tools/pty/buffer.ts | 48 ++++++++++ src/tools/pty/index.ts | 34 +++++++ src/tools/pty/manager.ts | 159 ++++++++++++++++++++++++++++++++ src/tools/pty/tools/kill.ts | 68 ++++++++++++++ src/tools/pty/tools/list.ts | 55 +++++++++++ src/tools/pty/tools/read.ts | 152 ++++++++++++++++++++++++++++++ src/tools/pty/tools/spawn.ts | 77 ++++++++++++++++ src/tools/pty/tools/write.ts | 95 +++++++++++++++++++ src/tools/pty/types.ts | 62 +++++++++++++ tests/tools/pty/buffer.test.ts | 71 ++++++++++++++ tests/tools/pty/kill.test.ts | 87 +++++++++++++++++ tests/tools/pty/list.test.ts | 66 +++++++++++++ tests/tools/pty/manager.test.ts | 138 +++++++++++++++++++++++++++ tests/tools/pty/read.test.ts | 88 ++++++++++++++++++ tests/tools/pty/spawn.test.ts | 51 ++++++++++ tests/tools/pty/write.test.ts | 66 +++++++++++++ 16 files changed, 1317 insertions(+) create mode 100644 src/tools/pty/buffer.ts create mode 100644 src/tools/pty/index.ts create mode 100644 src/tools/pty/manager.ts create mode 100644 src/tools/pty/tools/kill.ts create mode 100644 src/tools/pty/tools/list.ts create mode 100644 src/tools/pty/tools/read.ts create mode 100644 src/tools/pty/tools/spawn.ts create mode 100644 src/tools/pty/tools/write.ts create mode 100644 src/tools/pty/types.ts create mode 100644 tests/tools/pty/buffer.test.ts create mode 100644 tests/tools/pty/kill.test.ts create mode 100644 tests/tools/pty/list.test.ts create mode 100644 tests/tools/pty/manager.test.ts create mode 100644 tests/tools/pty/read.test.ts create mode 100644 tests/tools/pty/spawn.test.ts create mode 100644 tests/tools/pty/write.test.ts diff --git a/src/tools/pty/buffer.ts b/src/tools/pty/buffer.ts new file mode 100644 index 0000000..e5d5964 --- /dev/null +++ b/src/tools/pty/buffer.ts @@ -0,0 +1,48 @@ +// src/tools/pty/buffer.ts +import type { SearchMatch } from "./types"; + +const DEFAULT_MAX_LINES = parseInt(process.env.PTY_MAX_BUFFER_LINES || "50000", 10); + +export class RingBuffer { + private lines: string[] = []; + private maxLines: number; + + constructor(maxLines: number = DEFAULT_MAX_LINES) { + this.maxLines = maxLines; + } + + append(data: string): void { + const newLines = data.split("\n"); + for (const line of newLines) { + this.lines.push(line); + if (this.lines.length > this.maxLines) { + this.lines.shift(); + } + } + } + + read(offset: number = 0, limit?: number): string[] { + const start = Math.max(0, offset); + const end = limit !== undefined ? start + limit : this.lines.length; + return this.lines.slice(start, end); + } + + search(pattern: RegExp): SearchMatch[] { + const matches: SearchMatch[] = []; + for (let i = 0; i < this.lines.length; i++) { + const line = this.lines[i]; + if (line !== undefined && pattern.test(line)) { + matches.push({ lineNumber: i + 1, text: line }); + } + } + return matches; + } + + get length(): number { + return this.lines.length; + } + + clear(): void { + this.lines = []; + } +} diff --git a/src/tools/pty/index.ts b/src/tools/pty/index.ts new file mode 100644 index 0000000..27426e0 --- /dev/null +++ b/src/tools/pty/index.ts @@ -0,0 +1,34 @@ +// src/tools/pty/index.ts +export { PTYManager } from "./manager"; +export { RingBuffer } from "./buffer"; +export { createPtySpawnTool } from "./tools/spawn"; +export { createPtyWriteTool } from "./tools/write"; +export { createPtyReadTool } from "./tools/read"; +export { createPtyListTool } from "./tools/list"; +export { createPtyKillTool } from "./tools/kill"; +export type { + PTYSession, + PTYSessionInfo, + PTYStatus, + SpawnOptions, + ReadResult, + SearchMatch, + SearchResult, +} from "./types"; + +import type { PTYManager } from "./manager"; +import { createPtySpawnTool } from "./tools/spawn"; +import { createPtyWriteTool } from "./tools/write"; +import { createPtyReadTool } from "./tools/read"; +import { createPtyListTool } from "./tools/list"; +import { createPtyKillTool } from "./tools/kill"; + +export function createPtyTools(manager: PTYManager) { + return { + pty_spawn: createPtySpawnTool(manager), + pty_write: createPtyWriteTool(manager), + pty_read: createPtyReadTool(manager), + pty_list: createPtyListTool(manager), + pty_kill: createPtyKillTool(manager), + }; +} diff --git a/src/tools/pty/manager.ts b/src/tools/pty/manager.ts new file mode 100644 index 0000000..ec74ac4 --- /dev/null +++ b/src/tools/pty/manager.ts @@ -0,0 +1,159 @@ +// src/tools/pty/manager.ts +import { spawn, type IPty } from "bun-pty"; +import { RingBuffer } from "./buffer"; +import type { PTYSession, PTYSessionInfo, SpawnOptions, ReadResult, SearchResult } from "./types"; + +function generateId(): string { + const hex = Array.from(crypto.getRandomValues(new Uint8Array(4))) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return `pty_${hex}`; +} + +export class PTYManager { + private sessions: Map = new Map(); + + spawn(opts: SpawnOptions): PTYSessionInfo { + const id = generateId(); + const args = opts.args ?? []; + const workdir = opts.workdir ?? process.cwd(); + const env = { ...process.env, ...opts.env } as Record; + const title = opts.title ?? (`${opts.command} ${args.join(" ")}`.trim() || `Terminal ${id.slice(-4)}`); + + const ptyProcess: IPty = spawn(opts.command, args, { + name: "xterm-256color", + cols: 120, + rows: 40, + cwd: workdir, + env, + }); + + const buffer = new RingBuffer(); + const session: PTYSession = { + id, + title, + command: opts.command, + args, + workdir, + env: opts.env, + status: "running", + pid: ptyProcess.pid, + createdAt: new Date(), + parentSessionId: opts.parentSessionId, + buffer, + process: ptyProcess, + }; + + this.sessions.set(id, session); + + ptyProcess.onData((data: string) => { + buffer.append(data); + }); + + ptyProcess.onExit(({ exitCode }: { exitCode: number }) => { + if (session.status === "running") { + session.status = "exited"; + session.exitCode = exitCode; + } + }); + + return this.toInfo(session); + } + + write(id: string, data: string): boolean { + const session = this.sessions.get(id); + if (!session) { + return false; + } + if (session.status !== "running") { + return false; + } + session.process.write(data); + return true; + } + + read(id: string, offset: number = 0, limit?: number): ReadResult | null { + const session = this.sessions.get(id); + if (!session) { + return null; + } + const lines = session.buffer.read(offset, limit); + const totalLines = session.buffer.length; + const hasMore = offset + lines.length < totalLines; + return { lines, totalLines, offset, hasMore }; + } + + search(id: string, pattern: RegExp, offset: number = 0, limit?: number): SearchResult | null { + const session = this.sessions.get(id); + if (!session) { + return null; + } + const allMatches = session.buffer.search(pattern); + const totalMatches = allMatches.length; + const totalLines = session.buffer.length; + const paginatedMatches = limit !== undefined ? allMatches.slice(offset, offset + limit) : allMatches.slice(offset); + const hasMore = offset + paginatedMatches.length < totalMatches; + return { matches: paginatedMatches, totalMatches, totalLines, offset, hasMore }; + } + + list(): PTYSessionInfo[] { + return Array.from(this.sessions.values()).map((s) => this.toInfo(s)); + } + + get(id: string): PTYSessionInfo | null { + const session = this.sessions.get(id); + return session ? this.toInfo(session) : null; + } + + kill(id: string, cleanup: boolean = false): boolean { + const session = this.sessions.get(id); + if (!session) { + return false; + } + + if (session.status === "running") { + try { + session.process.kill(); + } catch { + // Process may already be dead + } + session.status = "killed"; + } + + if (cleanup) { + session.buffer.clear(); + this.sessions.delete(id); + } + + return true; + } + + cleanupBySession(parentSessionId: string): void { + for (const [id, session] of this.sessions) { + if (session.parentSessionId === parentSessionId) { + this.kill(id, true); + } + } + } + + cleanupAll(): void { + for (const id of this.sessions.keys()) { + this.kill(id, true); + } + } + + private toInfo(session: PTYSession): PTYSessionInfo { + return { + id: session.id, + title: session.title, + command: session.command, + args: session.args, + workdir: session.workdir, + status: session.status, + exitCode: session.exitCode, + pid: session.pid, + createdAt: session.createdAt, + lineCount: session.buffer.length, + }; + } +} diff --git a/src/tools/pty/tools/kill.ts b/src/tools/pty/tools/kill.ts new file mode 100644 index 0000000..605ec76 --- /dev/null +++ b/src/tools/pty/tools/kill.ts @@ -0,0 +1,68 @@ +// src/tools/pty/tools/kill.ts +import { tool } from "@opencode-ai/plugin/tool"; +import type { PTYManager } from "../manager"; + +const DESCRIPTION = `Terminates a PTY session and optionally cleans up its buffer. + +Use this tool to: +- Stop a running process (sends SIGTERM) +- Clean up an exited session to free memory +- Remove a session from the list + +Usage: +- \`id\`: The PTY session ID (from pty_spawn or pty_list) +- \`cleanup\`: If true, removes the session and frees the buffer (default: false) + +Behavior: +- If the session is running, it will be killed (status becomes "killed") +- If cleanup=false (default), the session remains in the list with its output buffer intact +- If cleanup=true, the session is removed entirely and the buffer is freed +- Keeping sessions without cleanup allows you to compare logs between runs + +Tips: +- Use cleanup=false if you might want to read the output later +- Use cleanup=true when you're done with the session entirely +- To send Ctrl+C instead of killing, use pty_write with data="\\x03" + +Examples: +- Kill but keep logs: cleanup=false (or omit) +- Kill and remove: cleanup=true`; + +export function createPtyKillTool(manager: PTYManager) { + return tool({ + description: DESCRIPTION, + args: { + id: tool.schema.string().describe("The PTY session ID (e.g., pty_a1b2c3d4)"), + cleanup: tool.schema + .boolean() + .optional() + .describe("If true, removes the session and frees the buffer (default: false)"), + }, + execute: async (args) => { + const session = manager.get(args.id); + if (!session) { + throw new Error(`PTY session '${args.id}' not found. Use pty_list to see active sessions.`); + } + + const wasRunning = session.status === "running"; + const cleanup = args.cleanup ?? false; + const success = manager.kill(args.id, cleanup); + + if (!success) { + throw new Error(`Failed to kill PTY session '${args.id}'.`); + } + + const action = wasRunning ? "Killed" : "Cleaned up"; + const cleanupNote = cleanup ? " (session removed)" : " (session retained for log access)"; + + return [ + ``, + `${action}: ${args.id}${cleanupNote}`, + `Title: ${session.title}`, + `Command: ${session.command} ${session.args.join(" ")}`, + `Final line count: ${session.lineCount}`, + ``, + ].join("\n"); + }, + }); +} diff --git a/src/tools/pty/tools/list.ts b/src/tools/pty/tools/list.ts new file mode 100644 index 0000000..de7224d --- /dev/null +++ b/src/tools/pty/tools/list.ts @@ -0,0 +1,55 @@ +// src/tools/pty/tools/list.ts +import { tool } from "@opencode-ai/plugin/tool"; +import type { PTYManager } from "../manager"; + +const DESCRIPTION = `Lists all PTY sessions (active and exited). + +Use this tool to: +- See all running and exited PTY sessions +- Get session IDs for use with other pty_* tools +- Check the status and output line count of each session +- Monitor which processes are still running + +Returns for each session: +- \`id\`: Unique identifier for use with other tools +- \`title\`: Human-readable name +- \`command\`: The command that was executed +- \`status\`: Current status (running, exited, killed) +- \`exitCode\`: Exit code (if exited/killed) +- \`pid\`: Process ID +- \`lineCount\`: Number of lines in the output buffer +- \`createdAt\`: When the session was created + +Tips: +- Use the session ID with pty_read, pty_write, or pty_kill +- Sessions remain in the list after exit until explicitly cleaned up with pty_kill +- This allows you to compare output from multiple sessions`; + +export function createPtyListTool(manager: PTYManager) { + return tool({ + description: DESCRIPTION, + args: {}, + execute: async () => { + const sessions = manager.list(); + + if (sessions.length === 0) { + return "\nNo active PTY sessions.\n"; + } + + const lines = [""]; + for (const session of sessions) { + const exitInfo = session.exitCode !== undefined ? ` (exit: ${session.exitCode})` : ""; + lines.push(`[${session.id}] ${session.title}`); + lines.push(` Command: ${session.command} ${session.args.join(" ")}`); + lines.push(` Status: ${session.status}${exitInfo}`); + lines.push(` PID: ${session.pid} | Lines: ${session.lineCount} | Workdir: ${session.workdir}`); + lines.push(` Created: ${session.createdAt.toISOString()}`); + lines.push(""); + } + lines.push(`Total: ${sessions.length} session(s)`); + lines.push(""); + + return lines.join("\n"); + }, + }); +} diff --git a/src/tools/pty/tools/read.ts b/src/tools/pty/tools/read.ts new file mode 100644 index 0000000..313fd55 --- /dev/null +++ b/src/tools/pty/tools/read.ts @@ -0,0 +1,152 @@ +// src/tools/pty/tools/read.ts +import { tool } from "@opencode-ai/plugin/tool"; +import type { PTYManager } from "../manager"; + +const DESCRIPTION = `Reads output from a PTY session's buffer. + +The PTY maintains a rolling buffer of output lines. Use offset and limit to paginate through the output, similar to reading a file. + +Usage: +- \`id\`: The PTY session ID (from pty_spawn or pty_list) +- \`offset\`: Line number to start reading from (0-based, defaults to 0) +- \`limit\`: Number of lines to read (defaults to 500) +- \`pattern\`: Regex pattern to filter lines (optional) +- \`ignoreCase\`: Case-insensitive pattern matching (default: false) + +Returns: +- Numbered lines of output (similar to cat -n format) +- Total line count in the buffer +- Indicator if more lines are available + +The buffer stores up to PTY_MAX_BUFFER_LINES (default: 50000) lines. Older lines are discarded when the limit is reached. + +Pattern Filtering: +- When \`pattern\` is set, lines are FILTERED FIRST using the regex, then offset/limit apply to the MATCHES +- Original line numbers are preserved so you can see where matches occurred in the buffer +- Supports full regex syntax (e.g., "error", "ERROR|WARN", "failed.*connection", etc.) +- If the pattern is invalid, an error message is returned explaining the issue +- If no lines match the pattern, a clear message indicates zero matches + +Tips: +- To see the latest output, use a high offset or omit offset to read from the start +- To tail recent output, calculate offset as (totalLines - N) where N is how many recent lines you want +- Lines longer than 2000 characters are truncated +- Empty output may mean the process hasn't produced output yet + +Examples: +- Read first 100 lines: offset=0, limit=100 +- Read lines 500-600: offset=500, limit=100 +- Read all available: omit both parameters +- Find errors: pattern="error", ignoreCase=true +- Find specific log levels: pattern="ERROR|WARN|FATAL" +- First 10 matches only: pattern="error", limit=10`; + +const DEFAULT_LIMIT = 500; +const MAX_LINE_LENGTH = 2000; + +export function createPtyReadTool(manager: PTYManager) { + return tool({ + description: DESCRIPTION, + args: { + id: tool.schema.string().describe("The PTY session ID (e.g., pty_a1b2c3d4)"), + offset: tool.schema.number().optional().describe("Line number to start reading from (0-based, defaults to 0)"), + limit: tool.schema.number().optional().describe("Number of lines to read (defaults to 500)"), + pattern: tool.schema.string().optional().describe("Regex pattern to filter lines"), + ignoreCase: tool.schema.boolean().optional().describe("Case-insensitive pattern matching (default: false)"), + }, + execute: async (args) => { + const session = manager.get(args.id); + if (!session) { + throw new Error(`PTY session '${args.id}' not found. Use pty_list to see active sessions.`); + } + + const offset = args.offset ?? 0; + const limit = args.limit ?? DEFAULT_LIMIT; + + if (args.pattern) { + let regex: RegExp; + try { + regex = new RegExp(args.pattern, args.ignoreCase ? "i" : ""); + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + throw new Error(`Invalid regex pattern '${args.pattern}': ${error}`); + } + + const result = manager.search(args.id, regex, offset, limit); + if (!result) { + throw new Error(`PTY session '${args.id}' not found.`); + } + + if (result.matches.length === 0) { + return [ + ``, + `No lines matched the pattern '${args.pattern}'.`, + `Total lines in buffer: ${result.totalLines}`, + ``, + ].join("\n"); + } + + const formattedLines = result.matches.map((match) => { + const lineNum = match.lineNumber.toString().padStart(5, "0"); + const truncatedLine = + match.text.length > MAX_LINE_LENGTH ? `${match.text.slice(0, MAX_LINE_LENGTH)}...` : match.text; + return `${lineNum}| ${truncatedLine}`; + }); + + const output = [ + ``, + ...formattedLines, + "", + ]; + + if (result.hasMore) { + output.push( + `(${result.matches.length} of ${result.totalMatches} matches shown. Use offset=${offset + result.matches.length} to see more.)`, + ); + } else { + output.push( + `(${result.totalMatches} match${result.totalMatches === 1 ? "" : "es"} from ${result.totalLines} total lines)`, + ); + } + output.push(``); + + return output.join("\n"); + } + + const result = manager.read(args.id, offset, limit); + if (!result) { + throw new Error(`PTY session '${args.id}' not found.`); + } + + if (result.lines.length === 0) { + return [ + ``, + `(No output available - buffer is empty)`, + `Total lines: ${result.totalLines}`, + ``, + ].join("\n"); + } + + const formattedLines = result.lines.map((line, index) => { + const lineNum = (result.offset + index + 1).toString().padStart(5, "0"); + const truncatedLine = line.length > MAX_LINE_LENGTH ? `${line.slice(0, MAX_LINE_LENGTH)}...` : line; + return `${lineNum}| ${truncatedLine}`; + }); + + const output = [``, ...formattedLines]; + + if (result.hasMore) { + output.push(""); + output.push( + `(Buffer has more lines. Use offset=${result.offset + result.lines.length} to read beyond line ${result.offset + result.lines.length})`, + ); + } else { + output.push(""); + output.push(`(End of buffer - total ${result.totalLines} lines)`); + } + output.push(``); + + return output.join("\n"); + }, + }); +} diff --git a/src/tools/pty/tools/spawn.ts b/src/tools/pty/tools/spawn.ts new file mode 100644 index 0000000..8274fe8 --- /dev/null +++ b/src/tools/pty/tools/spawn.ts @@ -0,0 +1,77 @@ +// src/tools/pty/tools/spawn.ts +import { tool } from "@opencode-ai/plugin/tool"; +import type { PTYManager } from "../manager"; + +const DESCRIPTION = `Spawns a new interactive PTY (pseudo-terminal) session that runs in the background. + +Unlike the built-in bash tool which runs commands synchronously and waits for completion, PTY sessions persist and allow you to: +- Run long-running processes (dev servers, watch modes, etc.) +- Send interactive input (including Ctrl+C, arrow keys, etc.) +- Read output at any time +- Manage multiple concurrent terminal sessions + +Usage: +- The \`command\` parameter is required (e.g., "npm", "python", "bash") +- Use \`args\` to pass arguments to the command (e.g., ["run", "dev"]) +- Use \`workdir\` to set the working directory (defaults to project root) +- Use \`env\` to set additional environment variables +- Use \`title\` to give the session a human-readable name +- The \`description\` parameter is required: a clear, concise 5-10 word description + +Returns the session info including: +- \`id\`: Unique identifier (pty_XXXXXXXX) for use with other pty_* tools +- \`pid\`: Process ID +- \`status\`: Current status ("running") + +After spawning, use: +- \`pty_write\` to send input to the PTY +- \`pty_read\` to read output from the PTY +- \`pty_list\` to see all active PTY sessions +- \`pty_kill\` to terminate the PTY + +Examples: +- Start a dev server: command="npm", args=["run", "dev"], title="Dev Server" +- Start a Python REPL: command="python3", title="Python REPL" +- Run tests in watch mode: command="npm", args=["test", "--", "--watch"]`; + +export function createPtySpawnTool(manager: PTYManager) { + return tool({ + description: DESCRIPTION, + args: { + command: tool.schema.string().describe("The command/executable to run"), + args: tool.schema.array(tool.schema.string()).optional().describe("Arguments to pass to the command"), + workdir: tool.schema.string().optional().describe("Working directory for the PTY session"), + env: tool.schema + .record(tool.schema.string(), tool.schema.string()) + .optional() + .describe("Additional environment variables"), + title: tool.schema.string().optional().describe("Human-readable title for the session"), + description: tool.schema + .string() + .describe("Clear, concise description of what this PTY session is for in 5-10 words"), + }, + execute: async (args, ctx) => { + const info = manager.spawn({ + command: args.command, + args: args.args, + workdir: args.workdir, + env: args.env, + title: args.title, + parentSessionId: ctx.sessionID, + }); + + const output = [ + ``, + `ID: ${info.id}`, + `Title: ${info.title}`, + `Command: ${info.command} ${info.args.join(" ")}`, + `Workdir: ${info.workdir}`, + `PID: ${info.pid}`, + `Status: ${info.status}`, + ``, + ].join("\n"); + + return output; + }, + }); +} diff --git a/src/tools/pty/tools/write.ts b/src/tools/pty/tools/write.ts new file mode 100644 index 0000000..15ff77b --- /dev/null +++ b/src/tools/pty/tools/write.ts @@ -0,0 +1,95 @@ +// src/tools/pty/tools/write.ts +import { tool } from "@opencode-ai/plugin/tool"; +import type { PTYManager } from "../manager"; + +const DESCRIPTION = `Sends input data to an active PTY session. + +Use this tool to: +- Type commands or text into an interactive terminal +- Send special key sequences (Ctrl+C, Enter, arrow keys, etc.) +- Respond to prompts in interactive programs + +Usage: +- \`id\`: The PTY session ID (from pty_spawn or pty_list) +- \`data\`: The input to send (text, commands, or escape sequences) + +Common escape sequences: +- Enter/newline: "\\n" or "\\r" +- Ctrl+C (interrupt): "\\x03" +- Ctrl+D (EOF): "\\x04" +- Ctrl+Z (suspend): "\\x1a" +- Tab: "\\t" +- Arrow Up: "\\x1b[A" +- Arrow Down: "\\x1b[B" +- Arrow Right: "\\x1b[C" +- Arrow Left: "\\x1b[D" + +Returns success or error message. + +Examples: +- Send a command: data="ls -la\\n" +- Interrupt a process: data="\\x03" +- Answer a prompt: data="yes\\n"`; + +/** + * Parse escape sequences in a string to their actual byte values. + * Handles: \n, \r, \t, \xNN (hex), \uNNNN (unicode), \\ + */ +function parseEscapeSequences(input: string): string { + return input.replace(/\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|[nrt\\])/g, (match, seq: string) => { + if (seq.startsWith("x")) { + return String.fromCharCode(parseInt(seq.slice(1), 16)); + } + if (seq.startsWith("u")) { + return String.fromCharCode(parseInt(seq.slice(1), 16)); + } + switch (seq) { + case "n": + return "\n"; + case "r": + return "\r"; + case "t": + return "\t"; + case "\\": + return "\\"; + default: + return match; + } + }); +} + +export function createPtyWriteTool(manager: PTYManager) { + return tool({ + description: DESCRIPTION, + args: { + id: tool.schema.string().describe("The PTY session ID (e.g., pty_a1b2c3d4)"), + data: tool.schema.string().describe("The input data to send to the PTY"), + }, + execute: async (args) => { + const session = manager.get(args.id); + if (!session) { + throw new Error(`PTY session '${args.id}' not found. Use pty_list to see active sessions.`); + } + + if (session.status !== "running") { + throw new Error(`Cannot write to PTY '${args.id}' - session status is '${session.status}'.`); + } + + // Parse escape sequences to actual bytes + const parsedData = parseEscapeSequences(args.data); + + const success = manager.write(args.id, parsedData); + if (!success) { + throw new Error(`Failed to write to PTY '${args.id}'.`); + } + + const preview = args.data.length > 50 ? `${args.data.slice(0, 50)}...` : args.data; + const displayPreview = preview + .replace(/\x03/g, "^C") + .replace(/\x04/g, "^D") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r"); + return `Sent ${args.data.length} bytes to ${args.id}: "${displayPreview}"`; + }, + }); +} diff --git a/src/tools/pty/types.ts b/src/tools/pty/types.ts new file mode 100644 index 0000000..8872914 --- /dev/null +++ b/src/tools/pty/types.ts @@ -0,0 +1,62 @@ +// src/tools/pty/types.ts +import type { RingBuffer } from "./buffer"; + +export type PTYStatus = "running" | "exited" | "killed"; + +export interface PTYSession { + id: string; + title: string; + command: string; + args: string[]; + workdir: string; + env?: Record; + status: PTYStatus; + exitCode?: number; + pid: number; + createdAt: Date; + parentSessionId: string; + buffer: RingBuffer; + process: import("bun-pty").IPty; +} + +export interface PTYSessionInfo { + id: string; + title: string; + command: string; + args: string[]; + workdir: string; + status: PTYStatus; + exitCode?: number; + pid: number; + createdAt: Date; + lineCount: number; +} + +export interface SpawnOptions { + command: string; + args?: string[]; + workdir?: string; + env?: Record; + title?: string; + parentSessionId: string; +} + +export interface ReadResult { + lines: string[]; + totalLines: number; + offset: number; + hasMore: boolean; +} + +export interface SearchMatch { + lineNumber: number; + text: string; +} + +export interface SearchResult { + matches: SearchMatch[]; + totalMatches: number; + totalLines: number; + offset: number; + hasMore: boolean; +} diff --git a/tests/tools/pty/buffer.test.ts b/tests/tools/pty/buffer.test.ts new file mode 100644 index 0000000..11f3190 --- /dev/null +++ b/tests/tools/pty/buffer.test.ts @@ -0,0 +1,71 @@ +// tests/tools/pty/buffer.test.ts +import { describe, it, expect } from "bun:test"; +import { RingBuffer } from "../../../src/tools/pty/buffer"; + +describe("RingBuffer", () => { + describe("append", () => { + it("should store appended lines", () => { + const buffer = new RingBuffer(100); + buffer.append("line1\nline2\nline3"); + + expect(buffer.length).toBe(3); + }); + + it("should evict oldest lines when max reached", () => { + const buffer = new RingBuffer(3); + buffer.append("line1\nline2\nline3\nline4\nline5"); + + expect(buffer.length).toBe(3); + const lines = buffer.read(0); + expect(lines).toEqual(["line3", "line4", "line5"]); + }); + }); + + describe("read", () => { + it("should return lines from offset", () => { + const buffer = new RingBuffer(100); + buffer.append("a\nb\nc\nd\ne"); + + const lines = buffer.read(2, 2); + expect(lines).toEqual(["c", "d"]); + }); + + it("should return all lines when no limit", () => { + const buffer = new RingBuffer(100); + buffer.append("a\nb\nc"); + + const lines = buffer.read(0); + expect(lines).toEqual(["a", "b", "c"]); + }); + }); + + describe("search", () => { + it("should find lines matching pattern", () => { + const buffer = new RingBuffer(100); + buffer.append("info: starting\nerror: failed\ninfo: done\nerror: timeout"); + + const matches = buffer.search(/error/); + expect(matches).toHaveLength(2); + expect(matches[0]).toEqual({ lineNumber: 2, text: "error: failed" }); + expect(matches[1]).toEqual({ lineNumber: 4, text: "error: timeout" }); + }); + + it("should return empty array when no matches", () => { + const buffer = new RingBuffer(100); + buffer.append("line1\nline2"); + + const matches = buffer.search(/notfound/); + expect(matches).toEqual([]); + }); + }); + + describe("clear", () => { + it("should remove all lines", () => { + const buffer = new RingBuffer(100); + buffer.append("line1\nline2"); + buffer.clear(); + + expect(buffer.length).toBe(0); + }); + }); +}); diff --git a/tests/tools/pty/kill.test.ts b/tests/tools/pty/kill.test.ts new file mode 100644 index 0000000..60cdbb9 --- /dev/null +++ b/tests/tools/pty/kill.test.ts @@ -0,0 +1,87 @@ +// tests/tools/pty/kill.test.ts +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { PTYManager } from "../../../src/tools/pty/manager"; +import { createPtyKillTool } from "../../../src/tools/pty/tools/kill"; +import { createPtySpawnTool } from "../../../src/tools/pty/tools/spawn"; + +describe("pty_kill tool", () => { + let manager: PTYManager; + let pty_kill: ReturnType; + let pty_spawn: ReturnType; + + beforeEach(() => { + manager = new PTYManager(); + pty_kill = createPtyKillTool(manager); + pty_spawn = createPtySpawnTool(manager); + }); + + afterEach(() => { + manager.cleanupAll(); + }); + + it("should have correct description", () => { + expect(pty_kill.description).toContain("Terminates"); + expect(pty_kill.description).toContain("PTY"); + }); + + it("should require id parameter", () => { + expect(pty_kill.args).toHaveProperty("id"); + }); + + it("should have optional cleanup parameter", () => { + expect(pty_kill.args).toHaveProperty("cleanup"); + }); + + it("should throw error for unknown session", async () => { + await expect(pty_kill.execute({ id: "pty_nonexistent" }, {} as any)).rejects.toThrow("not found"); + }); + + it("should kill a running session", async () => { + const spawnResult = await pty_spawn.execute( + { command: "sleep", args: ["10"], title: "Sleeper", description: "Test" }, + { sessionID: "test", messageID: "msg" } as any, + ); + + const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/); + const id = idMatch?.[1]; + expect(id).toBeDefined(); + + const result = await pty_kill.execute({ id: id! }, {} as any); + + expect(result).toContain(""); + expect(result).toContain("Killed:"); + expect(result).toContain(id!); + expect(result).toContain("Sleeper"); + expect(result).toContain(""); + }); + + it("should cleanup session when cleanup=true", async () => { + const spawnResult = await pty_spawn.execute({ command: "echo", args: ["test"], description: "Test" }, { + sessionID: "test", + messageID: "msg", + } as any); + + const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/); + const id = idMatch?.[1]; + + await pty_kill.execute({ id: id!, cleanup: true }, {} as any); + + const sessions = manager.list(); + expect(sessions).toHaveLength(0); + }); + + it("should retain session when cleanup=false", async () => { + const spawnResult = await pty_spawn.execute({ command: "echo", args: ["test"], description: "Test" }, { + sessionID: "test", + messageID: "msg", + } as any); + + const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/); + const id = idMatch?.[1]; + + await pty_kill.execute({ id: id!, cleanup: false }, {} as any); + + const sessions = manager.list(); + expect(sessions).toHaveLength(1); + }); +}); diff --git a/tests/tools/pty/list.test.ts b/tests/tools/pty/list.test.ts new file mode 100644 index 0000000..0582fad --- /dev/null +++ b/tests/tools/pty/list.test.ts @@ -0,0 +1,66 @@ +// tests/tools/pty/list.test.ts +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { PTYManager } from "../../../src/tools/pty/manager"; +import { createPtyListTool } from "../../../src/tools/pty/tools/list"; +import { createPtySpawnTool } from "../../../src/tools/pty/tools/spawn"; + +describe("pty_list tool", () => { + let manager: PTYManager; + let pty_list: ReturnType; + let pty_spawn: ReturnType; + + beforeEach(() => { + manager = new PTYManager(); + pty_list = createPtyListTool(manager); + pty_spawn = createPtySpawnTool(manager); + }); + + afterEach(() => { + manager.cleanupAll(); + }); + + it("should have correct description", () => { + expect(pty_list.description).toContain("PTY sessions"); + }); + + it("should return empty message when no sessions", async () => { + const result = await pty_list.execute({}, {} as any); + + expect(result).toContain(""); + expect(result).toContain("No active PTY sessions"); + expect(result).toContain(""); + }); + + it("should list all sessions", async () => { + await pty_spawn.execute({ command: "echo", args: ["1"], title: "First", description: "Test 1" }, { + sessionID: "test", + messageID: "msg", + } as any); + await pty_spawn.execute({ command: "echo", args: ["2"], title: "Second", description: "Test 2" }, { + sessionID: "test", + messageID: "msg", + } as any); + + const result = await pty_list.execute({}, {} as any); + + expect(result).toContain(""); + expect(result).toContain("First"); + expect(result).toContain("Second"); + expect(result).toContain("Total: 2 session(s)"); + expect(result).toContain(""); + }); + + it("should show session details", async () => { + await pty_spawn.execute({ command: "sleep", args: ["10"], title: "Sleeper", description: "Test" }, { + sessionID: "test", + messageID: "msg", + } as any); + + const result = await pty_list.execute({}, {} as any); + + expect(result).toContain("Command: sleep 10"); + expect(result).toContain("Status: running"); + expect(result).toContain("PID:"); + expect(result).toContain("Lines:"); + }); +}); diff --git a/tests/tools/pty/manager.test.ts b/tests/tools/pty/manager.test.ts new file mode 100644 index 0000000..2414c6f --- /dev/null +++ b/tests/tools/pty/manager.test.ts @@ -0,0 +1,138 @@ +// tests/tools/pty/manager.test.ts +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { PTYManager } from "../../../src/tools/pty/manager"; + +describe("PTYManager", () => { + let manager: PTYManager; + + beforeEach(() => { + manager = new PTYManager(); + }); + + afterEach(() => { + manager.cleanupAll(); + }); + + describe("spawn", () => { + it("should create a new PTY session", () => { + const info = manager.spawn({ + command: "echo", + args: ["hello"], + parentSessionId: "test-session", + }); + + expect(info.id).toMatch(/^pty_[a-f0-9]{8}$/); + expect(info.command).toBe("echo"); + expect(info.args).toEqual(["hello"]); + expect(info.status).toBe("running"); + expect(info.pid).toBeGreaterThan(0); + }); + + it("should use default title from command", () => { + const info = manager.spawn({ + command: "ls", + args: ["-la"], + parentSessionId: "test-session", + }); + + expect(info.title).toBe("ls -la"); + }); + + it("should use custom title when provided", () => { + const info = manager.spawn({ + command: "npm", + args: ["run", "dev"], + title: "Dev Server", + parentSessionId: "test-session", + }); + + expect(info.title).toBe("Dev Server"); + }); + }); + + describe("list", () => { + it("should return all sessions", () => { + manager.spawn({ command: "echo", args: ["1"], parentSessionId: "s1" }); + manager.spawn({ command: "echo", args: ["2"], parentSessionId: "s1" }); + + const sessions = manager.list(); + expect(sessions).toHaveLength(2); + }); + + it("should return empty array when no sessions", () => { + const sessions = manager.list(); + expect(sessions).toEqual([]); + }); + }); + + describe("get", () => { + it("should return session by id", () => { + const spawned = manager.spawn({ + command: "echo", + parentSessionId: "test", + }); + + const info = manager.get(spawned.id); + expect(info).not.toBeNull(); + expect(info?.id).toBe(spawned.id); + }); + + it("should return null for unknown id", () => { + const info = manager.get("pty_nonexistent"); + expect(info).toBeNull(); + }); + }); + + describe("write", () => { + it("should return false for unknown session", () => { + const result = manager.write("pty_nonexistent", "test"); + expect(result).toBe(false); + }); + }); + + describe("kill", () => { + it("should kill a running session", () => { + const info = manager.spawn({ + command: "sleep", + args: ["10"], + parentSessionId: "test", + }); + + const killed = manager.kill(info.id); + expect(killed).toBe(true); + + const updated = manager.get(info.id); + expect(updated?.status).toBe("killed"); + }); + + it("should cleanup session when cleanup=true", () => { + const info = manager.spawn({ + command: "echo", + parentSessionId: "test", + }); + + manager.kill(info.id, true); + + const sessions = manager.list(); + expect(sessions).toHaveLength(0); + }); + + it("should return false for unknown session", () => { + const result = manager.kill("pty_nonexistent"); + expect(result).toBe(false); + }); + }); + + describe("cleanupBySession", () => { + it("should cleanup all PTYs for a parent session", () => { + manager.spawn({ command: "echo", parentSessionId: "session-a" }); + manager.spawn({ command: "echo", parentSessionId: "session-a" }); + manager.spawn({ command: "echo", parentSessionId: "session-b" }); + + manager.cleanupBySession("session-a"); + + const sessions = manager.list(); + expect(sessions).toHaveLength(1); + }); + }); +}); diff --git a/tests/tools/pty/read.test.ts b/tests/tools/pty/read.test.ts new file mode 100644 index 0000000..63925ae --- /dev/null +++ b/tests/tools/pty/read.test.ts @@ -0,0 +1,88 @@ +// tests/tools/pty/read.test.ts +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { PTYManager } from "../../../src/tools/pty/manager"; +import { createPtyReadTool } from "../../../src/tools/pty/tools/read"; +import { createPtySpawnTool } from "../../../src/tools/pty/tools/spawn"; + +describe("pty_read tool", () => { + let manager: PTYManager; + let pty_read: ReturnType; + let pty_spawn: ReturnType; + + beforeEach(() => { + manager = new PTYManager(); + pty_read = createPtyReadTool(manager); + pty_spawn = createPtySpawnTool(manager); + }); + + afterEach(() => { + manager.cleanupAll(); + }); + + it("should have correct description", () => { + expect(pty_read.description).toContain("output"); + expect(pty_read.description).toContain("buffer"); + }); + + it("should require id parameter", () => { + expect(pty_read.args).toHaveProperty("id"); + }); + + it("should have optional offset, limit, pattern parameters", () => { + expect(pty_read.args).toHaveProperty("offset"); + expect(pty_read.args).toHaveProperty("limit"); + expect(pty_read.args).toHaveProperty("pattern"); + }); + + it("should throw error for unknown session", async () => { + await expect(pty_read.execute({ id: "pty_nonexistent" }, {} as any)).rejects.toThrow("not found"); + }); + + it("should read output from a session", async () => { + const spawnResult = await pty_spawn.execute({ command: "echo", args: ["hello world"], description: "Test echo" }, { + sessionID: "test", + messageID: "msg", + } as any); + + const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/); + const id = idMatch?.[1]; + expect(id).toBeDefined(); + + // Wait a bit for output + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result = await pty_read.execute({ id: id! }, {} as any); + + expect(result).toContain(""); + expect(result).toContain(id!); + }); + + it("should handle pattern filtering", async () => { + const spawnResult = await pty_spawn.execute( + { command: "echo", args: ["-e", "line1\\nerror: bad\\nline3"], description: "Test" }, + { sessionID: "test", messageID: "msg" } as any, + ); + + const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/); + const id = idMatch?.[1]; + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result = await pty_read.execute({ id: id!, pattern: "error" }, {} as any); + + expect(result).toContain("pattern="); + }); + + it("should throw error for invalid regex", async () => { + const spawnResult = await pty_spawn.execute({ command: "echo", args: ["test"], description: "Test" }, { + sessionID: "test", + messageID: "msg", + } as any); + + const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/); + const id = idMatch?.[1]; + + await expect(pty_read.execute({ id: id!, pattern: "[invalid" }, {} as any)).rejects.toThrow("Invalid regex"); + }); +}); diff --git a/tests/tools/pty/spawn.test.ts b/tests/tools/pty/spawn.test.ts new file mode 100644 index 0000000..527c0be --- /dev/null +++ b/tests/tools/pty/spawn.test.ts @@ -0,0 +1,51 @@ +// tests/tools/pty/spawn.test.ts +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { PTYManager } from "../../../src/tools/pty/manager"; +import { createPtySpawnTool } from "../../../src/tools/pty/tools/spawn"; + +describe("pty_spawn tool", () => { + let manager: PTYManager; + let pty_spawn: ReturnType; + + beforeEach(() => { + manager = new PTYManager(); + pty_spawn = createPtySpawnTool(manager); + }); + + afterEach(() => { + manager.cleanupAll(); + }); + + it("should have correct description", () => { + expect(pty_spawn.description).toContain("PTY"); + expect(pty_spawn.description).toContain("pseudo-terminal"); + }); + + it("should require command parameter", () => { + expect(pty_spawn.args).toHaveProperty("command"); + }); + + it("should have optional args, workdir, env, title parameters", () => { + expect(pty_spawn.args).toHaveProperty("args"); + expect(pty_spawn.args).toHaveProperty("workdir"); + expect(pty_spawn.args).toHaveProperty("env"); + expect(pty_spawn.args).toHaveProperty("title"); + }); + + it("should spawn a PTY and return formatted output", async () => { + const result = await pty_spawn.execute( + { + command: "echo", + args: ["hello"], + description: "Test echo command", + }, + { sessionID: "test-session", messageID: "msg-1" } as any, + ); + + expect(result).toContain(""); + expect(result).toContain(""); + expect(result).toContain("ID: pty_"); + expect(result).toContain("Command: echo hello"); + expect(result).toContain("Status: running"); + }); +}); diff --git a/tests/tools/pty/write.test.ts b/tests/tools/pty/write.test.ts new file mode 100644 index 0000000..a88dbce --- /dev/null +++ b/tests/tools/pty/write.test.ts @@ -0,0 +1,66 @@ +// tests/tools/pty/write.test.ts +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { PTYManager } from "../../../src/tools/pty/manager"; +import { createPtyWriteTool } from "../../../src/tools/pty/tools/write"; +import { createPtySpawnTool } from "../../../src/tools/pty/tools/spawn"; + +describe("pty_write tool", () => { + let manager: PTYManager; + let pty_write: ReturnType; + let pty_spawn: ReturnType; + + beforeEach(() => { + manager = new PTYManager(); + pty_write = createPtyWriteTool(manager); + pty_spawn = createPtySpawnTool(manager); + }); + + afterEach(() => { + manager.cleanupAll(); + }); + + it("should have correct description", () => { + expect(pty_write.description).toContain("input"); + expect(pty_write.description).toContain("PTY"); + }); + + it("should require id and data parameters", () => { + expect(pty_write.args).toHaveProperty("id"); + expect(pty_write.args).toHaveProperty("data"); + }); + + it("should throw error for unknown session", async () => { + await expect(pty_write.execute({ id: "pty_nonexistent", data: "test" }, {} as any)).rejects.toThrow("not found"); + }); + + it("should write to a running session", async () => { + const spawnResult = await pty_spawn.execute({ command: "cat", description: "Test cat" }, { + sessionID: "test", + messageID: "msg", + } as any); + + const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/); + const id = idMatch?.[1]; + expect(id).toBeDefined(); + + const result = await pty_write.execute({ id: id!, data: "hello\\n" }, {} as any); + + expect(result).toContain("Sent"); + expect(result).toContain(id!); + }); + + it("should parse escape sequences", async () => { + const spawnResult = await pty_spawn.execute({ command: "cat", description: "Test cat" }, { + sessionID: "test", + messageID: "msg", + } as any); + + const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/); + const id = idMatch?.[1]; + + // Send Ctrl+C + const result = await pty_write.execute({ id: id!, data: "\\x03" }, {} as any); + + expect(result).toContain("Sent"); + }); +}); From 18ddda554596ea7f3d299f10d4834780fea74ebf Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Sat, 3 Jan 2026 15:30:13 +0200 Subject: [PATCH 03/14] feat(pty): register PTY tools in main plugin --- src/index.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/index.ts b/src/index.ts index 708eede..0ad16e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,9 @@ import { createFileOpsTrackerHook } from "./hooks/file-ops-tracker"; // Background Task System import { BackgroundTaskManager, createBackgroundTaskTools } from "./tools/background-task"; +// PTY System +import { PTYManager, createPtyTools } from "./tools/pty"; + // Config loader import { loadMicodeConfig, mergeAgentConfigs } from "./config-loader"; @@ -100,6 +103,10 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => { const backgroundTaskManager = new BackgroundTaskManager(ctx); const backgroundTaskTools = createBackgroundTaskTools(backgroundTaskManager); + // PTY System + const ptyManager = new PTYManager(); + const ptyTools = createPtyTools(ptyManager); + return { // Tools tool: { @@ -109,6 +116,7 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => { look_at, artifact_search, ...backgroundTaskTools, + ...ptyTools, }, config: async (config) => { @@ -230,6 +238,14 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => { } } + // PTY cleanup on session delete + if (event.type === "session.deleted") { + const props = event.properties as { info?: { id?: string } } | undefined; + if (props?.info?.id) { + ptyManager.cleanupBySession(props.info.id); + } + } + // Run all event hooks await autoCompactHook.event({ event }); await autoClearLedgerHook.event({ event }); From c8089a5df308c2caa7fcfac788ffe2a26966bfcb Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Sat, 3 Jan 2026 15:30:18 +0200 Subject: [PATCH 04/14] docs(agents): add PTY tool guidance to agent prompts - Commander: terminal tool selection guidance - Implementer: bash vs pty decision rules - Executor: PTY tools for background processes - Reviewer: PTY cleanup verification --- src/agents/commander.ts | 17 +++++++++++++++++ src/agents/executor.ts | 18 ++++++++++++++++++ src/agents/implementer.ts | 7 +++++++ src/agents/reviewer.ts | 6 ++++++ 4 files changed, 48 insertions(+) diff --git a/src/agents/commander.ts b/src/agents/commander.ts index ad4cfc7..dbd4514 100644 --- a/src/agents/commander.ts +++ b/src/agents/commander.ts @@ -99,6 +99,23 @@ Just do it - including obvious follow-up actions. + +Synchronous commands. Use for: npm install, git, builds, quick commands that complete. +Background PTY sessions. Use for: dev servers, watch modes, REPLs, long-running processes. + +Command completes quickly (npm install, git status, mkdir) +Process runs indefinitely (npm run dev, pytest --watch, python REPL) +Need to send interactive input (Ctrl+C, responding to prompts) +Want to check output later without blocking + + +pty_spawn to start the process +pty_read to check output (use pattern to filter) +pty_write to send input (\\n for Enter, \\x03 for Ctrl+C) +pty_kill when done (cleanup=true to remove) + + + Use TodoWrite to track what you're doing Never discard tasks without explicit approval diff --git a/src/agents/executor.ts b/src/agents/executor.ts index fff1ff0..2dcdd5e 100644 --- a/src/agents/executor.ts +++ b/src/agents/executor.ts @@ -18,6 +18,24 @@ You have access to background task management tools: - background_list: List all background tasks and their status + +PTY tools manage background terminal sessions (different from background_task which runs subagents): +- pty_spawn: Start a background process (dev server, watch mode, REPL) +- pty_write: Send input to a PTY (commands, Ctrl+C, etc.) +- pty_read: Read output from a PTY buffer +- pty_list: List all PTY sessions +- pty_kill: Terminate a PTY session + +Use PTY when: +- Plan requires starting a dev server before running tests +- Plan requires a watch mode process running during implementation +- Plan requires interactive terminal input + +Do NOT use PTY for: +- Quick commands (use bash) +- Subagent tasks (use background_task) + + Parse plan to extract individual tasks Analyze task dependencies to build execution graph diff --git a/src/agents/implementer.ts b/src/agents/implementer.ts index ebd527a..ddcc415 100644 --- a/src/agents/implementer.ts +++ b/src/agents/implementer.ts @@ -31,6 +31,13 @@ Execute the plan. Write code. Verify. Report results + +Use for synchronous commands that complete (npm install, git, builds) +Use for background processes (dev servers, watch modes, REPLs) +If plan says "start dev server" or "run in background", use pty_spawn +If plan says "run command" or "install", use bash + + Verify file exists where expected Verify code structure matches plan assumptions diff --git a/src/agents/reviewer.ts b/src/agents/reviewer.ts index ec666b6..56da996 100644 --- a/src/agents/reviewer.ts +++ b/src/agents/reviewer.ts @@ -65,6 +65,12 @@ Check correctness and style. Be specific. Run code, don't just read. Report with precise references + +If implementation includes PTY usage, verify sessions are properly cleaned up +If tests require a running server, check that pty_spawn was used appropriately +Check that long-running processes use PTY, not blocking bash + +