Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,9 @@
"build": "npm run clean && tsc",
"postbuild": "cp -r src/dashboard/public dist/dashboard/",
"dev": "tsc -w",
"dev:local": "npm run build && npm link && echo '✓ agent-relay linked globally'",
"dev:unlink": "npm unlink -g agent-relay && echo '✓ agent-relay unlinked'",
"dev:rebuild": "npm run build && echo '✓ Rebuilt (linked version updated)'",
"start": "node dist/cli/index.js",
"daemon": "node dist/daemon/server.js",
"dashboard": "node dist/dashboard/start.js",
Expand Down
173 changes: 173 additions & 0 deletions src/wrapper/tmux-wrapper.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
/**
* Unit tests for TmuxWrapper constants and utilities
*/

import { describe, it, expect } from 'vitest';
import { getDefaultPrefix } from './tmux-wrapper.js';

describe('TmuxWrapper constants', () => {
// Test that importing the module works and constants are defined
// Note: The constants are module-private, so we test their usage indirectly
// through the behaviors they control

describe('getDefaultPrefix', () => {
it('returns >> for gemini CLI type', () => {
expect(getDefaultPrefix('gemini')).toBe('>>');
});

it('returns @relay: for claude CLI type', () => {
expect(getDefaultPrefix('claude')).toBe('@relay:');
});

it('returns @relay: for codex CLI type', () => {
expect(getDefaultPrefix('codex')).toBe('@relay:');
});

it('returns @relay: for other CLI type', () => {
expect(getDefaultPrefix('other')).toBe('@relay:');
});
});
});

describe('String truncation safety', () => {
// Test the truncation pattern used throughout tmux-wrapper
// Pattern: str.substring(0, Math.min(LIMIT, str.length))

const safeSubstring = (str: string, maxLen: number): string => {
return str.substring(0, Math.min(maxLen, str.length));
};

describe('safeSubstring helper pattern', () => {
it('truncates long strings', () => {
const longString = 'a'.repeat(100);
expect(safeSubstring(longString, 40)).toBe('a'.repeat(40));
expect(safeSubstring(longString, 40)).toHaveLength(40);
});

it('preserves short strings', () => {
const shortString = 'hello';
expect(safeSubstring(shortString, 40)).toBe('hello');
expect(safeSubstring(shortString, 40)).toHaveLength(5);
});

it('handles exact length strings', () => {
const exactString = 'a'.repeat(40);
expect(safeSubstring(exactString, 40)).toBe(exactString);
expect(safeSubstring(exactString, 40)).toHaveLength(40);
});

it('handles empty strings', () => {
expect(safeSubstring('', 40)).toBe('');
expect(safeSubstring('', 40)).toHaveLength(0);
});

it('handles strings shorter than limit', () => {
expect(safeSubstring('ab', 40)).toBe('ab');
});

it('handles limit of 0', () => {
expect(safeSubstring('hello', 0)).toBe('');
});

it('handles unicode characters', () => {
const unicodeStr = ''.repeat(100);
expect(safeSubstring(unicodeStr, 10)).toBe(''.repeat(10));
});
});

describe('DEBUG_LOG_TRUNCATE_LENGTH constant (40)', () => {
const DEBUG_LOG_TRUNCATE_LENGTH = 40;

it('truncates debug log content appropriately', () => {
const longMessage = 'This is a very long debug message that exceeds the limit';
const truncated = safeSubstring(longMessage, DEBUG_LOG_TRUNCATE_LENGTH);
expect(truncated).toBe('This is a very long debug message that e');
expect(truncated).toHaveLength(40);
});
});

describe('RELAY_LOG_TRUNCATE_LENGTH constant (50)', () => {
const RELAY_LOG_TRUNCATE_LENGTH = 50;

it('truncates relay command log content appropriately', () => {
const longMessage = 'This is a very long relay message that definitely exceeds the fifty character limit';
const truncated = safeSubstring(longMessage, RELAY_LOG_TRUNCATE_LENGTH);
expect(truncated).toBe('This is a very long relay message that definitely ');
expect(truncated).toHaveLength(50);
});
});
});

