Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Web Terminal Laravel Package

mwguerra/web-terminal

View on GitHub
Deep Wiki
Context7

FileSessionManager Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build a pure-PHP file-based session manager that enables interactive commands (REPLs, long-running processes) to work across PHP-FPM workers without requiring tmux.

Architecture: A background PHP worker process per session owns the PTY via proc_open. FPM workers communicate through the filesystem (stdin/stdout files). Session metadata is stored in Laravel's Cache (respects CACHE_STORE). The FileSessionManager implements the existing SessionManagerInterface and slots between TmuxSessionManager and ProcessSessionManager in the auto-detection chain.

Tech Stack: PHP 8.2+, Symfony Process (PTY), Laravel Cache, Pest testing


Task 1: Create the session-worker.php background script

Files:

  • Create: src/Sessions/session-worker.php

Step 1: Write the background worker script

This is a standalone PHP script spawned as a detached process. It owns the PTY and relays I/O through files.

<?php

declare(strict_types=1);

/**
 * Background session worker for FileSessionManager.
 *
 * This script is spawned as a detached process to manage an interactive
 * PTY session. It relays I/O between temp files and the PTY:
 * - Reads stdin file → writes to PTY stdin
 * - Reads PTY stdout/stderr → writes to stdout file
 * - Writes exit_code file when the command finishes
 *
 * Usage: php session-worker.php <session-dir> <command> [cwd] [env-json]
 */

// Validate arguments
if ($argc < 3) {
    fwrite(STDERR, "Usage: php session-worker.php <session-dir> <command> [cwd] [env-json]\n");
    exit(1);
}

$sessionDir = $argv[1];
$command = $argv[2];
$cwd = ($argc >= 4 && $argv[3] !== '') ? $argv[3] : null;
$envJson = ($argc >= 5) ? $argv[4] : null;

// Parse environment variables
$env = null;
if ($envJson !== null && $envJson !== '') {
    $env = json_decode($envJson, true);
    if (!is_array($env)) {
        $env = null;
    }
}

// Validate session directory exists
if (!is_dir($sessionDir)) {
    fwrite(STDERR, "Session directory does not exist: {$sessionDir}\n");
    exit(1);
}

$stdinFile = $sessionDir . '/stdin';
$stdoutFile = $sessionDir . '/stdout';
$pidFile = $sessionDir . '/pid';
$exitCodeFile = $sessionDir . '/exit_code';

// Write our PID
file_put_contents($pidFile, (string) getmypid());

// Set up PTY process
$descriptors = [
    0 => ['pty'],
    1 => ['pty'],
    2 => ['pty'],
];

$process = proc_open($command, $descriptors, $pipes, $cwd, $env);

if (!is_resource($process)) {
    file_put_contents($exitCodeFile, '1');
    exit(1);
}

// Set PTY output pipes to non-blocking
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);

// Open stdout file for appending
$stdoutHandle = fopen($stdoutFile, 'a');
if (!$stdoutHandle) {
    proc_terminate($process);
    file_put_contents($exitCodeFile, '1');
    exit(1);
}

// Track stdin file read position
$stdinPosition = 0;

// Ensure stdin file exists
if (!file_exists($stdinFile)) {
    touch($stdinFile);
}

// Install signal handler for graceful shutdown
$running = true;
if (function_exists('pcntl_signal')) {
    pcntl_signal(SIGTERM, function () use (&$running) {
        $running = false;
    });
    pcntl_signal(SIGINT, function () use (&$running) {
        $running = false;
    });
}

