Implement control pipe for external PTY session communication

- Add control-pipe field to session types for external command communication
- Create ResizeControlMessage and KillControlMessage interfaces
- Update PtyManager to send control messages via pipe for external sessions
- Enhance fwd.ts to create and monitor control pipe for resize/kill commands
- Support real-time resize operations for external sessions via IPC
- Add proper cleanup of control pipes on session exit

This enables full PTY control (resize, kill) for sessions created by
external tools like fwd.ts through a file-based IPC mechanism.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mario Zechner 2025-06-19 05:57:13 +02:00
parent 0fcddd2194
commit 6c055013af
3 changed files with 170 additions and 16 deletions

View file

@ -46,13 +46,13 @@ async function main() {
const monitorOnly = args[0] === '--monitor-only';
const command = monitorOnly ? args.slice(1) : args;
if (command.length === 0) {
console.error('Error: No command specified');
showUsage();
process.exit(1);
}
const cwd = process.cwd();
console.log(`Starting command: ${command.join(' ')}`);
@ -92,6 +92,95 @@ async function main() {
console.log(`Status: ${session.status}`);
console.log(`Stream output: ${session['stream-out']}`);
console.log(`Input pipe: ${session.stdin}`);
// Set up control pipe for external commands (resize, etc.)
const controlPipePath = path.join(path.dirname(session.stdin), 'control-pipe');
try {
// Create control pipe as a regular file (not FIFO) for simplicity
if (!fs.existsSync(controlPipePath)) {
fs.writeFileSync(controlPipePath, '');
}
// Update session info to include control pipe
const sessionInfoPath = path.join(path.dirname(session.stdin), 'session.json');
if (fs.existsSync(sessionInfoPath)) {
const sessionInfo = JSON.parse(fs.readFileSync(sessionInfoPath, 'utf8'));
sessionInfo['control-pipe'] = controlPipePath;
fs.writeFileSync(sessionInfoPath, JSON.stringify(sessionInfo, null, 2));
}
console.log(`Control pipe: ${controlPipePath}`);
// Watch for control messages
let lastControlPosition = 0;
const watchControl = () => {
try {
if (fs.existsSync(controlPipePath)) {
const stats = fs.statSync(controlPipePath);
if (stats.size > lastControlPosition) {
const fd = fs.openSync(controlPipePath, 'r');
const buffer = Buffer.allocUnsafe(stats.size - lastControlPosition);
fs.readSync(fd, buffer, 0, buffer.length, lastControlPosition);
fs.closeSync(fd);
const newData = buffer.toString('utf8');
const lines = newData.split('\n').filter((line) => line.trim());
for (const line of lines) {
try {
const message = JSON.parse(line);
handleControlMessage(message);
} catch (_e) {
console.warn('Invalid control message:', line);
}
}
lastControlPosition = stats.size;
}
}
} catch (_error) {
// Control file might be temporarily unavailable
}
};
// Check for control messages every 200ms
const controlInterval = setInterval(watchControl, 200);
// Handle control messages
const handleControlMessage = (message: Record<string, unknown>) => {
if (message.cmd === 'resize' && message.cols && message.rows) {
console.log(`Received resize command: ${message.cols}x${message.rows}`);
// Get current session from PTY service and resize if possible
try {
ptyService.resizeSession(result.sessionId, message.cols, message.rows);
} catch (error) {
console.warn('Failed to resize session:', error);
}
} else if (message.cmd === 'kill') {
console.log(`Received kill command: ${message.signal || 'SIGTERM'}`);
// The session monitoring will detect the exit and handle cleanup
try {
ptyService.killSession(result.sessionId, message.signal || 'SIGTERM');
} catch (error) {
console.warn('Failed to kill session:', error);
}
}
};
// Clean up control interval on exit
process.on('exit', () => {
clearInterval(controlInterval);
try {
if (fs.existsSync(controlPipePath)) {
fs.unlinkSync(controlPipePath);
}
} catch (_e) {
// Ignore cleanup errors
}
});
} catch (error) {
console.warn('Failed to set up control pipe:', error);
}
if (monitorOnly) {
console.log(`Monitor-only mode enabled\n`);
} else {
@ -117,7 +206,7 @@ async function main() {
// Stream PTY output to stdout
const streamOutput = session['stream-out'];
console.log(`Waiting for output stream file: ${streamOutput}`);
// Wait for the stream file to be created
const waitForStreamFile = async (maxWait = 5000) => {
const startTime = Date.now();
@ -125,7 +214,7 @@ async function main() {
if (streamOutput && fs.existsSync(streamOutput)) {
return true;
}
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
}
return false;
};
@ -135,13 +224,13 @@ async function main() {
console.log('Warning: Stream output file not found, proceeding without real-time output');
} else {
console.log('Stream file found, starting output monitoring...');
let lastPosition = 0;
const readNewData = () => {
try {
if (!streamOutput || !fs.existsSync(streamOutput)) return;
const stats = fs.statSync(streamOutput);
if (stats.size > lastPosition) {
const fd = fs.openSync(streamOutput, 'r');
@ -149,7 +238,7 @@ async function main() {
fs.readSync(fd, buffer, 0, buffer.length, lastPosition);
fs.closeSync(fd);
const chunk = buffer.toString('utf8');
// Parse asciinema format and extract text content
const lines = chunk.split('\n');
for (const line of lines) {
@ -165,7 +254,7 @@ async function main() {
}
}
}
lastPosition = stats.size;
}
} catch (_error) {
@ -175,7 +264,7 @@ async function main() {
// Start monitoring
const streamInterval = setInterval(readNewData, 50);
// Clean up on exit
process.on('exit', () => {
clearInterval(streamInterval);

View file

@ -18,6 +18,8 @@ import {
SessionInput,
SpecialKey,
PtyError,
ResizeControlMessage,
KillControlMessage,
} from './types.js';
import { AsciinemaWriter } from './AsciinemaWriter.js';
import { SessionManager } from './SessionManager.js';
@ -311,6 +313,31 @@ export class PtyManager {
}
}
/**
* Send a control message to an external session
*/
private sendControlMessage(
sessionId: string,
message: ResizeControlMessage | KillControlMessage
): boolean {
const diskSession = this.sessionManager.getSession(sessionId);
if (!diskSession || !diskSession['control-pipe']) {
return false;
}
const controlPipe = diskSession['control-pipe'];
try {
if (fs.existsSync(controlPipe)) {
const messageStr = JSON.stringify(message) + '\n';
fs.writeFileSync(controlPipe, messageStr);
return true;
}
} catch (error) {
console.warn(`Failed to send control message to session ${sessionId}:`, error);
}
return false;
}
/**
* Convert special key names to escape sequences
*/
@ -353,11 +380,19 @@ export class PtyManager {
memorySession.ptyProcess.resize(cols, rows);
memorySession.asciinemaWriter?.writeResize(cols, rows);
} else {
// For external sessions, we can't directly resize the PTY
// but we don't throw an error - the session should handle SIGWINCH automatically
console.log(
`Cannot resize external session ${sessionId} directly, PTY should handle SIGWINCH automatically`
);
// For external sessions, try to send resize via control pipe
const resizeMessage: ResizeControlMessage = {
cmd: 'resize',
cols,
rows,
};
const sent = this.sendControlMessage(sessionId, resizeMessage);
if (sent) {
console.log(`Sent resize command to external session ${sessionId}: ${cols}x${rows}`);
} else {
console.log(`Cannot resize external session ${sessionId} - no control pipe available`);
}
}
} catch (error) {
throw new PtyError(
@ -397,7 +432,20 @@ export class PtyManager {
// Start with SIGTERM and escalate if needed
await this.killSessionWithEscalation(sessionId, memorySession);
} else {
// For external sessions, kill by PID
// For external sessions, try control pipe first, then fall back to PID
const killMessage: KillControlMessage = {
cmd: 'kill',
signal,
};
const sentControl = this.sendControlMessage(sessionId, killMessage);
if (sentControl) {
console.log(`Sent kill command via control pipe to session ${sessionId}`);
// Wait a bit for the control message to be processed
await new Promise((resolve) => setTimeout(resolve, 500));
}
// Check if process is still running, if so, use direct PID kill
if (diskSession.pid && ProcessUtils.isProcessRunning(diskSession.pid)) {
console.log(
`Killing external session ${sessionId} (PID: ${diskSession.pid}) with ${signal}...`

View file

@ -50,6 +50,7 @@ export interface SessionEntryWithId {
'stream-out': string;
stdin: string;
'notification-stream': string;
'control-pipe'?: string;
waiting: boolean;
}
@ -73,6 +74,22 @@ export interface AsciinemaTheme {
export type AsciinemaEventType = 'o' | 'i' | 'r' | 'm'; // output, input, resize, marker
export interface ControlMessage {
cmd: string;
[key: string]: unknown;
}
export interface ResizeControlMessage extends ControlMessage {
cmd: 'resize';
cols: number;
rows: number;
}
export interface KillControlMessage extends ControlMessage {
cmd: 'kill';
signal?: string | number;
}
export interface AsciinemaEvent {
time: number;
type: AsciinemaEventType;