describe('Cursor stability constants', () => {
// These test the logic that uses STABLE_CURSOR_THRESHOLD and MAX_PROMPT_CURSOR_POSITION

const STABLE_CURSOR_THRESHOLD = 3;
const MAX_PROMPT_CURSOR_POSITION = 4;

describe('STABLE_CURSOR_THRESHOLD', () => {
it('requires 3 or more stable polls to consider input clear', () => {
// Simulate cursor stability counting
let stableCursorCount = 0;
const cursorX = 2;

// First poll - not stable yet
stableCursorCount++;
expect(stableCursorCount >= STABLE_CURSOR_THRESHOLD).toBe(false);

// Second poll - still not stable
stableCursorCount++;
expect(stableCursorCount >= STABLE_CURSOR_THRESHOLD).toBe(false);

// Third poll - now stable
stableCursorCount++;
expect(stableCursorCount >= STABLE_CURSOR_THRESHOLD).toBe(true);
expect(cursorX <= MAX_PROMPT_CURSOR_POSITION).toBe(true);
});

it('resets count when cursor moves', () => {
let stableCursorCount = 2;
let lastCursorX = 2;
const newCursorX = 5; // Cursor moved

if (newCursorX !== lastCursorX) {
stableCursorCount = 0;
lastCursorX = newCursorX;
}

expect(stableCursorCount).toBe(0);
});
});

describe('MAX_PROMPT_CURSOR_POSITION', () => {
it('considers positions 0-4 as typical prompt positions', () => {
expect(0 <= MAX_PROMPT_CURSOR_POSITION).toBe(true);
expect(1 <= MAX_PROMPT_CURSOR_POSITION).toBe(true);
expect(2 <= MAX_PROMPT_CURSOR_POSITION).toBe(true);
expect(3 <= MAX_PROMPT_CURSOR_POSITION).toBe(true);
expect(4 <= MAX_PROMPT_CURSOR_POSITION).toBe(true);
});

it('considers positions > 4 as likely having user input', () => {
expect(5 <= MAX_PROMPT_CURSOR_POSITION).toBe(false);
expect(10 <= MAX_PROMPT_CURSOR_POSITION).toBe(false);
});

it('works with combined stability check', () => {
const stableCursorCount = 3;
const cursorAtPrompt = 2;
const cursorWithInput = 10;

// At prompt position - should be considered clear
const isClearAtPrompt =
stableCursorCount >= STABLE_CURSOR_THRESHOLD &&
cursorAtPrompt <= MAX_PROMPT_CURSOR_POSITION;
expect(isClearAtPrompt).toBe(true);

// With input - should not be considered clear
const isClearWithInput =
stableCursorCount >= STABLE_CURSOR_THRESHOLD &&
cursorWithInput <= MAX_PROMPT_CURSOR_POSITION;
expect(isClearWithInput).toBe(false);
});
});
});
136 changes: 122 additions & 14 deletions src/wrapper/tmux-wrapper.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,16 @@ import type { SendPayload } from '../protocol/types.js';
const execAsync = promisify(exec);
const escapeRegex = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

// Constants for cursor stability detection in waitForClearInput
/** Number of consecutive polls with stable cursor before assuming input is clear */
const STABLE_CURSOR_THRESHOLD = 3;
/** Maximum cursor X position that indicates a prompt (typical prompts are 1-4 chars) */
const MAX_PROMPT_CURSOR_POSITION = 4;
/** Maximum characters to show in debug log truncation */
const DEBUG_LOG_TRUNCATE_LENGTH = 40;
/** Maximum characters to show in relay command log truncation */
const RELAY_LOG_TRUNCATE_LENGTH = 50;

