feat: enhance capture_terminal with activity settling detection

Extend capture_terminal to wait for terminal activity to settle before
capturing output. The wait parameter is renamed to maxWait and now triggers
intelligent polling that detects when terminal output has stabilized (1s of
no changes) rather than a simple sleep.

Changes:
- Export waitForPaneActivityToSettle from tmux.ts for reuse
- Update captureTerminal to use settling logic when maxWait provided
- Update capture_terminal tool schema with new maxWait parameter
- Add error handling callbacks to StreamLLMParams interface
- Improve send_text/send_keys tool descriptions for clarity

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-10-20 00:04:35 +02:00
co-authored by Claude
parent 0bfda11c1f
commit 853a73cc05
3 changed files with 131 additions and 74 deletions
@@ -67,15 +67,15 @@ export const terminalTools = {
.number() .number()
.optional() .optional()
.describe("Number of lines to capture (default: 200)"), .describe("Number of lines to capture (default: 200)"),
wait: z maxWait: z
.number() .number()
.optional() .optional()
.describe( .describe(
"Milliseconds to wait before capturing output. Useful for slow commands." "Maximum milliseconds to wait for terminal activity to settle before capturing. Polls every 100ms and waits for 1s of no changes. Useful for commands with delayed output."
), ),
}), }),
execute: async ({ terminalName, lines, wait }) => { execute: async ({ terminalName, lines, maxWait }) => {
const output = await captureTerminal(terminalName, lines, wait); const output = await captureTerminal(terminalName, lines, maxWait);
return { output }; return { output };
}, },
}), }),
@@ -102,14 +102,22 @@ export const terminalTools = {
.number() .number()
.optional() .optional()
.describe("Number of lines to capture (default: 200)"), .describe("Number of lines to capture (default: 200)"),
wait: z waitForSettled: z
.boolean()
.optional()
.describe(
"Wait for terminal activity to settle before returning output. Polls terminal and waits 500ms after last change (default: true)"
),
maxWait: z
.number() .number()
.optional() .optional()
.describe("Milliseconds to wait before capturing output"), .describe(
"Maximum milliseconds to wait for activity to settle (default: 120000 = 2 minutes)"
),
}) })
.optional() .optional()
.describe( .describe(
"Capture terminal output after sending text. Specify 'wait' for slow commands." "Capture terminal output after sending text. By default waits for activity to settle."
), ),
}), }),
execute: async ({ terminalName, text, pressEnter, return_output }) => { execute: async ({ terminalName, text, pressEnter, return_output }) => {
@@ -144,14 +152,22 @@ export const terminalTools = {
.number() .number()
.optional() .optional()
.describe("Number of lines to capture (default: 200)"), .describe("Number of lines to capture (default: 200)"),
wait: z waitForSettled: z
.boolean()
.optional()
.describe(
"Wait for terminal activity to settle before returning output. Polls terminal and waits 500ms after last change (default: true)"
),
maxWait: z
.number() .number()
.optional() .optional()
.describe("Milliseconds to wait before capturing output"), .describe(
"Maximum milliseconds to wait for activity to settle (default: 120000 = 2 minutes)"
),
}) })
.optional() .optional()
.describe( .describe(
"Capture terminal output after sending keys. Specify 'wait' for slow commands." "Capture terminal output after sending keys. By default waits for activity to settle."
), ),
}), }),
execute: async ({ terminalName, keys, repeat, return_output }) => { execute: async ({ terminalName, keys, repeat, return_output }) => {
@@ -211,8 +227,18 @@ export interface StreamLLMParams {
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
onChunk?: (chunk: string) => void | Promise<void>; onChunk?: (chunk: string) => void | Promise<void>;
onTextSegment?: (segment: string) => void; onTextSegment?: (segment: string) => void;
onToolCall?: (toolCallId: string, toolName: string, args: any) => Promise<void>; onToolCall?: (
toolCallId: string,
toolName: string,
args: any
) => Promise<void>;
onToolResult?: (toolCallId: string, toolName: string, result: any) => void; onToolResult?: (toolCallId: string, toolName: string, result: any) => void;
onToolError?: (
toolCallId: string,
toolName: string,
error: unknown
) => void | Promise<void>;
onError?: (error: unknown) => void | Promise<void>;
onFinish?: (fullText: string) => void | Promise<void>; onFinish?: (fullText: string) => void | Promise<void>;
} }
@@ -246,7 +272,11 @@ export async function streamLLM(params: StreamLLMParams): Promise<string> {
// Emit tool call event // Emit tool call event
if (params.onToolCall) { if (params.onToolCall) {
await params.onToolCall(chunk.toolCallId, chunk.toolName, chunk.input); await params.onToolCall(
chunk.toolCallId,
chunk.toolName,
chunk.input
);
} }
} else if (chunk.type === "tool-result") { } else if (chunk.type === "tool-result") {
// Emit tool result event // Emit tool result event
@@ -255,6 +285,12 @@ export async function streamLLM(params: StreamLLMParams): Promise<string> {
} }
} }
}, },
onError: async (error) => {
// Emit general stream error
if (params.onError) {
await params.onError(error);
}
},
stopWhen: stepCountIs(10), stopWhen: stepCountIs(10),
}); });
@@ -268,8 +304,15 @@ export async function streamLLM(params: StreamLLMParams): Promise<string> {
textBuffer = ""; textBuffer = "";
} }
for await (const _part of result.fullStream) { for await (const part of result.fullStream) {
// console.log("part", _part); // console.log("part", part);
// Handle tool-error chunks (not available in onChunk callback)
if (part.type === "tool-error") {
if (params.onToolError) {
await params.onToolError(part.toolCallId, part.toolName, part.error);
}
}
} }
// Flush any remaining text at the end // Flush any remaining text at the end
@@ -13,6 +13,7 @@ import {
isWindowNameUnique, isWindowNameUnique,
getCurrentWorkingDirectory, getCurrentWorkingDirectory,
getCurrentCommand, getCurrentCommand,
waitForPaneActivityToSettle,
} from "./tmux.js"; } from "./tmux.js";
const DEFAULT_SESSION = "voice-dev"; const DEFAULT_SESSION = "voice-dev";
@@ -22,7 +23,6 @@ const DEFAULT_SESSION = "voice-dev";
export interface TerminalInfo { export interface TerminalInfo {
name: string; name: string;
active: boolean;
workingDirectory: string; workingDirectory: string;
currentCommand: string; currentCommand: string;
lastLines?: string; lastLines?: string;
@@ -34,8 +34,8 @@ export interface CreateTerminalParams {
initialCommand?: string; initialCommand?: string;
} }
export interface Terminal extends TerminalInfo { export interface CreateTerminalResult extends TerminalInfo {
sessionId: string; commandOutput?: string;
} }
/** /**
@@ -58,7 +58,9 @@ export async function listTerminals(): Promise<TerminalInfo[]> {
const session = await findSessionByName(DEFAULT_SESSION); const session = await findSessionByName(DEFAULT_SESSION);
if (!session) { if (!session) {
throw new Error(`Session '${DEFAULT_SESSION}' not found. Call initializeDefaultSession() first.`); throw new Error(
`Session '${DEFAULT_SESSION}' not found. Call initializeDefaultSession() first.`
);
} }
const windows = await listWindows(session.id); const windows = await listWindows(session.id);
@@ -69,28 +71,16 @@ export async function listTerminals(): Promise<TerminalInfo[]> {
// Get the first (and only) pane in this window // Get the first (and only) pane in this window
const paneId = `${window.id}.0`; const paneId = `${window.id}.0`;
try { const workingDirectory = await getCurrentWorkingDirectory(paneId);
const workingDirectory = await getCurrentWorkingDirectory(paneId); const currentCommand = await getCurrentCommand(paneId);
const currentCommand = await getCurrentCommand(paneId); const lastLines = await capturePaneContent(paneId, 5, false);
const lastLines = await capturePaneContent(paneId, 5, false);
terminals.push({ terminals.push({
name: window.name, name: window.name,
active: window.active, workingDirectory,
workingDirectory, currentCommand,
currentCommand, lastLines,
lastLines, });
});
} catch (error) {
// If we can't get pane info, still include the terminal with empty values
terminals.push({
name: window.name,
active: window.active,
workingDirectory: "",
currentCommand: "",
lastLines: "",
});
}
} }
return terminals; return terminals;
@@ -100,11 +90,15 @@ export async function listTerminals(): Promise<TerminalInfo[]> {
* Create a new terminal (tmux window) with specified name and working directory * Create a new terminal (tmux window) with specified name and working directory
* Optionally execute an initial command * Optionally execute an initial command
*/ */
export async function createTerminal(params: CreateTerminalParams): Promise<Terminal> { export async function createTerminal(
params: CreateTerminalParams
): Promise<CreateTerminalResult> {
const session = await findSessionByName(DEFAULT_SESSION); const session = await findSessionByName(DEFAULT_SESSION);
if (!session) { if (!session) {
throw new Error(`Session '${DEFAULT_SESSION}' not found. Call initializeDefaultSession() first.`); throw new Error(
`Session '${DEFAULT_SESSION}' not found. Call initializeDefaultSession() first.`
);
} }
// Validate name uniqueness // Validate name uniqueness
@@ -133,21 +127,21 @@ export async function createTerminal(params: CreateTerminalParams): Promise<Term
return { return {
name: windowResult.name, name: windowResult.name,
active: windowResult.active,
workingDirectory, workingDirectory,
currentCommand, currentCommand,
sessionId: session.id, commandOutput: windowResult.output,
}; };
} }
/** /**
* Capture output from a terminal by name * Capture output from a terminal by name
* Returns the last N lines of terminal content * Returns the last N lines of terminal content
* If maxWait is provided, waits for terminal activity to settle before capturing
*/ */
export async function captureTerminal( export async function captureTerminal(
terminalName: string, terminalName: string,
lines: number = 200, lines: number = 200,
wait?: number maxWait?: number
): Promise<string> { ): Promise<string> {
const session = await findSessionByName(DEFAULT_SESSION); const session = await findSessionByName(DEFAULT_SESSION);
if (!session) { if (!session) {
@@ -171,9 +165,9 @@ export async function captureTerminal(
throw new Error(`No pane found for terminal ${terminalName}`); throw new Error(`No pane found for terminal ${terminalName}`);
} }
// Optional wait before capture // Wait for activity to settle if maxWait is provided
if (wait) { if (maxWait) {
await new Promise((resolve) => setTimeout(resolve, wait)); return waitForPaneActivityToSettle(pane.id, maxWait, lines);
} }
return capturePaneContent(pane.id, lines, false); return capturePaneContent(pane.id, lines, false);
@@ -199,10 +199,10 @@ export async function capturePaneContent(
// Trim trailing whitespace, split by lines, take last N lines, rejoin // Trim trailing whitespace, split by lines, take last N lines, rejoin
const trimmed = output.trimEnd(); const trimmed = output.trimEnd();
const allLines = trimmed.split('\n'); const allLines = trimmed.split("\n");
const lastLines = allLines.slice(-lines); const lastLines = allLines.slice(-lines);
return lastLines.join('\n'); return lastLines.join("\n");
} }
/** /**
@@ -212,7 +212,9 @@ export async function getCurrentWorkingDirectory(
paneId: string paneId: string
): Promise<string> { ): Promise<string> {
try { try {
const tmuxPath = await executeTmux(`display-message -p -t '${paneId}' '#{pane_current_path}'`); const tmuxPath = await executeTmux(
`display-message -p -t '${paneId}' '#{pane_current_path}'`
);
// If tmux returns a valid path, use it // If tmux returns a valid path, use it
if (tmuxPath && tmuxPath.trim()) { if (tmuxPath && tmuxPath.trim()) {
@@ -220,8 +222,12 @@ export async function getCurrentWorkingDirectory(
} }
// Fallback: get the PID and use lsof to find the actual CWD // Fallback: get the PID and use lsof to find the actual CWD
const shellPid = await executeTmux(`display-message -p -t '${paneId}' '#{pane_pid}'`); const shellPid = await executeTmux(
const { stdout } = await exec(`lsof -a -p ${shellPid.trim()} -d cwd -Fn | grep '^n' | cut -c2-`); `display-message -p -t '${paneId}' '#{pane_pid}'`
);
const { stdout } = await exec(
`lsof -a -p ${shellPid.trim()} -d cwd -Fn | grep '^n' | cut -c2-`
);
return stdout.trim() || tmuxPath; return stdout.trim() || tmuxPath;
} catch (error) { } catch (error) {
// If all else fails, return empty string // If all else fails, return empty string
@@ -236,15 +242,21 @@ export async function getCurrentWorkingDirectory(
export async function getCurrentCommand(paneId: string): Promise<string> { export async function getCurrentCommand(paneId: string): Promise<string> {
try { try {
// Get the shell PID (the pane's main process) // Get the shell PID (the pane's main process)
const shellPid = await executeTmux(`display-message -p -t '${paneId}' '#{pane_pid}'`); const shellPid = await executeTmux(
`display-message -p -t '${paneId}' '#{pane_pid}'`
);
// First, check if there's a child process using comm= (works for all programs including top) // First, check if there's a child process using comm= (works for all programs including top)
// Use 'ax' flags to see all processes // Use 'ax' flags to see all processes
const { stdout: childPid } = await exec(`ps ax -o pid=,ppid=,comm= | awk '$2 == ${shellPid.trim()} { print $1; exit }'`); const { stdout: childPid } = await exec(
`ps ax -o pid=,ppid=,comm= | awk '$2 == ${shellPid.trim()} { print $1; exit }'`
);
if (childPid.trim()) { if (childPid.trim()) {
// Found a child process, get its full command with args // Found a child process, get its full command with args
const { stdout: fullCmd } = await exec(`ps -p ${childPid.trim()} -o args= | sed 's/\\\\012.*//'`); const { stdout: fullCmd } = await exec(
`ps -p ${childPid.trim()} -o args= | sed 's/\\\\012.*//'`
);
const command = fullCmd.trim(); const command = fullCmd.trim();
if (command) { if (command) {
return command; return command;
@@ -256,7 +268,9 @@ export async function getCurrentCommand(paneId: string): Promise<string> {
return shellCmd.trim(); return shellCmd.trim();
} catch (error) { } catch (error) {
// Fallback to just the command name if ps fails // Fallback to just the command name if ps fails
return executeTmux(`display-message -p -t '${paneId}' '#{pane_current_command}'`); return executeTmux(
`display-message -p -t '${paneId}' '#{pane_current_command}'`
);
} }
} }
@@ -277,11 +291,11 @@ export async function createSession(name: string): Promise<TmuxSession | null> {
* Expand tilde in path to home directory * Expand tilde in path to home directory
*/ */
function expandTilde(path: string): string { function expandTilde(path: string): string {
if (path.startsWith('~/')) { if (path.startsWith("~/")) {
const homeDir = process.env.HOME || os.homedir(); const homeDir = process.env.HOME || os.homedir();
return path.replace('~', homeDir); return path.replace("~", homeDir);
} }
if (path === '~') { if (path === "~") {
return process.env.HOME || os.homedir(); return process.env.HOME || os.homedir();
} }
return path; return path;
@@ -331,17 +345,15 @@ export async function createWindow(
// If command is provided, execute it in the new pane // If command is provided, execute it in the new pane
if (options?.command && defaultPane) { if (options?.command && defaultPane) {
await sendText({ commandOutput = (await sendText({
paneId: defaultPane.id, paneId: defaultPane.id,
text: options.command, text: options.command,
pressEnter: true, pressEnter: true,
}); return_output: {
waitForSettled: true,
// Sleep for 1 second to allow command to execute maxWait: 120000,
await new Promise((resolve) => setTimeout(resolve, 1000)); },
})) as string;
// Capture the pane content
commandOutput = await capturePaneContent(defaultPane.id, 200, false);
} }
return { return {
@@ -809,7 +821,11 @@ export async function sendKeys({
paneId: string; paneId: string;
keys: string; keys: string;
repeat?: number; repeat?: number;
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }; return_output?: {
lines?: number;
waitForSettled?: boolean;
maxWait?: number;
};
}): Promise<string | void> { }): Promise<string | void> {
// Repeat the key press the specified number of times // Repeat the key press the specified number of times
for (let i = 0; i < repeat; i++) { for (let i = 0; i < repeat; i++) {
@@ -831,15 +847,15 @@ export async function sendKeys({
} }
} }
async function waitForPaneActivityToSettle( export async function waitForPaneActivityToSettle(
paneId: string, paneId: string,
maxWait: number, maxWait: number,
lines: number lines: number
): Promise<string> { ): Promise<string> {
const settleTime = 500; // Hardcoded debounce const settleTime = 1000; // Hardcoded debounce
const pollInterval = 100; // Poll every 100ms const pollInterval = 100; // Poll every 100ms
let lastContent = ''; let lastContent = "";
let lastChangeTime = Date.now(); let lastChangeTime = Date.now();
const startTime = Date.now(); const startTime = Date.now();
@@ -861,7 +877,7 @@ async function waitForPaneActivityToSettle(
return content; return content;
} }
await new Promise(resolve => setTimeout(resolve, pollInterval)); await new Promise((resolve) => setTimeout(resolve, pollInterval));
} }
} }
@@ -874,7 +890,11 @@ export async function sendText({
paneId: string; paneId: string;
text: string; text: string;
pressEnter?: boolean; pressEnter?: boolean;
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }; return_output?: {
lines?: number;
waitForSettled?: boolean;
maxWait?: number;
};
}): Promise<string | void> { }): Promise<string | void> {
// Send each character with -l flag for literal interpretation // Send each character with -l flag for literal interpretation
for (const char of text) { for (const char of text) {