For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a ghostty-web terminal mode to the web-terminal package with WebSocket PTY streaming, dual-mode toggle, and full feature parity.
Architecture: Dual Livewire component design — GhosttyTerminal (new) alongside WebTerminal (unchanged), wrapped by TerminalContainer when both are enabled. Ratchet WebSocket server bridges ghostty-web to a PTY process. See docs/superpowers/specs/2026-03-30-ghostty-terminal-design.md for full spec.
Tech Stack: PHP 8.2+, Laravel 12/13, Livewire 4, Alpine.js, ghostty-web (npm), cboden/ratchet, phpseclib3
Spec: docs/superpowers/specs/2026-03-30-ghostty-terminal-design.md
| File | Responsibility |
|---|---|
src/Enums/TerminalMode.php |
Classic/Ghostty enum with labels |
src/WebSocket/WebSocketProviderInterface.php |
Contract for WebSocket providers |
src/WebSocket/TerminalPtyBridge.php |
Manages PTY process lifecycle (local + SSH) |
src/WebSocket/PtySessionRegistry.php |
PID registry for orphan cleanup |
src/WebSocket/RatchetProvider.php |
Ratchet implementation of WebSocket provider |
src/WebSocket/RatchetServer.php |
Ratchet IoServer + WsServer setup |
src/Console/Commands/TerminalServeCommand.php |
php artisan terminal:serve command |
src/Http/Controllers/TerminalWebSocketController.php |
Token generation endpoint |
src/Livewire/GhosttyTerminal.php |
Livewire component for ghostty mode |
src/Livewire/TerminalContainer.php |
Wrapper with toggle pill for dual-mode |
resources/views/ghostty-terminal.blade.php |
Ghostty terminal Blade view |
resources/views/terminal-container.blade.php |
Container Blade view |
resources/views/partials/toggle-pill.blade.php |
Mode switcher partial |
resources/js/ghostty-terminal.js |
ghostty-web init, WebSocket client, FitAddon |
tests/Unit/Enums/TerminalModeTest.php |
Enum tests |
tests/Unit/WebSocket/TerminalPtyBridgeTest.php |
PTY bridge tests |
tests/Unit/WebSocket/PtySessionRegistryTest.php |
PID registry tests |
tests/Unit/WebSocket/RatchetProviderTest.php |
Ratchet provider tests |
tests/Unit/Livewire/GhosttyTerminalTest.php |
Ghostty component tests |
tests/Unit/Livewire/TerminalContainerTest.php |
Container component tests |
tests/Unit/Livewire/TerminalBuilderGhosttyTest.php |
Builder ghostty method tests |
| File | Changes |
|---|---|
src/Livewire/TerminalBuilder.php |
Add ghosttyTerminal(), classicTerminal(), defaultMode(), ghosttyTheme(), update render() and toHtml() |
src/WebTerminalServiceProvider.php |
Register new Livewire components, route, command, conditional JS asset |
config/web-terminal.php |
Add ghostty config section |
composer.json |
Add cboden/ratchet to suggest |
package.json |
Add ghostty-web dependency, add Vite/esbuild build script for JS |
resources/css/index.css |
Add toggle pill and ghostty overlay styles |
Files:
Create: src/Enums/TerminalMode.php
Test: tests/Unit/Enums/TerminalModeTest.php
Step 1: Write the failing test
<?php
declare(strict_types=1);
use MWGuerra\WebTerminal\Enums\TerminalMode;
describe('TerminalMode', function () {
it('has classic and ghostty cases', function () {
expect(TerminalMode::cases())->toHaveCount(2);
expect(TerminalMode::Classic->value)->toBe('classic');
expect(TerminalMode::Ghostty->value)->toBe('ghostty');
});
it('returns labels', function () {
expect(TerminalMode::Classic->label())->toBe('Classic');
expect(TerminalMode::Ghostty->label())->toBe('Ghostty');
});
it('returns descriptions', function () {
expect(TerminalMode::Classic->description())->toBeString()->not->toBeEmpty();
expect(TerminalMode::Ghostty->description())->toBeString()->not->toBeEmpty();
});
it('provides options array', function () {
$options = TerminalMode::options();
expect($options)->toBeArray()->toHaveCount(2);
expect($options['classic'])->toBe('Classic');
expect($options['ghostty'])->toBe('Ghostty');
});
});
Run: cd /home/guerra/projects/web-terminal && vendor/bin/pest tests/Unit/Enums/TerminalModeTest.php
Expected: FAIL — class not found
Create src/Enums/TerminalMode.php following the exact pattern of src/Enums/ConnectionType.php:
<?php
declare(strict_types=1);
namespace MWGuerra\WebTerminal\Enums;
enum TerminalMode: string
{
case Classic = 'classic';
case Ghostty = 'ghostty';
public function label(): string
{
return match ($this) {
self::Classic => 'Classic',
self::Ghostty => 'Ghostty',
};
}
public function description(): string
{
return match ($this) {
self::Classic => 'Command-by-command terminal via Livewire',
self::Ghostty => 'Full interactive PTY terminal via WebSocket',
};
}
public static function options(): array
{
return array_combine(
array_column(self::cases(), 'value'),
array_map(fn (self $case) => $case->label(), self::cases())
);
}
}
Run: cd /home/guerra/projects/web-terminal && vendor/bin/pest tests/Unit/Enums/TerminalModeTest.php
Expected: PASS (all 4 tests)
git add src/Enums/TerminalMode.php tests/Unit/Enums/TerminalModeTest.php
git commit -m "feat: add TerminalMode enum (Classic/Ghostty)"
Files:
src/Livewire/TerminalBuilder.phptests/Unit/Livewire/TerminalBuilderGhosttyTest.phpReference: Read the existing TerminalBuilder.php and tests/Unit/Livewire/TerminalBuilderTest.php for patterns.
Create tests/Unit/Livewire/TerminalBuilderGhosttyTest.php:
<?php
declare(strict_types=1);
use MWGuerra\WebTerminal\Enums\TerminalMode;
use MWGuerra\WebTerminal\Livewire\TerminalBuilder;
describe('TerminalBuilder Ghostty Methods', function () {
describe('ghosttyTerminal()', function () {
it('enables ghostty mode', function () {
$builder = new TerminalBuilder;
$builder->local()->ghosttyTerminal();
$params = $builder->getParameters();
expect($params['ghosttyEnabled'])->toBeTrue();
});
it('disables ghostty mode explicitly', function () {
$builder = new TerminalBuilder;
$builder->local()->ghosttyTerminal(false);
$params = $builder->getParameters();
expect($params['ghosttyEnabled'])->toBeFalse();
});
it('is disabled by default', function () {
$builder = new TerminalBuilder;
$builder->local();
$params = $builder->getParameters();
expect($params['ghosttyEnabled'])->toBeFalse();
});
});
describe('classicTerminal()', function () {
it('is enabled by default', function () {
$builder = new TerminalBuilder;
$builder->local();
$params = $builder->getParameters();
expect($params['classicEnabled'])->toBeTrue();
});
it('can be disabled', function () {
$builder = new TerminalBuilder;
$builder->local()->ghosttyTerminal()->classicTerminal(false);
$params = $builder->getParameters();
expect($params['classicEnabled'])->toBeFalse();
});
});
describe('defaultMode()', function () {
it('defaults to classic', function () {
$builder = new TerminalBuilder;
$builder->local()->ghosttyTerminal();
$params = $builder->getParameters();
expect($params['defaultMode'])->toBe('classic');
});
it('can be set to ghostty', function () {
$builder = new TerminalBuilder;
$builder->local()->ghosttyTerminal()->defaultMode(TerminalMode::Ghostty);
$params = $builder->getParameters();
expect($params['defaultMode'])->toBe('ghostty');
});
});
describe('ghosttyTheme()', function () {
it('stores theme options', function () {
$theme = ['background' => '#1a1b26', 'fontSize' => 14];
$builder = new TerminalBuilder;
$builder->local()->ghosttyTerminal()->ghosttyTheme($theme);
$params = $builder->getParameters();
expect($params['ghosttyTheme'])->toBe($theme);
});
it('defaults to empty array', function () {
$builder = new TerminalBuilder;
$builder->local()->ghosttyTerminal();
$params = $builder->getParameters();
expect($params['ghosttyTheme'])->toBe([]);
});
});
describe('validation', function () {
it('throws when both modes disabled', function () {
$builder = new TerminalBuilder;
$builder->local()->classicTerminal(false);
expect(fn () => $builder->render())->toThrow(\InvalidArgumentException::class, 'At least one terminal mode must be enabled');
});
it('throws when defaultMode set to disabled mode', function () {
$builder = new TerminalBuilder;
$builder->local()->defaultMode(TerminalMode::Ghostty);
expect(fn () => $builder->render())->toThrow(\InvalidArgumentException::class);
});
});
});
Run: cd /home/guerra/projects/web-terminal && vendor/bin/pest tests/Unit/Livewire/TerminalBuilderGhosttyTest.php
Expected: FAIL — methods not found
Open src/Livewire/TerminalBuilder.php. Add new properties alongside existing ones:
protected bool $ghosttyEnabled = false;
protected bool $classicEnabled = true;
protected TerminalMode $defaultMode = TerminalMode::Classic;
protected array $ghosttyTheme = [];
Add the fluent methods (following existing patterns like allowAllCommands()):
public function ghosttyTerminal(bool $enabled = true): static
{
$this->ghosttyEnabled = $enabled;
return $this;
}
public function classicTerminal(bool $enabled = true): static
{
$this->classicEnabled = $enabled;
return $this;
}
public function defaultMode(TerminalMode $mode = TerminalMode::Classic): static
{
$this->defaultMode = $mode;
return $this;
}
public function ghosttyTheme(array $theme): static
{
$this->ghosttyTheme = $theme;
return $this;
}
Add the new keys to getParameters(). Important: Only include ghostty-related params when ghostty is actually enabled, to avoid passing unexpected properties to the classic WebTerminal component:
// In getParameters(), add conditionally:
if ($this->ghosttyEnabled) {
$params['ghosttyEnabled'] = true;
$params['ghosttyTheme'] = $this->ghosttyTheme;
}
if (! $this->classicEnabled) {
$params['classicEnabled'] = false;
}
$params['defaultMode'] = $this->defaultMode->value;
Add validation to render() before the existing rendering logic:
if (! $this->classicEnabled && ! $this->ghosttyEnabled) {
throw new \InvalidArgumentException('At least one terminal mode must be enabled');
}
if ($this->defaultMode === TerminalMode::Ghostty && ! $this->ghosttyEnabled) {
throw new \InvalidArgumentException('Cannot set default mode to Ghostty when Ghostty is disabled');
}
if ($this->defaultMode === TerminalMode::Classic && ! $this->classicEnabled) {
throw new \InvalidArgumentException('Cannot set default mode to Classic when Classic is disabled');
}
Important: Don't change the actual rendering logic yet (Task 8 handles routing to the correct component). Just add the properties, methods, validation, and parameter output.
Run: cd /home/guerra/projects/web-terminal && vendor/bin/pest tests/Unit/Livewire/TerminalBuilderGhosttyTest.php
Expected: PASS (all tests)
Run: cd /home/guerra/projects/web-terminal && vendor/bin/pest tests/Unit/Livewire/TerminalBuilderTest.php
Expected: PASS (all existing tests still pass)
git add src/Livewire/TerminalBuilder.php tests/Unit/Livewire/TerminalBuilderGhosttyTest.php
git commit -m "feat: add ghostty fluent methods to TerminalBuilder"
Files:
Modify: config/web-terminal.php
Modify: composer.json
Modify: package.json
Step 1: Add ghostty config section
Open config/web-terminal.php. Add at the end, before the closing ];:
/*
|--------------------------------------------------------------------------
| Ghostty Terminal Mode
|--------------------------------------------------------------------------
|
| Configuration for the ghostty-web terminal mode. This mode provides a
| full interactive PTY shell via WebSocket, using the ghostty-web WASM
| terminal emulator. Requires cboden/ratchet to be installed.
|
| See: docs/superpowers/specs/2026-03-30-ghostty-terminal-design.md
|
*/
'ghostty' => [
'enabled' => env('WEB_TERMINAL_GHOSTTY_ENABLED', false),
'websocket_provider' => 'ratchet',
'ratchet_host' => env('WEB_TERMINAL_RATCHET_HOST', '127.0.0.1'),
'ratchet_port' => env('WEB_TERMINAL_RATCHET_PORT', 8090),
'pty_grace_period' => 30,
'max_session_lifetime' => 3600,
'signed_url_ttl' => 300,
'allowed_origins' => [env('APP_URL', 'http://localhost')],
'theme' => [
'background' => '#1a1b26',
'foreground' => '#a9b1d6',
'fontSize' => 14,
],
],
In composer.json, add to the suggest section:
"suggest": {
"filament/filament": "Required for Filament admin panel integration (^5.0)",
"cboden/ratchet": "Required for Ghostty terminal mode - WebSocket PTY bridge (^0.4)"
}
Update package.json to add ghostty-web and a JS build script:
{
"private": true,
"scripts": {
"build": "npx [@tailwindcss](https://github.com/tailwindcss)/cli -i resources/css/index.css -o resources/dist/web-terminal.css --minify",
"build:js": "npx esbuild resources/js/ghostty-terminal.js --bundle --minify --format=iife --outfile=resources/dist/ghostty-terminal.js",
"build:all": "npm run build && npm run build:js"
},
"dependencies": {
"ghostty-web": "^0.4.0"
},
"devDependencies": {
"[@tailwindcss](https://github.com/tailwindcss)/cli": "^4.1.0",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.0"
}
}
Run: cd /home/guerra/projects/web-terminal && npm install
Expected: ghostty-web and esbuild installed successfully
git add config/web-terminal.php composer.json package.json package-lock.json
git commit -m "feat: add ghostty config, ratchet suggest dep, ghostty-web npm dep"
Files:
Create: src/WebSocket/PtySessionRegistry.php
Test: tests/Unit/WebSocket/PtySessionRegistryTest.php
Step 1: Write the failing tests
<?php
declare(strict_types=1);
use MWGuerra\WebTerminal\WebSocket\PtySessionRegistry;
beforeEach(function () {
$this->registryPath = sys_get_temp_dir() . '/web-terminal-test-' . uniqid() . '/pty-sessions.json';
$this->registry = new PtySessionRegistry(dirname($this->registryPath));
});
afterEach(function () {
if (file_exists($this->registryPath)) {
unlink($this->registryPath);
}
$dir = dirname($this->registryPath);
if (is_dir($dir)) {
rmdir($dir);
}
});
describe('PtySessionRegistry', function () {
it('registers a session with PID', function () {
$this->registry->register('session-1', 12345, 1);
$sessions = $this->registry->all();
expect($sessions)->toHaveKey('session-1');
expect($sessions['session-1']['pid'])->toBe(12345);
expect($sessions['session-1']['userId'])->toBe(1);
});
it('unregisters a session', function () {
$this->registry->register('session-1', 12345, 1);
$this->registry->unregister('session-1');
expect($this->registry->all())->not->toHaveKey('session-1');
});
it('finds a session by ID', function () {
$this->registry->register('session-1', 12345, 1);
$session = $this->registry->find('session-1');
expect($session)->not->toBeNull();
expect($session['pid'])->toBe(12345);
});
it('returns null for unknown session', function () {
expect($this->registry->find('nonexistent'))->toBeNull();
});
it('creates directory if it does not exist', function () {
$dir = dirname($this->registryPath);
expect(is_dir($dir))->toBeFalse();
$this->registry->register('session-1', 12345, 1);
expect(is_dir($dir))->toBeTrue();
});
it('records created_at timestamp', function () {
$before = time();
$this->registry->register('session-1', 12345, 1);
$session = $this->registry->find('session-1');
expect($session['createdAt'])->toBeGreaterThanOrEqual($before);
});
it('cleans up stale sessions', function () {
// Register with a past timestamp by writing directly
$dir = dirname($this->registryPath);
if (! is_dir($dir)) { mkdir($dir, 0755, true); }
file_put_contents($this->registryPath, json_encode([
'old-session' => ['pid' => 99999, 'userId' => 1, 'createdAt' => time() - 7200],
'new-session' => ['pid' => 88888, 'userId' => 1, 'createdAt' => time()],
]));
$stale = $this->registry->cleanupStale(3600); // 1 hour max
expect($stale)->toHaveKey('old-session');
expect($stale)->not->toHaveKey('new-session');
expect($this->registry->find('old-session'))->toBeNull();
expect($this->registry->find('new-session'))->not->toBeNull();
});
});
Run: cd /home/guerra/projects/web-terminal && vendor/bin/pest tests/Unit/WebSocket/PtySessionRegistryTest.php
Expected: FAIL — class not found
Create src/WebSocket/PtySessionRegistry.php:
<?php
declare(strict_types=1);
namespace MWGuerra\WebTerminal\WebSocket;
class PtySessionRegistry
{
private string $registryPath;
public function __construct(string $storagePath)
{
$this->registryPath = rtrim($storagePath, '/') . '/pty-sessions.json';
}
public function register(string $sessionId, int $pid, int $userId): void
{
$sessions = $this->all();
$sessions[$sessionId] = [
'pid' => $pid,
'userId' => $userId,
'createdAt' => time(),
];
$this->save($sessions);
}
public function unregister(string $sessionId): void
{
$sessions = $this->all();
unset($sessions[$sessionId]);
$this->save($sessions);
}
public function find(string $sessionId): ?array
{
return $this->all()[$sessionId] ?? null;
}
public function all(): array
{
if (! file_exists($this->registryPath)) {
return [];
}
$content = file_get_contents($this->registryPath);
if ($content === false || $content === '') {
return [];
}
return json_decode($content, true) ?? [];
}
public function cleanupStale(int $maxLifetimeSeconds): array
{
$sessions = $this->all();
$stale = [];
$now = time();
foreach ($sessions as $sessionId => $session) {
if ($now - $session['createdAt'] > $maxLifetimeSeconds) {
$stale[$sessionId] = $session;
unset($sessions[$sessionId]);
}
}
$this->save($sessions);
return $stale;
}
private function save(array $sessions): void
{
$dir = dirname($this->registryPath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents(
$this->registryPath,
json_encode($sessions, JSON_PRETTY_PRINT),
LOCK_EX
);
}
}
Run: cd /home/guerra/projects/web-terminal && vendor/bin/pest tests/Unit/WebSocket/PtySessionRegistryTest.php
Expected: PASS (all 6 tests)
git add src/WebSocket/PtySessionRegistry.php tests/Unit/WebSocket/PtySessionRegistryTest.php
git commit -m "feat: add PtySessionRegistry for orphan process cleanup"
Files:
Create: src/WebSocket/WebSocketProviderInterface.php
Create: src/WebSocket/TerminalPtyBridge.php
Test: tests/Unit/WebSocket/TerminalPtyBridgeTest.php
Step 1: Create the WebSocketProviderInterface
<?php
declare(strict_types=1);
namespace MWGuerra\WebTerminal\WebSocket;
interface WebSocketProviderInterface
{
public function start(string $host, int $port): void;
public function stop(): void;
public function sendToConnection(string $sessionId, string $data): void;
}
The sendToConnection method is the key extensibility point — it...
How can I help you explore Laravel packages today?