// Main I/O relay loop
while ($running) {
    // Check if process is still alive
    $status = proc_get_status($process);
    if (!$status['running']) {
        // Read any remaining output
        $out = stream_get_contents($pipes[1]);
        if ($out !== false && $out !== '') {
            fwrite($stdoutHandle, $out);
            fflush($stdoutHandle);
        }
        $err = stream_get_contents($pipes[2]);
        if ($err !== false && $err !== '') {
            fwrite($stdoutHandle, $err);
            fflush($stdoutHandle);
        }
        break;
    }

    // Dispatch signals if available
    if (function_exists('pcntl_signal_dispatch')) {
        pcntl_signal_dispatch();
    }

    // Read PTY stdout → append to stdout file
    $out = fread($pipes[1], 8192);
    if ($out !== false && $out !== '') {
        fwrite($stdoutHandle, $out);
        fflush($stdoutHandle);
    }

    // Read PTY stderr → append to stdout file
    $err = fread($pipes[2], 8192);
    if ($err !== false && $err !== '') {
        fwrite($stdoutHandle, $err);
        fflush($stdoutHandle);
    }

    // Read stdin file for new input → write to PTY stdin
    clearstatcache(true, $stdinFile);
    $fileSize = filesize($stdinFile);
    if ($fileSize !== false && $fileSize > $stdinPosition) {
        $stdinHandle = fopen($stdinFile, 'r');
        if ($stdinHandle) {
            fseek($stdinHandle, $stdinPosition);
            $input = fread($stdinHandle, $fileSize - $stdinPosition);
            fclose($stdinHandle);

            if ($input !== false && $input !== '') {
                fwrite($pipes[0], $input);
                fflush($pipes[0]);
                $stdinPosition = $fileSize;
            }
        }
    }

    // Small sleep to prevent CPU spinning
    usleep(10000); // 10ms
}

// Cleanup
fclose($stdoutHandle);
fclose($pipes[0]);
fclose($pipes[1]);
fclose($pipes[2]);

// Get exit code
$exitCode = $status['exitcode'] ?? -1;
if ($exitCode === -1) {
    // proc_get_status only returns real exit code on first call after exit
    // Try proc_close as fallback
    $exitCode = proc_close($process);
} else {
    proc_close($process);
}

// Write exit code
file_put_contents($exitCodeFile, (string) $exitCode);

Step 2: Verify the script is syntactically valid

Run: php -l src/Sessions/session-worker.php Expected: No syntax errors detected

Step 3: Commit

git add src/Sessions/session-worker.php
git commit -m "feat: add session-worker.php background script for FileSessionManager"

Task 2: Create FileSessionManager class

Files:

  • Create: src/Sessions/FileSessionManager.php

Step 1: Write the failing test

Create tests/Unit/Sessions/FileSessionManagerTest.php:

<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Cache;
use MWGuerra\WebTerminal\Sessions\FileSessionManager;

beforeEach(function () {
    $this->manager = new FileSessionManager;
    // Use a unique session dir per test to avoid conflicts
    $this->manager->setSessionBaseDir(storage_path('app/web-terminal/test-sessions-'.uniqid()));
});

afterEach(function () {
    // Cleanup: terminate all sessions and remove test directory
    $baseDir = $this->manager->getSessionBaseDir();
    if (is_dir($baseDir)) {
        // Kill any running processes
        $dirs = glob($baseDir.'/*', GLOB_ONLYDIR);
        foreach ($dirs as $dir) {
            $pidFile = $dir.'/pid';
            if (file_exists($pidFile)) {
                $pid = (int) file_get_contents($pidFile);
                if ($pid > 0) {
                    [@posix_kill](https://github.com/posix_kill)($pid, SIGKILL);
                }
            }
        }
        // Wait briefly for processes to die
        usleep(100000);
        // Remove directory tree
        $files = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($baseDir, RecursiveDirectoryIterator::SKIP_DOTS),
            RecursiveIteratorIterator::CHILD_FIRST
        );
        foreach ($files as $file) {
            $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
        }
        rmdir($baseDir);
    }
    Cache::flush();
});

