diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a9d985..7e328cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,25 +94,28 @@ jobs: fi printf "%s\n" "$CHANGELOG" echo "" - echo "### Installation" + echo "### CLI Installation" + echo "" + echo "Install globally via npm:" + echo "" + printf '%s\n' '```bash' + printf '%s\n' 'npm install -g pty-skill' + printf '%s\n' '# or run directly with npx' + printf '%s\n' 'npx pty-skill --help' + printf '%s\n' '```' + echo "" + echo "Or download a pre-built binary from the release assets above." + echo "" + echo "### OpenCode Plugin" echo "" echo "Add to your \`opencode.json\`:" echo "" printf '%s\n' '```json' printf '%s\n' '{' printf '%s\n' ' "$schema": "https://opencode.ai/config.json",' - printf '%s\n' ' "plugin": ["opencode-pty"]' + printf '%s\n' ' "plugin": ["pty-skill"]' printf '%s\n' '}' printf '%s\n' '```' - echo "" - echo "### Update Instructions" - echo "" - echo "To get the latest version:" - echo "" - printf '%s\n' '```bash' - printf '%s\n' 'rm -rf ~/.cache/opencode/node_modules/opencode-pty' - printf '%s\n' 'opencode' - printf '%s\n' '```' } >"$BODY_FILE" cat "$BODY_FILE" { @@ -121,8 +124,19 @@ jobs: echo "EOF" } >>"$GITHUB_OUTPUT" + - name: Setup Bun + if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Build CLI binaries + if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' + run: bun run build + - name: Create GitHub release if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' + id: create_release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -132,6 +146,39 @@ jobs: body: ${{ steps.release_notes.outputs.body }} generate_release_notes: false + - name: Upload darwin-arm64 binary + if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./dist/pty-skill-darwin-arm64 + asset_name: pty-skill-darwin-arm64 + asset_content_type: application/octet-stream + + - name: Upload darwin-x64 binary + if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./dist/pty-skill-darwin-x64 + asset_name: pty-skill-darwin-x64 + asset_content_type: application/octet-stream + + - name: Upload linux-x64 binary + if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./dist/pty-skill-linux-x64 + asset_name: pty-skill-linux-x64 + asset_content_type: application/octet-stream + - name: Publish to npm if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' run: npm publish --access public --provenance diff --git a/README.md b/README.md index 88dfa34..1da97ca 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ -# opencode-pty +# pty-skill -A plugin for [OpenCode](https://opencode.ai) that provides interactive PTY (pseudo-terminal) management, enabling the AI agent to run background processes, send interactive input, and read output on demand. +Interactive PTY (pseudo-terminal) management for AI agents. Works as a **standalone CLI** for Claude Code, Codex CLI, and other AI tools, or as an **OpenCode plugin**. ## Why? -OpenCode's built-in `bash` tool runs commands synchronously—the agent waits for completion. This works for quick commands, but not for: +AI agents need to interact with long-running processes, but most tools run commands synchronously. This doesn't work for: - **Dev servers** (`npm run dev`, `cargo watch`) - **Watch modes** (`npm test -- --watch`) - **Long-running processes** (database servers, tunnels) - **Interactive programs** (REPLs, prompts) -This plugin gives the agent full control over multiple terminal sessions, like tabs in a terminal app. +This tool gives AI agents full control over multiple terminal sessions, like tabs in a terminal app. ## Features @@ -24,74 +24,90 @@ This plugin gives the agent full control over multiple terminal sessions, like t - **Session Lifecycle**: Sessions persist until explicitly killed - **Auto-cleanup**: PTYs are cleaned up when OpenCode sessions end -## Setup +## Installation -Add the plugin to your [OpenCode config](https://opencode.ai/docs/config/): +### CLI (for Claude Code, Codex CLI, etc.) + +```bash +# Install globally +npm install -g pty-skill + +# Or run directly with npx +npx pty-skill --help +``` + +The CLI runs a background daemon that maintains PTY sessions across invocations. The daemon auto-starts on first use. + +### OpenCode Plugin + +Add to your [OpenCode config](https://opencode.ai/docs/config/): ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-pty"] + "plugin": ["pty-skill"] } ``` -That's it. OpenCode will automatically install the plugin on next run. +OpenCode will automatically install the plugin on next run. -## Updating +## CLI Usage -> [!WARNING] -> OpenCode does NOT auto-update plugins. +```bash +# Start a dev server +pty-skill spawn -t "Dev Server" npm run dev +# Returns: pty_abc123 -To get the latest version, clear the cached plugin and let OpenCode reinstall it: +# Check server output +pty-skill read pty_abc123 --limit 50 -```bash -rm -rf ~/.cache/opencode/node_modules/opencode-pty -opencode +# Filter for errors +pty-skill read pty_abc123 --pattern "error" --ignore-case + +# Send Ctrl+C to stop +pty-skill write pty_abc123 "\x03" + +# List all sessions +pty-skill list + +# Kill and cleanup +pty-skill kill pty_abc123 --cleanup + +# Check daemon status +pty-skill status ``` -## Tools Provided +See [SKILL.md](SKILL.md) for complete CLI documentation. -| Tool | Description | -|------|-------------| -| `pty_spawn` | Create a new PTY session (command, args, workdir, env, title) | -| `pty_write` | Send input to a PTY (text, escape sequences like `\x03` for Ctrl+C) | -| `pty_read` | Read output buffer with pagination and optional regex filtering | -| `pty_list` | List all PTY sessions with status, PID, line count | -| `pty_kill` | Terminate a PTY, optionally cleanup the buffer | +### Example: Claude Code using pty-skill -## Usage Examples +See [examples/claude-code-conversation.md](examples/claude-code-conversation.md) for a real conversation where Claude Code uses pty-skill to test nvim - spawning a session, sending vim commands, and saving a file. -### Start a dev server +## OpenCode Plugin Tools + +| Tool | Description | +| ----------- | ------------------------------------------------------------------- | +| `pty_spawn` | Create a new PTY session (command, args, workdir, env, title) | +| `pty_write` | Send input to a PTY (text, escape sequences like `\x03` for Ctrl+C) | +| `pty_read` | Read output buffer with pagination and optional regex filtering | +| `pty_list` | List all PTY sessions with status, PID, line count | +| `pty_kill` | Terminate a PTY, optionally cleanup the buffer | + +### Plugin Usage Examples ``` pty_spawn: command="npm", args=["run", "dev"], title="Dev Server" → Returns: pty_a1b2c3d4 -``` - -### Check server output -``` pty_read: id="pty_a1b2c3d4", limit=50 → Shows last 50 lines of output -``` - -### Filter for errors -``` pty_read: id="pty_a1b2c3d4", pattern="error|ERROR", ignoreCase=true → Shows only lines matching the pattern -``` -### Send Ctrl+C to stop - -``` pty_write: id="pty_a1b2c3d4", data="\x03" → Sends interrupt signal -``` -### Kill and cleanup - -``` pty_kill: id="pty_a1b2c3d4", cleanup=true → Terminates process and frees buffer ``` @@ -100,8 +116,8 @@ pty_kill: id="pty_a1b2c3d4", cleanup=true ### Environment Variables -| Variable | Default | Description | -|----------|---------|-------------| +| Variable | Default | Description | +| ---------------------- | ------- | -------------------------------------------------- | | `PTY_MAX_BUFFER_LINES` | `50000` | Maximum lines to keep in output buffer per session | ### Permissions @@ -125,7 +141,6 @@ This plugin respects OpenCode's [permission settings](https://opencode.ai/docs/p > **Limitations compared to built-in bash tool:** > > - **"ask" permissions are treated as "deny"**: Since plugins cannot trigger OpenCode's permission prompt UI, commands matching an "ask" pattern will be denied. A toast notification will inform you when this happens. Configure explicit "allow" or "deny" for commands you want to use with PTY. -> > - **"external_directory" with "ask" is treated as "allow"**: When the working directory is outside the project and `permission.external_directory` is set to "ask", this plugin allows it (with a log message). Set to "deny" explicitly if you want to block external directories. #### Example: Allow specific commands for PTY @@ -163,6 +178,7 @@ spawn → running → [exited | killed] ``` Sessions remain in the list after exit so the agent can: + - Read final output - Check exit code - Compare logs between runs @@ -171,14 +187,101 @@ Use `pty_kill` with `cleanup=true` to remove completely. ## Local Development +### Prerequisites + +- [Bun](https://bun.sh) runtime (required for bun-pty) + +### Setup + ```bash -git clone https://github.com/shekohex/opencode-pty.git +git clone https://github.com/fcoury/pty-skill.git cd opencode-pty bun install -bun run tsc --noEmit # Type check ``` -To load a local checkout in OpenCode: +### Running the CLI Locally + +```bash +# Run CLI directly from source +bun cli/bin/pty-skill.ts --help + +# Test the full flow +bun cli/bin/pty-skill.ts daemon start +bun cli/bin/pty-skill.ts spawn echo "Hello" +bun cli/bin/pty-skill.ts list +bun cli/bin/pty-skill.ts daemon stop +``` + +### Installing as a Skill for Claude Code + +To use this as a skill in Claude Code during development: + +**Option 1: Link globally (recommended for development)** + +```bash +# From the repo directory +bun link + +# Now you can use it anywhere +pty-skill --help +``` + +**Option 2: Add to PATH** + +```bash +# Add an alias to your shell config (.bashrc, .zshrc, etc.) +alias pty-skill="bun /path/to/opencode-pty/cli/bin/pty-skill.ts" +``` + +**Option 3: Install from local path** + +```bash +npm install -g /path/to/opencode-pty +``` + +### Setting up the Skill for Claude Code + +Skills require their own subdirectory. To set up for Claude Code: + +```bash +# Create the skill directory +mkdir -p ~/.claude/skills/pty-skill + +# Option 1: Symlink just the SKILL.md (for development) +ln -s /path/to/opencode-pty/SKILL.md ~/.claude/skills/pty-skill/SKILL.md + +# Option 2: Symlink the whole repo (includes all resources) +ln -s /path/to/opencode-pty ~/.claude/skills/pty-skill +``` + +The skill directory structure: + +``` +~/.claude/skills/ +└── pty-skill/ + └── SKILL.md # Required - skill documentation +``` + +Once installed, Claude Code will automatically use it when it detects relevant triggers (like "start a dev server"), or you can invoke it explicitly. + +### Building Binaries + +```bash +# Build for all platforms +bun run build + +# Build for specific platform +bun run build:darwin-arm64 +bun run build:darwin-x64 +bun run build:linux-x64 + +# Binaries are output to dist/ +ls -la dist/ +``` + +### Testing the OpenCode Plugin + +To load the plugin from a local checkout in OpenCode: ```json { @@ -187,6 +290,26 @@ To load a local checkout in OpenCode: } ``` +### Project Structure + +``` +opencode-pty/ +├── cli/ # Standalone CLI +│ ├── bin/pty-skill.ts # CLI entry point +│ ├── client.ts # Daemon client +│ ├── commands/ # CLI commands +│ └── daemon/ # Background daemon +├── src/ +│ ├── core/ # Shared core (used by both CLI and plugin) +│ │ ├── manager.ts # PTY session manager +│ │ ├── buffer.ts # Ring buffer for output +│ │ └── types.ts # Type definitions +│ └── plugin/ # OpenCode plugin +│ └── pty/tools/ # Plugin tool definitions +├── SKILL.md # AI agent skill documentation +└── index.ts # Plugin entry point +``` + ## License MIT diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..44573ee --- /dev/null +++ b/SKILL.md @@ -0,0 +1,350 @@ +--- +name: pty-skill +description: Interactive PTY (pseudo-terminal) management for long-running processes. Use when you need to run dev servers, watch modes, REPLs, or any interactive CLI program that requires sending input and reading output over time. +triggers: + - start a dev server + - run in background + - interactive terminal + - send Ctrl+C + - check server output + - background process + - long-running command + - REPL session + - watch mode +--- + +# pty-skill + +Manage interactive pseudo-terminal sessions for background processes, dev servers, and interactive programs. + +## Dependencies + +The `pty-skill` CLI must be installed. If not available, install it: + +```bash +# Install globally via npm +npm install -g pty-skill + +# Or run directly with npx (no install needed) +npx pty-skill --help +``` + +The CLI runs a background daemon that maintains PTY sessions across invocations. The daemon auto-starts on first use. + +## When to Use + +Use this skill when you need to: +- Start long-running processes (dev servers, watch modes, database servers) +- Send interactive input to running processes (Ctrl+C, commands, responses) +- Monitor output from background processes +- Manage multiple terminal sessions simultaneously + +## Quick Reference + +```bash +# Start a dev server +pty-skill spawn npm run dev + +# Check server output (last 100 lines) +pty-skill read pty_abc123 --limit 100 + +# Look for errors +pty-skill read pty_abc123 --pattern "error|ERROR" --ignore-case + +# Send Ctrl+C to stop +pty-skill write pty_abc123 "\x03" + +# List all sessions +pty-skill list + +# Kill and remove a session +pty-skill kill pty_abc123 --cleanup +``` + +## Commands + +### spawn - Start a PTY session + +```bash +pty-skill spawn [options] [args...] + +Options: + -w, --workdir Working directory (default: current) + -e, --env Environment variable (can repeat) + -t, --title Human-readable title + -j, --json Output as JSON +``` + +**Examples:** +```bash +# Start Next.js dev server +pty-skill spawn -t "Next.js Dev" npm run dev + +# Start with custom port +pty-skill spawn -e PORT=4000 npm start + +# Start Python REPL +pty-skill spawn python3 + +# Start in specific directory +pty-skill spawn -w /app -t "API Server" cargo run +``` + +### read - Read PTY output + +```bash +pty-skill read [options] <id> + +Options: + -o, --offset <n> Start from line n (default: 0) + -l, --limit <n> Max lines to read (default: 500) + -p, --pattern <regex> Filter lines by regex pattern + -i, --ignore-case Case-insensitive pattern matching + -j, --json Output as JSON +``` + +**Examples:** +```bash +# Read last 50 lines +pty-skill read pty_abc123 --limit 50 + +# Find compilation errors +pty-skill read pty_abc123 --pattern "error|failed" --ignore-case + +# Read from specific offset +pty-skill read pty_abc123 --offset 100 --limit 50 + +# Get output as JSON for parsing +pty-skill read pty_abc123 --json +``` + +### write - Send input to PTY + +```bash +pty-skill write <id> <data> + +Options: + -j, --json Output as JSON +``` + +**Escape Sequences:** +| Sequence | Meaning | +|----------|---------| +| `\n` | Enter/newline | +| `\r` | Carriage return | +| `\t` | Tab | +| `\x03` | Ctrl+C (interrupt) | +| `\x04` | Ctrl+D (EOF) | +| `\x1a` | Ctrl+Z (suspend) | +| `\x1b[A` | Arrow Up | +| `\x1b[B` | Arrow Down | +| `\x1b[C` | Arrow Right | +| `\x1b[D` | Arrow Left | +| `\\` | Literal backslash | + +**Examples:** +```bash +# Send a command +pty-skill write pty_abc123 "ls -la\n" + +# Interrupt process (Ctrl+C) +pty-skill write pty_abc123 "\x03" + +# Answer a prompt +pty-skill write pty_abc123 "yes\n" + +# Send EOF (Ctrl+D) +pty-skill write pty_abc123 "\x04" + +# Navigate with arrow keys +pty-skill write pty_abc123 "\x1b[A" # Up arrow +``` + +### list - List PTY sessions + +```bash +pty-skill list [options] + +Options: + -s, --status <status> Filter: running|exited|killed + -j, --json Output as JSON +``` + +**Examples:** +```bash +# List all sessions +pty-skill list + +# List only running sessions +pty-skill list --status running + +# Get as JSON for parsing +pty-skill list --json +``` + +### kill - Terminate a PTY + +```bash +pty-skill kill [options] <id> + +Options: + -c, --cleanup Remove session and free buffer + -j, --json Output as JSON +``` + +**Examples:** +```bash +# Kill but keep output accessible +pty-skill kill pty_abc123 + +# Kill and remove completely +pty-skill kill pty_abc123 --cleanup +``` + +### status - Check daemon status + +```bash +pty-skill status [options] + +Options: + -j, --json Output as JSON +``` + +### daemon - Manage the daemon + +```bash +pty-skill daemon <action> + +Actions: + start Start the daemon + stop Stop the daemon + restart Restart the daemon + logs Show daemon logs +``` + +## Workflow Patterns + +### Start and Monitor Dev Server + +```bash +# 1. Start the server +pty-skill spawn -t "Dev Server" npm run dev +# Returns: pty_abc123 + +# 2. Wait briefly for startup +sleep 2 + +# 3. Check for startup success +pty-skill read pty_abc123 --pattern "ready|listening" --limit 10 + +# 4. If errors, check logs +pty-skill read pty_abc123 --pattern "error" --ignore-case +``` + +### Interactive REPL Session + +```bash +# 1. Start Python REPL +pty-skill spawn -t "Python" python3 + +# 2. Send commands +pty-skill write pty_abc123 "import sys\n" +pty-skill write pty_abc123 "print(sys.version)\n" + +# 3. Read output +pty-skill read pty_abc123 --limit 20 + +# 4. Exit REPL +pty-skill write pty_abc123 "exit()\n" +``` + +### Graceful Shutdown + +```bash +# 1. Send interrupt signal +pty-skill write pty_abc123 "\x03" + +# 2. Wait for graceful shutdown +sleep 2 + +# 3. Check if exited +pty-skill list --json + +# 4. If still running, force kill +pty-skill kill pty_abc123 --cleanup +``` + +### Run Tests with Live Output + +```bash +# 1. Start test runner +pty-skill spawn -t "Tests" npm test + +# 2. Periodically check for failures +pty-skill read pty_abc123 --pattern "FAIL|Error" --ignore-case + +# 3. When done, check final status +pty-skill list --json +``` + +### Database Interactive Session + +```bash +# 1. Start psql +pty-skill spawn -t "PostgreSQL" psql mydb + +# 2. Run queries +pty-skill write pty_abc123 "SELECT * FROM users LIMIT 5;\n" + +# 3. Read results +pty-skill read pty_abc123 --limit 50 + +# 4. Exit +pty-skill write pty_abc123 "\\q\n" +``` + +## Troubleshooting + +### Daemon not responding +```bash +pty-skill daemon restart +``` + +### Session ID not found +```bash +# List all sessions to find correct ID +pty-skill list +``` + +### No output appearing +```bash +# Buffer might be empty - wait for process output +sleep 1 +pty-skill read pty_abc123 + +# Or check if process exited +pty-skill list --json +``` + +### Process not responding to input +```bash +# Check if process is still running +pty-skill list --status running + +# Try sending Ctrl+C first +pty-skill write pty_abc123 "\x03" +``` + +## Configuration + +**Environment Variables:** +- `PTY_MAX_BUFFER_LINES`: Max lines per session buffer (default: 50,000) +- `PTY_SKILL_SOCKET`: Custom socket path (default: `~/.pty-skill/daemon.sock`) + +## Notes + +- Sessions persist until explicitly killed or daemon stops +- Output buffer stores up to 50,000 lines per session +- The daemon auto-starts on first CLI invocation +- All PTY operations are local (no network required) +- Exited processes remain in list for log inspection until cleanup diff --git a/build-cli.ts b/build-cli.ts new file mode 100644 index 0000000..6f51f35 --- /dev/null +++ b/build-cli.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env bun + +import { $ } from "bun"; +import { mkdirSync, existsSync } from "fs"; + +const targets = [ + { platform: "darwin", arch: "arm64" }, + { platform: "darwin", arch: "x64" }, + { platform: "linux", arch: "x64" }, +]; + +async function build() { + // Ensure dist directory exists + if (!existsSync("dist")) { + mkdirSync("dist"); + } + + console.log("Building pty-skill CLI binaries...\n"); + + for (const { platform, arch } of targets) { + const outName = `pty-skill-${platform}-${arch}`; + const target = `bun-${platform}-${arch}`; + + console.log(`Building ${outName}...`); + + try { + await $`bun build --compile --target=${target} cli/bin/pty-skill.ts --outfile dist/${outName}`.quiet(); + console.log(` ✓ dist/${outName}`); + } catch (e) { + console.error(` ✗ Failed to build ${outName}`); + console.error(` ${e}`); + } + } + + console.log("\nBuild complete!"); +} + +build().catch(console.error); diff --git a/cli/bin/pty-skill.ts b/cli/bin/pty-skill.ts new file mode 100644 index 0000000..d4de9fa --- /dev/null +++ b/cli/bin/pty-skill.ts @@ -0,0 +1,237 @@ +#!/usr/bin/env bun + +import { parseArgs } from "util"; +import { spawnCommand } from "../commands/spawn.ts"; +import { readCommand } from "../commands/read.ts"; +import { writeCommand } from "../commands/write.ts"; +import { listCommand } from "../commands/list.ts"; +import { killCommand } from "../commands/kill.ts"; +import { statusCommand } from "../commands/status.ts"; +import { daemonCommand } from "../commands/daemon.ts"; + +const VERSION = "0.2.0"; + +function showHelp(): void { + console.log(`pty-skill - Interactive PTY management for AI agents + +Usage: pty-skill <command> [options] + +Commands: + spawn <cmd> [args...] Start a new PTY session + read <id> Read output from a PTY session + write <id> <data> Send input to a PTY session + list List all PTY sessions + kill <id> Kill a PTY session + status Check daemon status + daemon <action> Manage the daemon (start|stop|restart|logs) + +Options: + -h, --help Show this help message + -v, --version Show version + +Examples: + pty-skill spawn npm run dev + pty-skill spawn -t "Dev Server" -w /app npm start + pty-skill read pty_abc123 --limit 50 + pty-skill read pty_abc123 --pattern "error" --ignore-case + pty-skill write pty_abc123 "\\x03" # Send Ctrl+C + pty-skill write pty_abc123 "yes\\n" # Send "yes" + Enter + pty-skill list + pty-skill kill pty_abc123 --cleanup +`); +} + +function showVersion(): void { + console.log(`pty-skill v${VERSION}`); +} + +async function main(): Promise<void> { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === "-h" || args[0] === "--help") { + showHelp(); + return; + } + + if (args[0] === "-v" || args[0] === "--version") { + showVersion(); + return; + } + + const command = args[0]; + const restArgs = args.slice(1); + + try { + switch (command) { + case "spawn": + await handleSpawn(restArgs); + break; + case "read": + await handleRead(restArgs); + break; + case "write": + await handleWrite(restArgs); + break; + case "list": + await handleList(restArgs); + break; + case "kill": + await handleKill(restArgs); + break; + case "status": + await handleStatus(restArgs); + break; + case "daemon": + await handleDaemon(restArgs); + break; + default: + console.error(`Unknown command: ${command}`); + console.error("Run 'pty-skill --help' for usage."); + process.exit(1); + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + console.error(`Error: ${message}`); + process.exit(1); + } +} + +async function handleSpawn(args: string[]): Promise<void> { + const { values, positionals } = parseArgs({ + args, + allowPositionals: true, + options: { + workdir: { type: "string", short: "w" }, + env: { type: "string", short: "e", multiple: true }, + title: { type: "string", short: "t" }, + json: { type: "boolean", short: "j" }, + }, + }); + + if (positionals.length === 0) { + console.error("Error: command is required"); + console.error("Usage: pty-skill spawn [options] <command> [args...]"); + process.exit(1); + } + + const [cmd, ...cmdArgs] = positionals; + await spawnCommand(cmd, cmdArgs, { + workdir: values.workdir, + env: values.env, + title: values.title, + json: values.json, + }); +} + +async function handleRead(args: string[]): Promise<void> { + const { values, positionals } = parseArgs({ + args, + allowPositionals: true, + options: { + offset: { type: "string", short: "o" }, + limit: { type: "string", short: "l" }, + pattern: { type: "string", short: "p" }, + "ignore-case": { type: "boolean", short: "i" }, + json: { type: "boolean", short: "j" }, + }, + }); + + if (positionals.length === 0) { + console.error("Error: PTY session ID is required"); + console.error("Usage: pty-skill read [options] <id>"); + process.exit(1); + } + + await readCommand(positionals[0], { + offset: values.offset ? parseInt(values.offset, 10) : undefined, + limit: values.limit ? parseInt(values.limit, 10) : undefined, + pattern: values.pattern, + ignoreCase: values["ignore-case"], + json: values.json, + }); +} + +async function handleWrite(args: string[]): Promise<void> { + const { values, positionals } = parseArgs({ + args, + allowPositionals: true, + options: { + json: { type: "boolean", short: "j" }, + }, + }); + + if (positionals.length < 2) { + console.error("Error: PTY session ID and data are required"); + console.error("Usage: pty-skill write <id> <data>"); + process.exit(1); + } + + await writeCommand(positionals[0], positionals[1], { + json: values.json, + }); +} + +async function handleList(args: string[]): Promise<void> { + const { values } = parseArgs({ + args, + options: { + status: { type: "string", short: "s" }, + json: { type: "boolean", short: "j" }, + }, + }); + + await listCommand({ + status: values.status, + json: values.json, + }); +} + +async function handleKill(args: string[]): Promise<void> { + const { values, positionals } = parseArgs({ + args, + allowPositionals: true, + options: { + cleanup: { type: "boolean", short: "c" }, + json: { type: "boolean", short: "j" }, + }, + }); + + if (positionals.length === 0) { + console.error("Error: PTY session ID is required"); + console.error("Usage: pty-skill kill [options] <id>"); + process.exit(1); + } + + await killCommand(positionals[0], { + cleanup: values.cleanup, + json: values.json, + }); +} + +async function handleStatus(args: string[]): Promise<void> { + const { values } = parseArgs({ + args, + options: { + json: { type: "boolean", short: "j" }, + }, + }); + + await statusCommand({ + json: values.json, + }); +} + +async function handleDaemon(args: string[]): Promise<void> { + if (args.length === 0) { + console.error("Error: daemon action is required"); + console.error("Usage: pty-skill daemon <start|stop|restart|logs>"); + process.exit(1); + } + + await daemonCommand(args[0]); +} + +main().catch((e) => { + console.error("Fatal error:", e); + process.exit(1); +}); diff --git a/cli/client.ts b/cli/client.ts new file mode 100644 index 0000000..c4b730e --- /dev/null +++ b/cli/client.ts @@ -0,0 +1,164 @@ +import { connect, type Socket } from "net"; +import { spawn } from "child_process"; +import { existsSync, readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import type { JsonRpcRequest, JsonRpcResponse } from "./daemon/protocol.ts"; + +const HOME = process.env.HOME || "/tmp"; +const CONFIG_DIR = join(HOME, ".pty-skill"); +const SOCKET_PATH = process.env.PTY_SKILL_SOCKET || join(CONFIG_DIR, "daemon.sock"); +const PID_FILE = join(CONFIG_DIR, "daemon.pid"); + +export class DaemonClient { + private requestId = 0; + + /** + * Check if daemon is running by pinging it + */ + async ping(): Promise<boolean> { + try { + await this.call("ping", {}, 1000); + return true; + } catch { + return false; + } + } + + /** + * Start the daemon if not running + */ + async startDaemon(): Promise<void> { + // Find the daemon server script + const __dirname = dirname(fileURLToPath(import.meta.url)); + const serverPath = join(__dirname, "daemon", "server.ts"); + + const child = spawn("bun", ["run", serverPath], { + detached: true, + stdio: "ignore", + }); + + child.unref(); + + // Wait for daemon to start + for (let i = 0; i < 50; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (await this.ping()) { + return; + } + } + + throw new Error("Failed to start daemon"); + } + + /** + * Stop the daemon + */ + async stopDaemon(): Promise<boolean> { + if (!existsSync(PID_FILE)) { + return false; + } + + try { + const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10); + process.kill(pid, "SIGTERM"); + return true; + } catch { + return false; + } + } + + /** + * Ensure daemon is running, starting it if necessary + */ + async ensureDaemon(): Promise<void> { + if (await this.ping()) { + return; + } + await this.startDaemon(); + } + + /** + * Get daemon status + */ + async status(): Promise<{ running: boolean; pid?: number }> { + const isRunning = await this.ping(); + + if (isRunning && existsSync(PID_FILE)) { + const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10); + return { running: true, pid }; + } + + return { running: false }; + } + + /** + * Call a method on the daemon + */ + async call<T = unknown>( + method: string, + params: Record<string, unknown> = {}, + timeout: number = 30000 + ): Promise<T> { + return new Promise((resolve, reject) => { + const socket = connect(SOCKET_PATH); + let buffer = ""; + let timeoutId: ReturnType<typeof setTimeout> | null = null; + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + socket.destroy(); + }; + + timeoutId = setTimeout(() => { + cleanup(); + reject(new Error("Request timed out")); + }, timeout); + + socket.on("connect", () => { + const request: JsonRpcRequest = { + jsonrpc: "2.0", + method, + params, + id: ++this.requestId, + }; + socket.write(JSON.stringify(request) + "\n"); + }); + + socket.on("data", (data: Buffer) => { + buffer += data.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + + try { + const response = JSON.parse(line) as JsonRpcResponse; + cleanup(); + + if ("error" in response) { + reject(new Error(response.error.message)); + } else { + resolve(response.result as T); + } + } catch (e) { + cleanup(); + reject(new Error("Invalid response from daemon")); + } + } + }); + + socket.on("error", (err: Error) => { + cleanup(); + reject(err); + }); + }); + } +} + +// Export singleton instance +export const client = new DaemonClient(); diff --git a/cli/commands/daemon.ts b/cli/commands/daemon.ts new file mode 100644 index 0000000..c7e5bde --- /dev/null +++ b/cli/commands/daemon.ts @@ -0,0 +1,88 @@ +import { client } from "../client.ts"; +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; + +const HOME = process.env.HOME || "/tmp"; +const CONFIG_DIR = join(HOME, ".pty-skill"); +const LOG_FILE = join(CONFIG_DIR, "daemon.log"); + +export async function daemonCommand(action: string): Promise<void> { + switch (action) { + case "start": + await daemonStart(); + break; + case "stop": + await daemonStop(); + break; + case "restart": + await daemonRestart(); + break; + case "logs": + daemonLogs(); + break; + default: + console.error(`Unknown daemon action: ${action}`); + console.error("Usage: pty-skill daemon <start|stop|restart|logs>"); + process.exit(1); + } +} + +async function daemonStart(): Promise<void> { + const status = await client.status(); + + if (status.running) { + console.log(`Daemon is already running (PID: ${status.pid})`); + return; + } + + console.log("Starting daemon..."); + await client.startDaemon(); + const newStatus = await client.status(); + console.log(`Daemon started (PID: ${newStatus.pid})`); +} + +async function daemonStop(): Promise<void> { + const status = await client.status(); + + if (!status.running) { + console.log("Daemon is not running."); + return; + } + + console.log(`Stopping daemon (PID: ${status.pid})...`); + const stopped = await client.stopDaemon(); + + if (stopped) { + console.log("Daemon stopped."); + } else { + console.log("Failed to stop daemon."); + process.exit(1); + } +} + +async function daemonRestart(): Promise<void> { + await daemonStop(); + // Wait a bit for socket to be released + await new Promise((resolve) => setTimeout(resolve, 500)); + await daemonStart(); +} + +function daemonLogs(): void { + if (!existsSync(LOG_FILE)) { + console.log("No log file found."); + return; + } + + const logs = readFileSync(LOG_FILE, "utf-8"); + const lines = logs.trim().split("\n"); + + // Show last 50 lines + const lastLines = lines.slice(-50); + for (const line of lastLines) { + console.log(line); + } + + if (lines.length > 50) { + console.log(`\n(Showing last 50 of ${lines.length} lines)`); + } +} diff --git a/cli/commands/kill.ts b/cli/commands/kill.ts new file mode 100644 index 0000000..6d1b198 --- /dev/null +++ b/cli/commands/kill.ts @@ -0,0 +1,48 @@ +import { client } from "../client.ts"; +import type { PTYSessionInfo } from "../../src/core/types.ts"; + +interface KillOptions { + cleanup?: boolean; + json?: boolean; +} + +interface KillResult { + success: boolean; + session: PTYSessionInfo | null; +} + +export async function killCommand(id: string, options: KillOptions): Promise<void> { + await client.ensureDaemon(); + + // Get session info before killing + const sessions = await client.call<PTYSessionInfo[]>("list", {}); + const sessionBefore = sessions.find((s) => s.id === id); + + if (!sessionBefore) { + console.error(`PTY session '${id}' not found. Use 'pty-skill list' to see active sessions.`); + process.exit(1); + } + + const wasRunning = sessionBefore.status === "running"; + const cleanup = options.cleanup ?? false; + + const result = await client.call<KillResult>("kill", { + id, + cleanup, + }); + + if (options.json) { + console.log(JSON.stringify({ ...result, sessionBefore }, null, 2)); + return; + } + + const action = wasRunning ? "Killed" : "Cleaned up"; + const cleanupNote = cleanup ? " (session removed)" : " (session retained for log access)"; + + console.log("<pty_killed>"); + console.log(`${action}: ${id}${cleanupNote}`); + console.log(`Title: ${sessionBefore.title}`); + console.log(`Command: ${sessionBefore.command} ${sessionBefore.args.join(" ")}`); + console.log(`Final line count: ${sessionBefore.lineCount}`); + console.log("</pty_killed>"); +} diff --git a/cli/commands/list.ts b/cli/commands/list.ts new file mode 100644 index 0000000..ae1a4f8 --- /dev/null +++ b/cli/commands/list.ts @@ -0,0 +1,43 @@ +import { client } from "../client.ts"; +import type { PTYSessionInfo } from "../../src/core/types.ts"; + +interface ListOptions { + status?: string; + json?: boolean; +} + +export async function listCommand(options: ListOptions): Promise<void> { + await client.ensureDaemon(); + + let sessions = await client.call<PTYSessionInfo[]>("list", {}); + + // Filter by status if specified + if (options.status) { + sessions = sessions.filter((s) => s.status === options.status); + } + + if (options.json) { + console.log(JSON.stringify(sessions, null, 2)); + return; + } + + if (sessions.length === 0) { + console.log("<pty_list>"); + console.log("No active PTY sessions."); + console.log("</pty_list>"); + return; + } + + console.log("<pty_list>"); + for (const session of sessions) { + const exitInfo = session.exitCode !== undefined ? ` (exit: ${session.exitCode})` : ""; + console.log(`[${session.id}] ${session.title}`); + console.log(` Command: ${session.command} ${session.args.join(" ")}`); + console.log(` Status: ${session.status}${exitInfo}`); + console.log(` PID: ${session.pid} | Lines: ${session.lineCount} | Workdir: ${session.workdir}`); + console.log(` Created: ${new Date(session.createdAt).toISOString()}`); + console.log(); + } + console.log(`Total: ${sessions.length} session(s)`); + console.log("</pty_list>"); +} diff --git a/cli/commands/read.ts b/cli/commands/read.ts new file mode 100644 index 0000000..9338c42 --- /dev/null +++ b/cli/commands/read.ts @@ -0,0 +1,105 @@ +import { client } from "../client.ts"; + +interface ReadResult { + lines: string[]; + totalLines: number; + offset: number; + hasMore: boolean; + status: string; +} + +interface SearchResult { + matches: Array<{ lineNumber: number; text: string }>; + totalMatches: number; + totalLines: number; + offset: number; + hasMore: boolean; + status: string; +} + +interface ReadOptions { + offset?: number; + limit?: number; + pattern?: string; + ignoreCase?: boolean; + json?: boolean; +} + +const MAX_LINE_LENGTH = 2000; + +export async function readCommand(id: string, options: ReadOptions): Promise<void> { + await client.ensureDaemon(); + + const result = await client.call<ReadResult | SearchResult>("read", { + id, + offset: options.offset ?? 0, + limit: options.limit ?? 500, + pattern: options.pattern, + ignoreCase: options.ignoreCase, + }); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + // Check if it's a search result (has matches) or read result (has lines) + if ("matches" in result) { + // Search result + const searchResult = result as SearchResult; + + if (searchResult.matches.length === 0) { + console.log(`<pty_output id="${id}" status="${searchResult.status}" pattern="${options.pattern}">`); + console.log(`No lines matched the pattern '${options.pattern}'.`); + console.log(`Total lines in buffer: ${searchResult.totalLines}`); + console.log(`</pty_output>`); + return; + } + + console.log(`<pty_output id="${id}" status="${searchResult.status}" pattern="${options.pattern}">`); + for (const match of searchResult.matches) { + const lineNum = match.lineNumber.toString().padStart(5, "0"); + const text = match.text.length > MAX_LINE_LENGTH + ? match.text.slice(0, MAX_LINE_LENGTH) + "..." + : match.text; + console.log(`${lineNum}| ${text}`); + } + console.log(); + if (searchResult.hasMore) { + console.log(`(${searchResult.matches.length} of ${searchResult.totalMatches} matches shown. Use --offset=${(options.offset ?? 0) + searchResult.matches.length} to see more.)`); + } else { + console.log(`(${searchResult.totalMatches} match${searchResult.totalMatches === 1 ? "" : "es"} from ${searchResult.totalLines} total lines)`); + } + console.log(`</pty_output>`); + } else { + // Read result + const readResult = result as ReadResult; + + if (readResult.lines.length === 0) { + console.log(`<pty_output id="${id}" status="${readResult.status}">`); + console.log(`(No output available - buffer is empty)`); + console.log(`Total lines: ${readResult.totalLines}`); + console.log(`</pty_output>`); + return; + } + + console.log(`<pty_output id="${id}" status="${readResult.status}">`); + for (let i = 0; i < readResult.lines.length; i++) { + const lineNum = (readResult.offset + i + 1).toString().padStart(5, "0"); + const line = readResult.lines[i]; + const text = line.length > MAX_LINE_LENGTH + ? line.slice(0, MAX_LINE_LENGTH) + "..." + : line; + console.log(`${lineNum}| ${text}`); + } + + if (readResult.hasMore) { + console.log(); + console.log(`(Buffer has more lines. Use --offset=${readResult.offset + readResult.lines.length} to read beyond line ${readResult.offset + readResult.lines.length})`); + } else { + console.log(); + console.log(`(End of buffer - total ${readResult.totalLines} lines)`); + } + console.log(`</pty_output>`); + } +} diff --git a/cli/commands/spawn.ts b/cli/commands/spawn.ts new file mode 100644 index 0000000..2536233 --- /dev/null +++ b/cli/commands/spawn.ts @@ -0,0 +1,46 @@ +import { client } from "../client.ts"; +import type { PTYSessionInfo } from "../../src/core/types.ts"; + +interface SpawnOptions { + workdir?: string; + env?: string[]; + title?: string; + json?: boolean; +} + +export async function spawnCommand( + command: string, + args: string[], + options: SpawnOptions +): Promise<void> { + await client.ensureDaemon(); + + // Parse env from array of "KEY=VALUE" strings + const env: Record<string, string> = {}; + if (options.env) { + for (const e of options.env) { + const idx = e.indexOf("="); + if (idx > 0) { + env[e.slice(0, idx)] = e.slice(idx + 1); + } + } + } + + const result = await client.call<PTYSessionInfo>("spawn", { + command, + args, + workdir: options.workdir || process.cwd(), + env: Object.keys(env).length > 0 ? env : undefined, + title: options.title, + }); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`Spawned PTY session: ${result.id}`); + console.log(` Title: ${result.title}`); + console.log(` Command: ${result.command} ${result.args.join(" ")}`); + console.log(` PID: ${result.pid}`); + console.log(` Workdir: ${result.workdir}`); + } +} diff --git a/cli/commands/status.ts b/cli/commands/status.ts new file mode 100644 index 0000000..aa49897 --- /dev/null +++ b/cli/commands/status.ts @@ -0,0 +1,44 @@ +import { client } from "../client.ts"; + +interface StatusOptions { + json?: boolean; +} + +interface DaemonStatus { + running: boolean; + sessions: number; + uptime: number; +} + +export async function statusCommand(options: StatusOptions): Promise<void> { + const daemonStatus = await client.status(); + + if (!daemonStatus.running) { + if (options.json) { + console.log(JSON.stringify({ running: false }, null, 2)); + } else { + console.log("Daemon is not running."); + } + return; + } + + try { + const status = await client.call<DaemonStatus>("status", {}); + + if (options.json) { + console.log(JSON.stringify({ ...status, pid: daemonStatus.pid }, null, 2)); + } else { + console.log("Daemon status:"); + console.log(` Running: yes`); + console.log(` PID: ${daemonStatus.pid}`); + console.log(` Sessions: ${status.sessions}`); + console.log(` Uptime: ${Math.floor(status.uptime)}s`); + } + } catch (e) { + if (options.json) { + console.log(JSON.stringify({ running: false, error: String(e) }, null, 2)); + } else { + console.log("Daemon is not responding."); + } + } +} diff --git a/cli/commands/write.ts b/cli/commands/write.ts new file mode 100644 index 0000000..21f7f71 --- /dev/null +++ b/cli/commands/write.ts @@ -0,0 +1,35 @@ +import { client } from "../client.ts"; + +interface WriteOptions { + json?: boolean; +} + +interface WriteResult { + success: boolean; + bytes: number; +} + +export async function writeCommand( + id: string, + data: string, + options: WriteOptions +): Promise<void> { + await client.ensureDaemon(); + + const result = await client.call<WriteResult>("write", { + id, + data, + }); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + const preview = data.length > 50 ? data.slice(0, 50) + "..." : data; + const displayPreview = preview + .replace(/\x03/g, "^C") + .replace(/\x04/g, "^D") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r"); + console.log(`Sent ${result.bytes} bytes to ${id}: "${displayPreview}"`); + } +} diff --git a/cli/daemon/handlers.ts b/cli/daemon/handlers.ts new file mode 100644 index 0000000..5d18760 --- /dev/null +++ b/cli/daemon/handlers.ts @@ -0,0 +1,186 @@ +import { manager } from "../../src/core/manager.ts"; +import type { PTYSessionInfo, ReadResult, SearchResult } from "../../src/core/types.ts"; +import { + type JsonRpcRequest, + type JsonRpcResponse, + type SpawnParams, + type WriteParams, + type ReadParams, + type KillParams, + success, + error, + ErrorCodes, +} from "./protocol.ts"; + +/** + * Parse escape sequences in a string to their actual byte values. + * Handles: \n, \r, \t, \xNN (hex), \uNNNN (unicode), \\ + */ +function parseEscapeSequences(input: string): string { + return input.replace(/\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|[nrt\\])/g, (match, seq: string) => { + if (seq.startsWith("x")) { + return String.fromCharCode(parseInt(seq.slice(1), 16)); + } + if (seq.startsWith("u")) { + return String.fromCharCode(parseInt(seq.slice(1), 16)); + } + switch (seq) { + case "n": return "\n"; + case "r": return "\r"; + case "t": return "\t"; + case "\\": return "\\"; + default: return match; + } + }); +} + +function handleSpawn(params: SpawnParams): PTYSessionInfo { + if (!params.command) { + throw new Error("command is required"); + } + return manager.spawn({ + command: params.command, + args: params.args, + workdir: params.workdir, + env: params.env, + title: params.title, + }); +} + +function handleWrite(params: WriteParams): { success: boolean; bytes: number } { + if (!params.id) { + throw new Error("id is required"); + } + if (params.data === undefined) { + throw new Error("data is required"); + } + + const parsedData = parseEscapeSequences(params.data); + const success = manager.write(params.id, parsedData); + + if (!success) { + const session = manager.get(params.id); + if (!session) { + throw new Error(`PTY session '${params.id}' not found`); + } + throw new Error(`Cannot write to PTY '${params.id}' - session status is '${session.status}'`); + } + + return { success: true, bytes: parsedData.length }; +} + +function handleRead(params: ReadParams): ReadResult | SearchResult { + if (!params.id) { + throw new Error("id is required"); + } + + const session = manager.get(params.id); + if (!session) { + throw new Error(`PTY session '${params.id}' not found`); + } + + const offset = params.offset ?? 0; + const limit = params.limit ?? 500; + + if (params.pattern) { + let regex: RegExp; + try { + regex = new RegExp(params.pattern, params.ignoreCase ? "i" : ""); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`Invalid regex pattern '${params.pattern}': ${msg}`); + } + + const result = manager.search(params.id, regex, offset, limit); + if (!result) { + throw new Error(`PTY session '${params.id}' not found`); + } + return { ...result, status: session.status }; + } + + const result = manager.read(params.id, offset, limit); + if (!result) { + throw new Error(`PTY session '${params.id}' not found`); + } + return { ...result, status: session.status }; +} + +function handleList(): PTYSessionInfo[] { + return manager.list(); +} + +function handleKill(params: KillParams): { success: boolean; session: PTYSessionInfo | null } { + if (!params.id) { + throw new Error("id is required"); + } + + const session = manager.get(params.id); + if (!session) { + throw new Error(`PTY session '${params.id}' not found`); + } + + const cleanup = params.cleanup ?? false; + const result = manager.kill(params.id, cleanup); + + return { + success: result, + session: cleanup ? null : manager.get(params.id) + }; +} + +function handleStatus(): { running: boolean; sessions: number; uptime: number } { + const sessions = manager.list(); + return { + running: true, + sessions: sessions.length, + uptime: process.uptime(), + }; +} + +function handlePing(): { pong: true } { + return { pong: true }; +} + +export function handleRequest(request: JsonRpcRequest): JsonRpcResponse { + const { method, params, id } = request; + + try { + let result: unknown; + + switch (method) { + case "spawn": + result = handleSpawn(params as SpawnParams); + break; + case "write": + result = handleWrite(params as WriteParams); + break; + case "read": + result = handleRead(params as ReadParams); + break; + case "list": + result = handleList(); + break; + case "kill": + result = handleKill(params as KillParams); + break; + case "status": + result = handleStatus(); + break; + case "ping": + result = handlePing(); + break; + default: + return error(id, ErrorCodes.METHOD_NOT_FOUND, `Method '${method}' not found`); + } + + return success(id, result); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + + if (message.includes("not found")) { + return error(id, ErrorCodes.SESSION_NOT_FOUND, message); + } + + return error(id, ErrorCodes.INTERNAL_ERROR, message); + } +} diff --git a/cli/daemon/protocol.ts b/cli/daemon/protocol.ts new file mode 100644 index 0000000..1200894 --- /dev/null +++ b/cli/daemon/protocol.ts @@ -0,0 +1,81 @@ +// JSON-RPC 2.0 Protocol Types + +export interface JsonRpcRequest { + jsonrpc: "2.0"; + method: string; + params?: Record<string, unknown>; + id: number | string; +} + +export interface JsonRpcSuccessResponse { + jsonrpc: "2.0"; + result: unknown; + id: number | string; +} + +export interface JsonRpcErrorResponse { + jsonrpc: "2.0"; + error: { + code: number; + message: string; + data?: unknown; + }; + id: number | string | null; +} + +export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse; + +// Standard JSON-RPC error codes +export const ErrorCodes = { + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, + // Custom error codes (server-defined) + SESSION_NOT_FOUND: -32000, + SPAWN_FAILED: -32001, + WRITE_FAILED: -32002, +} as const; + +// RPC Method Parameters +export interface SpawnParams { + command: string; + args?: string[]; + workdir?: string; + env?: Record<string, string>; + title?: string; +} + +export interface WriteParams { + id: string; + data: string; +} + +export interface ReadParams { + id: string; + offset?: number; + limit?: number; + pattern?: string; + ignoreCase?: boolean; +} + +export interface KillParams { + id: string; + cleanup?: boolean; +} + +// Helper to create success response +export function success(id: number | string, result: unknown): JsonRpcSuccessResponse { + return { jsonrpc: "2.0", result, id }; +} + +// Helper to create error response +export function error( + id: number | string | null, + code: number, + message: string, + data?: unknown +): JsonRpcErrorResponse { + return { jsonrpc: "2.0", error: { code, message, data }, id }; +} diff --git a/cli/daemon/server.ts b/cli/daemon/server.ts new file mode 100644 index 0000000..1f7e0dc --- /dev/null +++ b/cli/daemon/server.ts @@ -0,0 +1,178 @@ +#!/usr/bin/env bun + +import { createServer, type Socket } from "net"; +import { existsSync, mkdirSync, unlinkSync, writeFileSync, readFileSync } from "fs"; +import { dirname, join } from "path"; +import { manager } from "../../src/core/manager.ts"; +import { handleRequest } from "./handlers.ts"; +import { error, ErrorCodes, type JsonRpcRequest, type JsonRpcResponse } from "./protocol.ts"; + +const HOME = process.env.HOME || "/tmp"; +const CONFIG_DIR = join(HOME, ".pty-skill"); +const SOCKET_PATH = process.env.PTY_SKILL_SOCKET || join(CONFIG_DIR, "daemon.sock"); +const PID_FILE = join(CONFIG_DIR, "daemon.pid"); +const LOG_FILE = join(CONFIG_DIR, "daemon.log"); + +function log(message: string, ...args: unknown[]): void { + const timestamp = new Date().toISOString(); + const line = `[${timestamp}] ${message}`; + console.log(line, ...args); + + // Also append to log file + try { + const logLine = args.length > 0 + ? `${line} ${JSON.stringify(args)}\n` + : `${line}\n`; + Bun.write(Bun.file(LOG_FILE), { append: true }).then(() => {}); + // Use sync write for simplicity + const fs = require("fs"); + fs.appendFileSync(LOG_FILE, logLine); + } catch {} +} + +function ensureConfigDir(): void { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } +} + +function writePidFile(): void { + writeFileSync(PID_FILE, String(process.pid)); +} + +function removePidFile(): void { + try { + if (existsSync(PID_FILE)) { + unlinkSync(PID_FILE); + } + } catch {} +} + +function removeSocket(): void { + try { + if (existsSync(SOCKET_PATH)) { + unlinkSync(SOCKET_PATH); + } + } catch {} +} + +function cleanup(): void { + log("Shutting down daemon..."); + manager.cleanupAll(); + removeSocket(); + removePidFile(); + log("Daemon stopped."); +} + +function handleConnection(socket: Socket): void { + let buffer = ""; + + socket.on("data", (data: Buffer) => { + buffer += data.toString(); + + // Try to parse complete JSON messages (newline-delimited) + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; // Keep incomplete line in buffer + + for (const line of lines) { + if (!line.trim()) continue; + + let response: JsonRpcResponse; + + try { + const request = JSON.parse(line) as JsonRpcRequest; + + if (request.jsonrpc !== "2.0" || !request.method) { + response = error( + request.id ?? null, + ErrorCodes.INVALID_REQUEST, + "Invalid JSON-RPC request" + ); + } else { + response = handleRequest(request); + } + } catch (e) { + response = error(null, ErrorCodes.PARSE_ERROR, "Parse error"); + } + + socket.write(JSON.stringify(response) + "\n"); + } + }); + + socket.on("error", (err: Error) => { + log("Socket error:", err.message); + }); + + socket.on("close", () => { + // Connection closed, nothing to clean up per-connection + }); +} + +function startServer(): void { + ensureConfigDir(); + + // Remove stale socket if exists + removeSocket(); + + const server = createServer(handleConnection); + + server.on("error", (err: Error) => { + log("Server error:", err.message); + cleanup(); + process.exit(1); + }); + + server.listen(SOCKET_PATH, () => { + writePidFile(); + log(`Daemon listening on ${SOCKET_PATH}`); + log(`PID: ${process.pid}`); + }); + + // Handle graceful shutdown + process.on("SIGTERM", () => { + log("Received SIGTERM"); + server.close(); + cleanup(); + process.exit(0); + }); + + process.on("SIGINT", () => { + log("Received SIGINT"); + server.close(); + cleanup(); + process.exit(0); + }); + + process.on("uncaughtException", (err: Error) => { + log("Uncaught exception:", err.message); + server.close(); + cleanup(); + process.exit(1); + }); +} + +// Check if already running +function isAlreadyRunning(): boolean { + if (!existsSync(PID_FILE)) { + return false; + } + + try { + const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10); + // Check if process is running + process.kill(pid, 0); + return true; + } catch { + // Process not running, remove stale PID file + removePidFile(); + return false; + } +} + +// Main +if (isAlreadyRunning()) { + console.error("Daemon is already running"); + process.exit(1); +} + +startServer(); diff --git a/examples/claude-code-conversation.md b/examples/claude-code-conversation.md new file mode 100644 index 0000000..af4a97d --- /dev/null +++ b/examples/claude-code-conversation.md @@ -0,0 +1,418 @@ +# Claude Conversation Log + +Session ID: cc2f8773-9250-4273-9de3-7d0d9c76e85c +Date: 2025-12-30 19:24:39 + +--- + +## 👤 User + +Use the pty skill to test if nvim works correctly, try a few moves on a new file, type something and save it to /tmp + +--- + +## 👤 User + +Base directory for this skill: /Users/fcoury/.claude/skills/pty-skill + +# pty-skill + +Manage interactive pseudo-terminal sessions for background processes, dev servers, and interactive programs. + +## Dependencies + +The `pty-skill` CLI must be installed. If not available, install it: + +```bash +# Install globally via npm +npm install -g pty-skill + +# Or run directly with npx (no install needed) +npx pty-skill --help +``` + +The CLI runs a background daemon that maintains PTY sessions across invocations. The daemon auto-starts on first use. + +## When to Use + +Use this skill when you need to: + +- Start long-running processes (dev servers, watch modes, database servers) +- Send interactive input to running processes (Ctrl+C, commands, responses) +- Monitor output from background processes +- Manage multiple terminal sessions simultaneously + +## Quick Reference + +```bash +# Start a dev server +pty-skill spawn npm run dev + +# Check server output (last 100 lines) +pty-skill read pty_abc123 --limit 100 + +# Look for errors +pty-skill read pty_abc123 --pattern "error|ERROR" --ignore-case + +# Send Ctrl+C to stop +pty-skill write pty_abc123 "\x03" + +# List all sessions +pty-skill list + +# Kill and remove a session +pty-skill kill pty_abc123 --cleanup +``` + +## Commands + +### spawn - Start a PTY session + +```bash +pty-skill spawn [options] <command> [args...] + +Options: + -w, --workdir <dir> Working directory (default: current) + -e, --env <KEY=VALUE> Environment variable (can repeat) + -t, --title <title> Human-readable title + -j, --json Output as JSON +``` + +**Examples:** + +```bash +# Start Next.js dev server +pty-skill spawn -t "Next.js Dev" npm run dev + +# Start with custom port +pty-skill spawn -e PORT=4000 npm start + +# Start Python REPL +pty-skill spawn python3 + +# Start in specific directory +pty-skill spawn -w /app -t "API Server" cargo run +``` + +### read - Read PTY output + +```bash +pty-skill read [options] <id> + +Options: + -o, --offset <n> Start from line n (default: 0) + -l, --limit <n> Max lines to read (default: 500) + -p, --pattern <regex> Filter lines by regex pattern + -i, --ignore-case Case-insensitive pattern matching + -j, --json Output as JSON +``` + +**Examples:** + +```bash +# Read last 50 lines +pty-skill read pty_abc123 --limit 50 + +# Find compilation errors +pty-skill read pty_abc123 --pattern "error|failed" --ignore-case + +# Read from specific offset +pty-skill read pty_abc123 --offset 100 --limit 50 + +# Get output as JSON for parsing +pty-skill read pty_abc123 --json +``` + +### write - Send input to PTY + +```bash +pty-skill write <id> <data> + +Options: + -j, --json Output as JSON +``` + +**Escape Sequences:** +| Sequence | Meaning | +|----------|---------| +| `\n` | Enter/newline | +| `\r` | Carriage return | +| `\t` | Tab | +| `\x03` | Ctrl+C (interrupt) | +| `\x04` | Ctrl+D (EOF) | +| `\x1a` | Ctrl+Z (suspend) | +| `\x1b[A` | Arrow Up | +| `\x1b[B` | Arrow Down | +| `\x1b[C` | Arrow Right | +| `\x1b[D` | Arrow Left | +| `\\` | Literal backslash | + +**Examples:** + +```bash +# Send a command +pty-skill write pty_abc123 "ls -la\n" + +# Interrupt process (Ctrl+C) +pty-skill write pty_abc123 "\x03" + +# Answer a prompt +pty-skill write pty_abc123 "yes\n" + +# Send EOF (Ctrl+D) +pty-skill write pty_abc123 "\x04" + +# Navigate with arrow keys +pty-skill write pty_abc123 "\x1b[A" # Up arrow +``` + +### list - List PTY sessions + +```bash +pty-skill list [options] + +Options: + -s, --status <status> Filter: running|exited|killed + -j, --json Output as JSON +``` + +**Examples:** + +```bash +# List all sessions +pty-skill list + +# List only running sessions +pty-skill list --status running + +# Get as JSON for parsing +pty-skill list --json +``` + +### kill - Terminate a PTY + +```bash +pty-skill kill [options] <id> + +Options: + -c, --cleanup Remove session and free buffer + -j, --json Output as JSON +``` + +**Examples:** + +```bash +# Kill but keep output accessible +pty-skill kill pty_abc123 + +# Kill and remove completely +pty-skill kill pty_abc123 --cleanup +``` + +### status - Check daemon status + +```bash +pty-skill status [options] + +Options: + -j, --json Output as JSON +``` + +### daemon - Manage the daemon + +```bash +pty-skill daemon <action> + +Actions: + start Start the daemon + stop Stop the daemon + restart Restart the daemon + logs Show daemon logs +``` + +## Workflow Patterns + +### Start and Monitor Dev Server + +```bash +# 1. Start the server +pty-skill spawn -t "Dev Server" npm run dev +# Returns: pty_abc123 + +# 2. Wait briefly for startup +sleep 2 + +# 3. Check for startup success +pty-skill read pty_abc123 --pattern "ready|listening" --limit 10 + +# 4. If errors, check logs +pty-skill read pty_abc123 --pattern "error" --ignore-case +``` + +### Interactive REPL Session + +```bash +# 1. Start Python REPL +pty-skill spawn -t "Python" python3 + +# 2. Send commands +pty-skill write pty_abc123 "import sys\n" +pty-skill write pty_abc123 "print(sys.version)\n" + +# 3. Read output +pty-skill read pty_abc123 --limit 20 + +# 4. Exit REPL +pty-skill write pty_abc123 "exit()\n" +``` + +### Graceful Shutdown + +```bash +# 1. Send interrupt signal +pty-skill write pty_abc123 "\x03" + +# 2. Wait for graceful shutdown +sleep 2 + +# 3. Check if exited +pty-skill list --json + +# 4. If still running, force kill +pty-skill kill pty_abc123 --cleanup +``` + +### Run Tests with Live Output + +```bash +# 1. Start test runner +pty-skill spawn -t "Tests" npm test + +# 2. Periodically check for failures +pty-skill read pty_abc123 --pattern "FAIL|Error" --ignore-case + +# 3. When done, check final status +pty-skill list --json +``` + +### Database Interactive Session + +```bash +# 1. Start psql +pty-skill spawn -t "PostgreSQL" psql mydb + +# 2. Run queries +pty-skill write pty_abc123 "SELECT * FROM users LIMIT 5;\n" + +# 3. Read results +pty-skill read pty_abc123 --limit 50 + +# 4. Exit +pty-skill write pty_abc123 "\\q\n" +``` + +## Troubleshooting + +### Daemon not responding + +```bash +pty-skill daemon restart +``` + +### Session ID not found + +```bash +# List all sessions to find correct ID +pty-skill list +``` + +### No output appearing + +```bash +# Buffer might be empty - wait for process output +sleep 1 +pty-skill read pty_abc123 + +# Or check if process exited +pty-skill list --json +``` + +### Process not responding to input + +```bash +# Check if process is still running +pty-skill list --status running + +# Try sending Ctrl+C first +pty-skill write pty_abc123 "\x03" +``` + +## Configuration + +**Environment Variables:** + +- `PTY_MAX_BUFFER_LINES`: Max lines per session buffer (default: 50,000) +- `PTY_SKILL_SOCKET`: Custom socket path (default: `~/.pty-skill/daemon.sock`) + +## Notes + +- Sessions persist until explicitly killed or daemon stops +- Output buffer stores up to 50,000 lines per session +- The daemon auto-starts on first CLI invocation +- All PTY operations are local (no network required) +- Exited processes remain in list for log inspection until cleanup + +ARGUMENTS: test nvim - open a new file, try some movements, type some text, and save it to /tmp/nvim-test.txt + +--- + +## 🤖 Claude + +I'll test nvim using pty-skill. Let me spawn a session and interact with it. + +--- + +## 🤖 Claude + +Nvim is running. Let me enter insert mode and type some text: + +--- + +## 🤖 Claude + +Now let me try some vim movements - go up, down, and to start of line: + +--- + +## 🤖 Claude + +Now let me save the file with `:wq`: + +--- + +## 🤖 Claude + +Nvim is working correctly. Let me clean up the session: + +--- + +## 🤖 Claude + +**nvim test completed successfully!** + +| Action | Result | +| ---------------------------------------- | ----------------------------------- | +| Spawn nvim | ✓ Started with `/tmp/nvim-test.txt` | +| Insert mode (`i`) | ✓ Entered insert mode | +| Type text | ✓ Added 3 lines of text | +| Escape | ✓ Returned to normal mode | +| Movements (`k`, `j`, `0`, `$`, `w`, `b`) | ✓ All navigation worked | +| Save & quit (`:wq`) | ✓ Exited with code 0 | + +**Saved file** (`/tmp/nvim-test.txt`): + +``` +Hello from nvim! +This is a test file. +Testing pty-skill with neovim. +``` diff --git a/package.json b/package.json index ade0f78..16eea3b 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,10 @@ { - "name": "opencode-pty", + "name": "pty-skill", "module": "index.ts", - "version": "0.1.1", - "description": "OpenCode plugin for interactive PTY management - run background processes, send input, read output with regex filtering", + "version": "0.2.0", + "description": "Interactive PTY management skill for AI agents - run background processes, send input, read output with regex filtering. Works with Claude Code, Codex CLI, and other AI tools.", "author": "shekohex", "keywords": [ - "opencode", - "opencode-plugin", "pty", "pseudo-terminal", "terminal", @@ -15,7 +13,13 @@ "cli", "shell", "dev-server", - "bun" + "bun", + "ai-agent", + "claude-code", + "codex-cli", + "skill", + "opencode", + "opencode-plugin" ], "repository": { "type": "git", @@ -25,15 +29,25 @@ "url": "https://github.com/shekohex/opencode-pty/issues" }, "homepage": "https://github.com/shekohex/opencode-pty#readme", + "bin": { + "pty-skill": "./cli/bin/pty-skill.ts" + }, "files": [ "index.ts", - "src" + "src", + "cli", + "SKILL.md", + "dist" ], "license": "MIT", "type": "module", "scripts": { "typecheck": "tsc --noEmit", - "test": "bun test" + "test": "bun test", + "build": "bun run build-cli.ts", + "build:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 cli/bin/pty-skill.ts --outfile dist/pty-skill-darwin-arm64", + "build:darwin-x64": "bun build --compile --target=bun-darwin-x64 cli/bin/pty-skill.ts --outfile dist/pty-skill-darwin-x64", + "build:linux-x64": "bun build --compile --target=bun-linux-x64 cli/bin/pty-skill.ts --outfile dist/pty-skill-linux-x64" }, "devDependencies": { "@opencode-ai/plugin": "^1.0.146", diff --git a/src/core/buffer.ts b/src/core/buffer.ts new file mode 100644 index 0000000..7577e19 --- /dev/null +++ b/src/core/buffer.ts @@ -0,0 +1,50 @@ +const DEFAULT_MAX_LINES = parseInt(process.env.PTY_MAX_BUFFER_LINES || "50000", 10); + +export interface SearchMatch { + lineNumber: number; + text: string; +} + +export class RingBuffer { + private lines: string[] = []; + private maxLines: number; + + constructor(maxLines: number = DEFAULT_MAX_LINES) { + this.maxLines = maxLines; + } + + append(data: string): void { + const newLines = data.split("\n"); + for (const line of newLines) { + this.lines.push(line); + if (this.lines.length > this.maxLines) { + this.lines.shift(); + } + } + } + + read(offset: number = 0, limit?: number): string[] { + const start = Math.max(0, offset); + const end = limit !== undefined ? start + limit : this.lines.length; + return this.lines.slice(start, end); + } + + search(pattern: RegExp): SearchMatch[] { + const matches: SearchMatch[] = []; + for (let i = 0; i < this.lines.length; i++) { + const line = this.lines[i]; + if (line !== undefined && pattern.test(line)) { + matches.push({ lineNumber: i + 1, text: line }); + } + } + return matches; + } + + get length(): number { + return this.lines.length; + } + + clear(): void { + this.lines = []; + } +} diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..70af06f --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,12 @@ +// Core PTY management - shared between plugin and CLI +export { RingBuffer, type SearchMatch } from "./buffer.ts"; +export { PTYManager, manager } from "./manager.ts"; +export { createLogger, setLogHandler, type Logger, type LogHandler } from "./logger.ts"; +export type { + PTYStatus, + PTYSession, + PTYSessionInfo, + SpawnOptions, + ReadResult, + SearchResult, +} from "./types.ts"; diff --git a/src/core/logger.ts b/src/core/logger.ts new file mode 100644 index 0000000..446c331 --- /dev/null +++ b/src/core/logger.ts @@ -0,0 +1,61 @@ +type LogLevel = "debug" | "info" | "warn" | "error"; + +export interface Logger { + debug(message: string, extra?: Record<string, unknown>): void; + info(message: string, extra?: Record<string, unknown>): void; + warn(message: string, extra?: Record<string, unknown>): void; + error(message: string, extra?: Record<string, unknown>): void; +} + +export type LogHandler = ( + level: LogLevel, + service: string, + message: string, + extra?: Record<string, unknown> +) => void; + +let _customHandler: LogHandler | null = null; + +/** + * Set a custom log handler (e.g., for plugin client logging) + */ +export function setLogHandler(handler: LogHandler | null): void { + _customHandler = handler; +} + +/** + * Create a logger for a specific module + */ +export function createLogger(module: string): Logger { + const service = `pty.${module}`; + + const log = (level: LogLevel, message: string, extra?: Record<string, unknown>): void => { + if (_customHandler) { + _customHandler(level, service, message, extra); + } else { + const prefix = `[${service}]`; + const args = extra ? [prefix, message, extra] : [prefix, message]; + switch (level) { + case "debug": + console.debug(...args); + break; + case "info": + console.info(...args); + break; + case "warn": + console.warn(...args); + break; + case "error": + console.error(...args); + break; + } + } + }; + + return { + debug: (message, extra) => log("debug", message, extra), + info: (message, extra) => log("info", message, extra), + warn: (message, extra) => log("warn", message, extra), + error: (message, extra) => log("error", message, extra), + }; +} diff --git a/src/core/manager.ts b/src/core/manager.ts new file mode 100644 index 0000000..0f9fd0e --- /dev/null +++ b/src/core/manager.ts @@ -0,0 +1,170 @@ +import { spawn, type IPty } from "bun-pty"; +import { RingBuffer } from "./buffer.ts"; +import type { PTYSession, PTYSessionInfo, SpawnOptions, ReadResult, SearchResult } from "./types.ts"; +import { createLogger } from "./logger.ts"; + +const log = createLogger("manager"); + +function generateId(): string { + const hex = Array.from(crypto.getRandomValues(new Uint8Array(4))) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return `pty_${hex}`; +} + +export class PTYManager { + private sessions: Map<string, PTYSession> = new Map(); + + spawn(opts: SpawnOptions): PTYSessionInfo { + const id = generateId(); + const args = opts.args ?? []; + const workdir = opts.workdir ?? process.cwd(); + const env = { ...process.env, ...opts.env } as Record<string, string>; + const title = opts.title ?? (`${opts.command} ${args.join(" ")}`.trim() || `Terminal ${id.slice(-4)}`); + + log.info("spawning pty", { id, command: opts.command, args, workdir }); + + const ptyProcess: IPty = spawn(opts.command, args, { + name: "xterm-256color", + cols: 120, + rows: 40, + cwd: workdir, + env, + }); + + const buffer = new RingBuffer(); + const session: PTYSession = { + id, + title, + command: opts.command, + args, + workdir, + env: opts.env, + status: "running", + pid: ptyProcess.pid, + createdAt: new Date(), + parentSessionId: opts.parentSessionId, + buffer, + process: ptyProcess, + }; + + this.sessions.set(id, session); + + ptyProcess.onData((data: string) => { + buffer.append(data); + }); + + ptyProcess.onExit(({ exitCode }: { exitCode: number }) => { + log.info("pty exited", { id, exitCode }); + if (session.status === "running") { + session.status = "exited"; + session.exitCode = exitCode; + } + }); + + return this.toInfo(session); + } + + write(id: string, data: string): boolean { + const session = this.sessions.get(id); + if (!session) { + return false; + } + if (session.status !== "running") { + return false; + } + session.process.write(data); + return true; + } + + read(id: string, offset: number = 0, limit?: number): ReadResult | null { + const session = this.sessions.get(id); + if (!session) { + return null; + } + const lines = session.buffer.read(offset, limit); + const totalLines = session.buffer.length; + const hasMore = offset + lines.length < totalLines; + return { lines, totalLines, offset, hasMore }; + } + + search(id: string, pattern: RegExp, offset: number = 0, limit?: number): SearchResult | null { + const session = this.sessions.get(id); + if (!session) { + return null; + } + const allMatches = session.buffer.search(pattern); + const totalMatches = allMatches.length; + const totalLines = session.buffer.length; + const paginatedMatches = limit !== undefined + ? allMatches.slice(offset, offset + limit) + : allMatches.slice(offset); + const hasMore = offset + paginatedMatches.length < totalMatches; + return { matches: paginatedMatches, totalMatches, totalLines, offset, hasMore }; + } + + list(): PTYSessionInfo[] { + return Array.from(this.sessions.values()).map((s) => this.toInfo(s)); + } + + get(id: string): PTYSessionInfo | null { + const session = this.sessions.get(id); + return session ? this.toInfo(session) : null; + } + + kill(id: string, cleanup: boolean = false): boolean { + const session = this.sessions.get(id); + if (!session) { + return false; + } + + log.info("killing pty", { id, cleanup }); + + if (session.status === "running") { + try { + session.process.kill(); + } catch {} + session.status = "killed"; + } + + if (cleanup) { + session.buffer.clear(); + this.sessions.delete(id); + } + + return true; + } + + cleanupBySession(parentSessionId: string): void { + log.info("cleaning up ptys for session", { parentSessionId }); + for (const [id, session] of this.sessions) { + if (session.parentSessionId === parentSessionId) { + this.kill(id, true); + } + } + } + + cleanupAll(): void { + log.info("cleaning up all ptys"); + for (const id of this.sessions.keys()) { + this.kill(id, true); + } + } + + private toInfo(session: PTYSession): PTYSessionInfo { + return { + id: session.id, + title: session.title, + command: session.command, + args: session.args, + workdir: session.workdir, + status: session.status, + exitCode: session.exitCode, + pid: session.pid, + createdAt: session.createdAt, + lineCount: session.buffer.length, + }; + } +} + +export const manager = new PTYManager(); diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..e6319bf --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,57 @@ +import type { IPty } from "bun-pty"; +import type { RingBuffer } from "./buffer.ts"; + +export type PTYStatus = "running" | "idle" | "exited" | "killed"; + +export interface PTYSession { + id: string; + title: string; + command: string; + args: string[]; + workdir: string; + env?: Record<string, string>; + status: PTYStatus; + exitCode?: number; + pid: number; + createdAt: Date; + parentSessionId?: string; // Optional for CLI usage + buffer: RingBuffer; + process: IPty; +} + +export interface PTYSessionInfo { + id: string; + title: string; + command: string; + args: string[]; + workdir: string; + status: PTYStatus; + exitCode?: number; + pid: number; + createdAt: Date; + lineCount: number; +} + +export interface SpawnOptions { + command: string; + args?: string[]; + workdir?: string; + env?: Record<string, string>; + title?: string; + parentSessionId?: string; // Optional for CLI usage +} + +export interface ReadResult { + lines: string[]; + totalLines: number; + offset: number; + hasMore: boolean; +} + +export interface SearchResult { + matches: Array<{ lineNumber: number; text: string }>; + totalMatches: number; + totalLines: number; + offset: number; + hasMore: boolean; +} diff --git a/src/plugin.ts b/src/plugin.ts index a5877fe..ed1a3f5 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,6 +1,6 @@ import { createLogger, initLogger } from "./plugin/logger.ts"; import type { PluginContext, PluginResult } from "./plugin/types.ts"; -import { manager } from "./plugin/pty/manager.ts"; +import { manager } from "./core/manager.ts"; import { initPermissions } from "./plugin/pty/permissions.ts"; import { ptySpawn } from "./plugin/pty/tools/spawn.ts"; import { ptyWrite } from "./plugin/pty/tools/write.ts"; diff --git a/src/plugin/logger.ts b/src/plugin/logger.ts index 22aa73d..57e8765 100644 --- a/src/plugin/logger.ts +++ b/src/plugin/logger.ts @@ -1,52 +1,18 @@ +import { setLogHandler, createLogger as coreCreateLogger, type Logger } from "../core/logger.ts"; import type { PluginClient } from "./types.ts"; -type LogLevel = "debug" | "info" | "warn" | "error"; - -interface Logger { - debug(message: string, extra?: Record<string, unknown>): void; - info(message: string, extra?: Record<string, unknown>): void; - warn(message: string, extra?: Record<string, unknown>): void; - error(message: string, extra?: Record<string, unknown>): void; -} - -let _client: PluginClient | null = null; - +/** + * Initialize the logger with the plugin client for remote logging + */ export function initLogger(client: PluginClient): void { - _client = client; + setLogHandler((level, service, message, extra) => { + client.app.log({ + body: { service, level, message, extra }, + }).catch(() => {}); + }); } -export function createLogger(module: string): Logger { - const service = `pty.${module}`; - - const log = (level: LogLevel, message: string, extra?: Record<string, unknown>): void => { - if (_client) { - _client.app.log({ - body: { service, level, message, extra }, - }).catch(() => { }); - } else { - const prefix = `[${service}]`; - const args = extra ? [prefix, message, extra] : [prefix, message]; - switch (level) { - case "debug": - console.debug(...args); - break; - case "info": - console.info(...args); - break; - case "warn": - console.warn(...args); - break; - case "error": - console.error(...args); - break; - } - } - }; - - return { - debug: (message, extra) => log("debug", message, extra), - info: (message, extra) => log("info", message, extra), - warn: (message, extra) => log("warn", message, extra), - error: (message, extra) => log("error", message, extra), - }; -} +/** + * Create a logger for a specific module (re-export from core) + */ +export const createLogger: (module: string) => Logger = coreCreateLogger; diff --git a/src/plugin/pty/tools/kill.ts b/src/plugin/pty/tools/kill.ts index 5a4555a..a39a767 100644 --- a/src/plugin/pty/tools/kill.ts +++ b/src/plugin/pty/tools/kill.ts @@ -1,5 +1,5 @@ import { tool } from "@opencode-ai/plugin"; -import { manager } from "../manager.ts"; +import { manager } from "../../../core/manager.ts"; import DESCRIPTION from "./kill.txt"; export const ptyKill = tool({ diff --git a/src/plugin/pty/tools/list.ts b/src/plugin/pty/tools/list.ts index 3be9215..9f919a9 100644 --- a/src/plugin/pty/tools/list.ts +++ b/src/plugin/pty/tools/list.ts @@ -1,5 +1,5 @@ import { tool } from "@opencode-ai/plugin"; -import { manager } from "../manager.ts"; +import { manager } from "../../../core/manager.ts"; import DESCRIPTION from "./list.txt"; export const ptyList = tool({ diff --git a/src/plugin/pty/tools/read.ts b/src/plugin/pty/tools/read.ts index a41d940..b86212c 100644 --- a/src/plugin/pty/tools/read.ts +++ b/src/plugin/pty/tools/read.ts @@ -1,5 +1,5 @@ import { tool } from "@opencode-ai/plugin"; -import { manager } from "../manager.ts"; +import { manager } from "../../../core/manager.ts"; import DESCRIPTION from "./read.txt"; const DEFAULT_LIMIT = 500; diff --git a/src/plugin/pty/tools/spawn.ts b/src/plugin/pty/tools/spawn.ts index 2ad978b..1895b27 100644 --- a/src/plugin/pty/tools/spawn.ts +++ b/src/plugin/pty/tools/spawn.ts @@ -1,5 +1,5 @@ import { tool } from "@opencode-ai/plugin"; -import { manager } from "../manager.ts"; +import { manager } from "../../../core/manager.ts"; import { checkCommandPermission, checkWorkdirPermission } from "../permissions.ts"; import DESCRIPTION from "./spawn.txt"; diff --git a/src/plugin/pty/tools/write.ts b/src/plugin/pty/tools/write.ts index 20d0de6..d9c0cf0 100644 --- a/src/plugin/pty/tools/write.ts +++ b/src/plugin/pty/tools/write.ts @@ -1,5 +1,5 @@ import { tool } from "@opencode-ai/plugin"; -import { manager } from "../manager.ts"; +import { manager } from "../../../core/manager.ts"; import { checkCommandPermission } from "../permissions.ts"; import DESCRIPTION from "./write.txt";