export interface TmuxWrapperConfig {
name: string;
command: string;
Expand All@@ -47,6 +57,10 @@ export interface TmuxWrapperConfig {
injectRetryMs?: number;
/** How long with no output before marking session idle (ms) */
activityIdleThresholdMs?: number;
/** Max time to wait for clear input before injecting (ms) */
inputWaitTimeoutMs?: number;
/** Polling interval when waiting for clear input (ms) */
inputWaitPollMs?: number;
/** CLI type for special handling (auto-detected from command if not set) */
cliType?: 'claude' | 'codex' | 'gemini' | 'other';
/** Enable tmux mouse mode for scroll passthrough (default: true) */
Expand DownExpand Up@@ -594,7 +608,8 @@ export class TmuxWrapper {
const success = this.client.sendMessage(cmd.to, cmd.body, cmd.kind, cmd.data);
if (success) {
this.sentMessageHashes.add(msgHash);
this.logStderr(`→ ${cmd.to}: ${cmd.body.substring(0, 50)}...`);
const truncatedBody = cmd.body.substring(0, Math.min(RELAY_LOG_TRUNCATE_LENGTH, cmd.body.length));
this.logStderr(`→ ${cmd.to}: ${truncatedBody}...`);
} else if (this.client.state !== 'READY') {
// Only log failure once per state change
this.logStderr(`Send failed (client ${this.client.state})`);
Expand All@@ -605,7 +620,8 @@ export class TmuxWrapper {
* Handle incoming message from relay
*/
private handleIncomingMessage(from: string, payload: SendPayload, messageId: string): void {
this.logStderr(`← ${from}: ${payload.body.substring(0, 40)}...`);
const truncatedBody = payload.body.substring(0, Math.min(DEBUG_LOG_TRUNCATE_LENGTH, payload.body.length));
this.logStderr(`← ${from}: ${truncatedBody}...`);

// Queue for injection
this.messageQueue.push({ from, body: payload.body, messageId });
Expand DownExpand Up@@ -667,6 +683,19 @@ export class TmuxWrapper {
? ` [TRUNCATED - run "agent-relay read ${msg.messageId}"]`
: '';

// Wait for input to be clear before injecting
const waitTimeoutMs = this.config.inputWaitTimeoutMs ?? 5000;
const waitPollMs = this.config.inputWaitPollMs ?? 200;
const inputClear = await this.waitForClearInput(waitTimeoutMs, waitPollMs);
if (!inputClear) {
// Input still has text after timeout - clear it forcefully
this.logStderr('Input not clear after waiting, clearing forcefully');
await this.sendKeys('Escape');
await this.sleep(30);
await this.sendKeys('C-u');
await this.sleep(30);
}

// Gemini CLI interprets input as shell commands, so we need special handling
if (this.cliType === 'gemini') {
// For Gemini: Use printf with %s to safely handle any characters
Expand All@@ -677,12 +706,6 @@ export class TmuxWrapper {
const safeHint = this.escapeForAnsiC(truncationHint);
const printfMsg = `printf '%s\\n' $'Relay message from ${safeFrom} ${idTag}: ${safeBody}${safeHint}'`;

// Clear any partial input
await this.sendKeys('Escape');
await this.sleep(30);
await this.sendKeys('C-u');
await this.sleep(30);

// Send printf command to display the message
await this.sendKeysLiteral(printfMsg);
await this.sleep(50);
Expand All@@ -694,12 +717,6 @@ export class TmuxWrapper {
// Format: Relay message from Sender [abc12345]: content
const injection = `Relay message from ${msg.from} ${idTag}: ${sanitizedBody}${truncationHint}`;

// Clear any partial input
await this.sendKeys('Escape');
await this.sleep(30);
await this.sendKeys('C-u');
await this.sleep(30);

// Type the message
await this.sendKeysLiteral(injection);
await this.sleep(50);
Expand DownExpand Up@@ -747,6 +764,97 @@ export class TmuxWrapper {
return new Promise(r => setTimeout(r, ms));
}

/**
* Check if the input line is clear (no user-typed text after the prompt).
* Returns true if the last visible line appears to be just a prompt.
*/
private async isInputClear(): Promise<boolean> {
try {
const { stdout } = await execAsync(
`tmux capture-pane -t ${this.sessionName} -p -J 2>/dev/null`
);
const lines = stdout.split('\n').filter(l => l.length > 0);
const lastLine = lines[lines.length - 1] || '';

// CLI-specific prompt patterns (prompt char + optional whitespace, nothing else)
const promptPatterns: Record<string, RegExp> = {
claude: /^[>›»]\s*$/, // Claude: "> " or similar
gemini: /^[>›»]\s*$/, // Gemini: "> "
codex: /^[>›»]\s*$/, // Codex: "> "
other: /^[>$%#➜›»]\s*$/, // Shell or other: "$ ", "> ", etc.
};

const pattern = promptPatterns[this.cliType] || promptPatterns.other;
const isClear = pattern.test(lastLine);

if (this.config.debug) {
const truncatedLine = lastLine.substring(0, Math.min(DEBUG_LOG_TRUNCATE_LENGTH, lastLine.length));
this.logStderr(`isInputClear: lastLine="${truncatedLine}", clear=${isClear}`);
}

return isClear;
} catch {
// If we can't capture, assume not clear (safer)
return false;
}
}

/**
* Get cursor X position to detect input length.
* Returns the cursor column (0-indexed).
*/
private async getCursorX(): Promise<number> {
try {
const { stdout } = await execAsync(
`tmux display-message -t ${this.sessionName} -p "#{cursor_x}" 2>/dev/null`
);
return parseInt(stdout.trim(), 10) || 0;
} catch {
return 0;
}
}

/**
* Wait for the input line to be clear before injecting.
* Polls until the input appears empty or timeout is reached.
*
* @param maxWaitMs Maximum time to wait (default 5000ms)
* @param pollIntervalMs How often to check (default 200ms)
* @returns true if input became clear, false if timed out
*/
private async waitForClearInput(maxWaitMs = 5000, pollIntervalMs = 200): Promise<boolean> {
const startTime = Date.now();
let lastCursorX = -1;
let stableCursorCount = 0;

while (Date.now() - startTime < maxWaitMs) {
// Check if input line is just a prompt
if (await this.isInputClear()) {
return true;
}

// Also check cursor stability - if cursor is moving, agent is typing
const cursorX = await this.getCursorX();
if (cursorX === lastCursorX) {
stableCursorCount++;
// If cursor has been stable for enough polls and at typical prompt position,
// the agent might be done but we just can't match the prompt pattern
if (stableCursorCount >= STABLE_CURSOR_THRESHOLD && cursorX <= MAX_PROMPT_CURSOR_POSITION) {
this.logStderr(`waitForClearInput: cursor stable at x=${cursorX}, assuming clear`);
return true;
}
} else {
stableCursorCount = 0;
lastCursorX = cursorX;
}

await this.sleep(pollIntervalMs);
}

this.logStderr(`waitForClearInput: timed out after ${maxWaitMs}ms`);
return false;
}

/**
* Stop and cleanup
*/
Expand Down
Loading