describe('FileSessionManager', function () {
    describe('isAvailable', function () {
        it('reports availability based on PTY support', function () {
            $available = FileSessionManager::isAvailable();
            // On Linux/macOS with proc_open, PTY should be supported
            expect($available)->toBeBool();
        });
    });

    describe('start', function () {
        it('starts a process and returns a session ID', function () {
            $sessionId = $this->manager->start('echo "hello from file session"');

            expect($sessionId)->toBeString();
            expect($sessionId)->not->toBeEmpty();
            expect($this->manager->hasSession($sessionId))->toBeTrue();
        });

        it('creates session directory with required files', function () {
            $sessionId = $this->manager->start('echo "test"');
            $sessionDir = $this->manager->getSessionBaseDir().'/'.$sessionId;

            // Wait for worker to start
            usleep(200000);

            expect(is_dir($sessionDir))->toBeTrue();
            expect(file_exists($sessionDir.'/stdin'))->toBeTrue();
            expect(file_exists($sessionDir.'/stdout'))->toBeTrue();
            expect(file_exists($sessionDir.'/pid'))->toBeTrue();
        });

        it('starts process in specified working directory', function () {
            $sessionId = $this->manager->start('pwd', '/tmp');

            // Wait for output
            usleep(500000);

            $output = $this->manager->getOutput($sessionId);
            expect($output)->not->toBeNull();
            expect($output['stdout'])->toContain('/tmp');
        });
    });

    describe('getOutput', function () {
        it('returns incremental output', function () {
            $sessionId = $this->manager->start('echo "line1"; sleep 0.2; echo "line2"');

            // Wait for first echo
            usleep(300000);
            $output1 = $this->manager->getOutput($sessionId);
            expect($output1['stdout'])->toContain('line1');

            // Wait for second echo
            usleep(400000);
            $output2 = $this->manager->getOutput($sessionId);
            expect($output2['stdout'])->toContain('line2');

            // Output should be incremental (not repeat line1)
            expect($output2['stdout'])->not->toContain('line1');
        });

        it('returns null for nonexistent session', function () {
            expect($this->manager->getOutput('nonexistent'))->toBeNull();
        });
    });

    describe('sendInput', function () {
        it('sends input to a running process', function () {
            // Start a process that reads from stdin
            $sessionId = $this->manager->start('/bin/bash -c "read LINE; echo GOT:\$LINE"');

            usleep(300000);

            // Send input
            $result = $this->manager->sendInput($sessionId, 'hello');
            expect($result)->toBeTrue();

            // Wait for response
            usleep(500000);

            $output = $this->manager->getOutput($sessionId);
            expect($output['stdout'])->toContain('GOT:hello');
        });

        it('returns false for nonexistent session', function () {
            expect($this->manager->sendInput('nonexistent', 'test'))->toBeFalse();
        });
    });

    describe('sendRawInput', function () {
        it('sends raw input without appending newline', function () {
            $sessionId = $this->manager->start('/bin/bash');

            usleep(300000);

            // Send raw input (no auto-newline)
            $result = $this->manager->sendRawInput($sessionId, "echo raw\n");
            expect($result)->toBeTrue();

            usleep(500000);

            $output = $this->manager->getOutput($sessionId);
            expect($output['stdout'])->toContain('raw');
        });
    });

    describe('isRunning', function () {
        it('returns true for a running process', function () {
            $sessionId = $this->manager->start('sleep 5');

            usleep(300000);

            expect($this->manager->isRunning($sessionId))->toBeTrue();
        });

        it('returns false after process finishes', function () {
            $sessionId = $this->manager->start('echo "done"');

            // Wait for process to finish
            usleep(1000000);

            expect($this->manager->isRunning($sessionId))->toBeFalse();
        });

        it('returns false for nonexistent session', function () {
            expect($this->manager->isRunning('nonexistent'))->toBeFalse();
        });
    });

    describe('getExitCode', function () {
        it('returns exit code after process finishes', function () {
            $sessionId = $this->manager->start('echo "success"');

            // Wait for process to complete
            usleep(1000000);

            // Trigger running check to detect completion
            $this->manager->isRunning($sessionId);

            expect($this->manager->getExitCode($sessionId))->toBe(0);
        });

        it('returns non-zero exit code for failed commands', function () {
            $sessionId = $this->manager->start('exit 42');

            usleep(1000000);
            $this->manager->isRunning($sessionId);

            expect($this->manager->getExitCode($sessionId))->toBe(42);
        });
    });

    describe('terminate', function () {
        it('terminates a running process', function () {
            $sessionId = $this->manager->start('sleep 60');

            usleep(300000);
            expect($this->manager->isRunning($sessionId))->toBeTrue();

            $result = $this->manager->terminate($sessionId);
            expect($result)->toBeTrue();

            usleep(300000);

            expect($this->manager->isRunning($sessionId))->toBeFalse();
        });

        it('cleans up session directory', function () {
            $sessionId = $this->manager->start('sleep 60');
            $sessionDir = $this->manager->getSessionBaseDir().'/'.$sessionId;

            usleep(300000);

            $this->manager->terminate($sessionId);
            usleep(200000);

            expect(is_dir($sessionDir))->toBeFalse();
        });
    });

    describe('cleanup', function () {
        it('removes expired sessions', function () {
            $this->manager->setMaxSessionLifetime(1); // 1 second

            $sessionId = $this->manager->start('sleep 60');

            usleep(300000);
            expect($this->manager->hasSession($sessionId))->toBeTrue();

            // Wait for expiry
            sleep(2);

            $this->manager->cleanup();

            expect($this->manager->hasSession($sessionId))->toBeFalse();
        });
    });
});

Step 2: Run test to verify it fails

Run: vendor/bin/pest tests/Unit/Sessions/FileSessionManagerTest.php Expected: FAIL — class FileSessionManager not found

Step 3: Write FileSessionManager implementation

Create src/Sessions/FileSessionManager.php:

<?php

declare(strict_types=1);

namespace MWGuerra\WebTerminal\Sessions;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;

/**
 * File-based session manager for cross-worker interactive process persistence.
 *
 * Uses a background PHP worker process per session that owns the PTY.
 * FPM workers communicate through the filesystem:
 * - stdin file: FPM workers append input
 * - stdout file: background worker appends output
 * - Session metadata stored in Laravel Cache (respects CACHE_STORE)
 *
 * This manager requires no external dependencies (no tmux, no screen).
 * It works with any PHP SAPI: FPM, Octane, artisan serve.
 */
class FileSessionManager implements SessionManagerInterface
{
    protected const CACHE_PREFIX = 'swt:file:';

    protected string $sessionBaseDir;

    protected int $maxSessionLifetime = 300; // 5 minutes

    public function __construct()
    {
        $this->sessionBaseDir = storage_path('app/web-terminal/sessions');
    }

    /**
     * Check if file-based session management is available.
     *
     * Requires PTY support (proc_open + OS PTY) for terminal emulation.
     */
    public static function isAvailable(): bool
    {
        return Process::isPtySupported();
    }

    public function start(
        string $command,
        ?string $cwd = null,
        ?array $env = null,
        ?float $timeout = null,
    ): string {
        $sessionId = $this->generateSessionId();
        $sessionDir = $this->getSessionDir($sessionId);

        // Create session directory
        if (! is_dir($sessionDir)) {
            mkdir($sessionDir, 0700, true);
        }

        // Create empty stdin and stdout files
        touch($sessionDir.'/stdin');
        chmod($sessionDir.'/stdin', 0600);
        touch($sessionDir.'/stdout');
        chmod($sessionDir.'/stdout', 0600);

        // Build the worker command
        $workerScript = __DIR__.'/session-worker.php';
        $phpBinary = PHP_BINARY;

        $workerArgs = [
            escapeshellarg($sessionDir),
            escapeshellarg($command),
            escapeshellarg($cwd ?? ''),
            escapeshellarg($env !== null ? json_encode($env) : ''),
        ];

        $workerCommand = sprintf(
            'nohup %s %s %s > /dev/null 2>&1 &',
            escapeshellarg($phpBinary),
            escapeshellarg($workerScript),
            implode(' ', $workerArgs),
        );

        // Spawn the background worker
        $descriptors = [
            0 => ['pipe', 'r'],
            1 => ['pipe', 'w'],
            2 => ['pipe', 'w'],
        ];

        $proc = proc_open($workerCommand, $descriptors, $pipes);

        if (is_resource($proc)) {
            fclose($pipes[0]);
            fclose($pipes[1]);
            fclose($pipes[2]);
            proc_close($proc);
        }

        // Wait briefly for the worker to write its PID
        $pidFile = $sessionDir.'/pid';
        $attempts = 0;
        while (! file_exists($pidFile) && $attempts < 50) {
            usleep(20000); // 20ms
            $attempts++;
        }

        $pid = file_exists($pidFile) ? (int) file_get_contents($pidFile) : 0;

        // Store session metadata in cache
        $sessionData = new SharedSessionData(
            sessionId: $sessionId,
            command: $command,
            pid: $pid,
            startedAt: time(),
            backend: 'file',
        );

        $this->saveSessionData($sessionData);

        return $sessionId;
    }

    public function getOutput(string $sessionId): ?array
    {
        $sessionData = $this->getSessionData($sessionId);

        if ($sessionData === null) {
            return null;
        }

        $stdoutFile = $this->getSessionDir($sessionId).'/stdout';

        if (! file_exists($stdoutFile)) {
            return ['stdout' => '', 'stderr' => ''];
        }

        // Read incrementally from last position
        clearstatcache(true, $stdoutFile);
        $fileSize = filesize($stdoutFile);

        if ($fileSize === false || $fileSize <= $sessionData->lastOutputPosition) {
            return ['stdout' => '', 'stderr' => ''];
        }

        $handle = fopen($stdoutFile, 'r');
        if (! $handle) {
            return ['stdout' => '', 'stderr' => ''];
        }

        fseek($handle, $sessionData->lastOutputPosition);
        $newOutput = fread($handle, $fileSize - $sessionData->lastOutputPosition);
        fclose($handle);

        // Update position in cache
        $sessionData->lastOutputPosition = $fileSize;
        $sessionData->lastActivity = time();
        $this->saveSessionData($sessionData);

        return [
            'stdout' => $newOutput ?: '',
            'stderr' => '', // PTY combines stdout/stderr
        ];
    }

    public function sendInput(string $sessionId, string $input): bool
    {
        $sessionData = $this->getSessionData($sessionId);

        if ($sessionData === null || $sessionData->finished) {
            return false;
        }

        $stdinFile = $this->getSessionDir($sessionId).'/stdin';

        if (! file_exists($stdinFile)) {
            return false;
        }

        // Append newline if not present
        if (! str_ends_with($input, "\n")) {
            $input .= "\n";
        }

        $result = file_put_contents($stdinFile, $input, FILE_APPEND | LOCK_EX);

        if ($result !== false) {
            $sessionData->lastActivity = time();
            $this->saveSessionData($sessionData);

            return true;
        }

        return false;
    }

    public function sendRawInput(string $sessionId, string $input): bool
    {
        $sessionData = $this->getSessionData($sessionId);

        if ($sessionData === null || $sessionData->finished) {
            return false;
        }

        $stdinFile = $this->getSessionDir($sessionId).'/stdin';

        if (! file_exists($stdinFile)) {
            return false;
        }

        $result = file_put_contents($stdinFile, $input, FILE_APPEND | LOCK_EX);

        if ($result !== false) {
            $sessionData->lastActivity = time();
            $this->saveSessionData($sessionData);

            return true;
        }

        return false;
    }

    public function isRunning(string $sessionId): bool
    {
        $sessionData = $this->getSessionData($sessionId);

        if ($sessionData === null || $sessionData->finished) {
            return false;
        }

        $sessionDir = $this->getSessionDir($sessionId);
        $exitCodeFile = $sessionDir.'/exit_code';

        // Check if exit_code file exists (worker writes this when process finishes)
        if (file_exists($exitCodeFile)) {
            $exitCode = (int) file_get_contents($exitCodeFile);
            $sessionData->finished = true;
            $sessionData->exitCode = $exitCode;
            $this->saveSessionData($sessionData);

            return false;
        }

        // Verify PID is still alive
        if ($sessionData->pid > 0) {
            if (! $this->isPidAlive($sessionData->pid)) {
                $sessionData->finished = true;
                $sessionData->exitCode = -1; // Unknown exit code
                $this->saveSessionData($sessionData);

                return false;
            }
        }

        return true;
    }

    public function getExitCode(string $sessionId): ?int
    {
        $sessionData = $this->getSessionData($sessionId);

        if ($sessionData === null) {
            return null;
        }...
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
nexmo/api-specification
capell-app/block-library
axium/identity
cetria/laravel-dummy-models
cetria/reflection-helper
agropredict/sso-auth-bundle
evolvestudio/spam-protection
datacore/hub-sdk
develia/commons
cuci/prototurk-sdk
cuci/prototurk-sdk-symfony
develia/geo-bundle
dreamzy/livewire-charts
touchestate-sdk/php-sdk
ecotone/kafka
22h/doctrine-garbage-collection-bundle
agtp/agtp-php
agtp/mod-php
splash/sonata-admin
splash/metadata