diff --git a/README.md b/README.md
index 3fc2806..71bf767 100644
--- a/README.md
+++ b/README.md
@@ -3,33 +3,19 @@
[](https://github.com/vtemian/micode/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/micode)
-OpenCode plugin with a structured Brainstorm → Plan → Implement workflow and session continuity.
-
+OpenCode plugin with structured Brainstorm → Plan → Implement workflow and session continuity.
https://github.com/user-attachments/assets/85236ad3-e78a-4ff7-a840-620f6ea2f512
-
-## Installation
+## Quick Start
Add to `~/.config/opencode/opencode.json`:
```json
-{
- "plugin": ["micode"]
-}
+{ "plugin": ["micode"] }
```
-**AI-assisted install:** Share [INSTALL_CLAUDE.md](./INSTALL_CLAUDE.md) with your AI assistant for guided setup.
-
-## Getting Started
-
-**Important:** Run `/init` first to generate project documentation:
-
-```
-/init
-```
-
-This creates `ARCHITECTURE.md` and `CODE_STYLE.md` which agents reference during brainstorming, planning, and implementation. Without these files, agents lack context about your codebase patterns.
+Then run `/init` to generate `ARCHITECTURE.md` and `CODE_STYLE.md`.
## Workflow
@@ -39,215 +25,42 @@ Brainstorm → Plan → Implement
research research executor
```
-Research subagents (codebase-locator, codebase-analyzer, pattern-finder) are spawned within brainstorm and plan phases - not as a separate step.
-
-### 1. Brainstorm
-
-Refine rough ideas into fully-formed designs through collaborative questioning.
-
-- One question at a time (critical rule!)
-- 2-3 approaches with trade-offs
-- Section-by-section validation
-- Fires research subagents in parallel via `background_task`
-- Auto-hands off to planner when user approves
-- Output: `thoughts/shared/designs/YYYY-MM-DD-{topic}-design.md`
-
-**Research subagents** (fired in parallel via background_task):
-
-| Subagent | Purpose |
-|----------|---------|
-| `codebase-locator` | Find WHERE files live (paths, no content) |
-| `codebase-analyzer` | Explain HOW code works (with file:line refs) |
-| `pattern-finder` | Find existing patterns to follow |
-
-**Auto-handoff:** When user approves the design, brainstormer automatically spawns the planner - no extra confirmation needed.
-
-### 2. Plan
-
-Transform validated designs into comprehensive implementation plans.
-
-- Fires research subagents in parallel via `background_task`
-- Uses `context7` and `btca_ask` for external library documentation
-- Bite-sized tasks (2-5 minutes each)
-- Exact file paths, complete code examples
-- TDD workflow: failing test → verify fail → implement → verify pass → commit
-- Get human approval before implementing
-- Output: `thoughts/shared/plans/YYYY-MM-DD-{topic}.md`
-
-**Library research tools:**
-
-| Tool | Purpose |
-|------|---------|
-| `context7` | Documentation lookup for external libraries |
-| `btca_ask` | Source code search for library internals |
-
-### 3. Implement
-
-Execute plan in git worktree for isolation:
-
-```bash
-git worktree add ../{feature} -b feature/{feature}
-```
-
-The **Executor** orchestrates task execution with intelligent parallelization:
-
-#### How It Works
-
-1. **Parse** - Extract individual tasks from the plan
-2. **Analyze** - Build dependency graph between tasks
-3. **Batch** - Group independent tasks for parallel execution
-4. **Execute** - Run implementer→reviewer cycle per task
-5. **Aggregate** - Collect results and report status
-
-#### Dependency Analysis
-
-Tasks are grouped into batches based on their dependencies:
-
-```
-Independent tasks (can parallelize):
-- Modify different files
-- Don't depend on each other's output
-- Don't share state
-
-Dependent tasks (must be sequential):
-- Task B modifies a file Task A creates
-- Task B imports something Task A defines
-- Task B's test relies on Task A's implementation
-```
-
-#### Parallel Execution (Fire-and-Check Pattern)
-
-The executor uses a **fire-and-check** pattern for maximum parallelism:
-
-1. **Fire** - Launch all implementers as `background_task` in ONE message
-2. **Poll** - Check `background_list` for completions
-3. **React** - Start reviewer immediately when each implementer finishes
-4. **Repeat** - Continue polling until batch complete
-
-```
-Plan with 6 tasks:
-├── Batch 1 (parallel): Tasks 1, 2, 3 → independent, different files
-│ │
-│ │ FIRE: background_task(agent="implementer") x3
-│ │
-│ │ POLL: background_list() → task 2 completed!
-│ │ → background_output(task_2)
-│ │ → background_task(agent="reviewer", "Review task 2")
-│ │
-│ │ POLL: background_list() → tasks 1, 3 completed!
-│ │ → start reviewers for 1 and 3
-│ │
-│ │ [continue until all reviewed]
-│
-└── Batch 2 (parallel): Tasks 4, 5, 6 → depend on batch 1
- └── [same pattern]
-```
-
-Key: Reviewers start **immediately** when their implementer finishes - no waiting for the whole batch.
-
-#### Per-Task Cycle
-
-Each task gets its own implement→review loop:
-
-1. Fire implementer via `background_task`
-2. Implementer: make changes → run tests → **commit** if passing
-3. Fire reviewer to check implementation
-4. If changes requested → fire new implementer (max 3 cycles)
-5. Mark as DONE or BLOCKED
-
-**Note:** Implementer commits after verification passes, using the commit message from the plan.
-
-### 4. Session Continuity
-
-Maintain context across long sessions and context clears with structured compaction:
-
-#### Ledger System
-
-The **continuity ledger** serves as both session state and compaction summary. Based on [Factory.ai's structured compaction research](https://factory.ai/blog/context-compression), which found that structured summarization with deterministic file tracking retains more useful context.
-
-```
-/ledger
-```
-
-Creates/updates `thoughts/ledgers/CONTINUITY_{session-name}.md` with:
-
-```markdown
-# Session: {name}
-Updated: {timestamp}
-
-## Goal
-## Constraints
-## Progress
-### Done
-- [x] {Completed items}
-### In Progress
-- [ ] {Current work}
-### Blocked
-- {Issues, if any}
-## Key Decisions
-- **{Decision}**: {Rationale}
-## Next Steps
-1. {Ordered list}
-## File Operations
-### Read
-- `{paths read since last compaction}`
-### Modified
-- `{paths written/edited since last compaction}`
-## Critical Context
-- {Data, examples, references needed to continue}
-```
-
-**Key features:**
-
-- **Iterative merging** - Updates preserve existing information, adding new progress rather than regenerating from scratch
-- **Deterministic file tracking** - Read/write/edit operations tracked automatically via tool call interception, not LLM extraction
-- **Auto-injection** - Most recent ledger injected into system prompt on session start
+### Brainstorm
+Refine ideas into designs through collaborative questioning. Fires research subagents in parallel. Output: `thoughts/shared/designs/YYYY-MM-DD-{topic}-design.md`
-**Auto-clear:** At 80% context usage, the system automatically:
-1. Captures file operations tracked since last clear
-2. Updates ledger with current state (iterative merge with previous)
-3. Clears the session
-4. Injects the updated ledger into fresh context
+### Plan
+Transform designs into implementation plans with bite-sized tasks (2-5 min each), exact file paths, and TDD workflow. Output: `thoughts/shared/plans/YYYY-MM-DD-{topic}.md`
-#### Artifact Search
+### Implement
+Execute in git worktree for isolation. The **Executor** orchestrates implementer→reviewer cycles with parallel execution via fire-and-check pattern.
-Search past work to find relevant precedent:
-
-```
-/search oauth authentication
-/search JWT tokens
-```
-
-Searches across:
-- Ledgers (`thoughts/ledgers/`)
-- Plans (`thoughts/shared/plans/`)
-
-**Auto-indexing:** Artifacts are automatically indexed when created.
+### Session Continuity
+Maintain context across sessions with structured compaction. Run `/ledger` to create/update `thoughts/ledgers/CONTINUITY_{session}.md`. Auto-clears at 60% context usage.
## Commands
| Command | Description |
|---------|-------------|
-| `/init` | Initialize project with ARCHITECTURE.md and CODE_STYLE.md |
-| `/ledger` | Create or update continuity ledger for session state |
+| `/init` | Initialize project docs |
+| `/ledger` | Create/update continuity ledger |
| `/search` | Search past plans and ledgers |
## Agents
-| Agent | Mode | Model | Purpose |
-|-------|------|-------|---------|
-| commander | primary | claude-opus-4-5 | Orchestrator, delegates to specialists |
-| brainstormer | primary | claude-opus-4-5 | Design exploration through questioning |
-| project-initializer | subagent | claude-opus-4-5 | Generate ARCHITECTURE.md and CODE_STYLE.md |
-| codebase-locator | subagent | claude-sonnet | Find file locations |
-| codebase-analyzer | subagent | claude-sonnet | Deep code analysis |
-| pattern-finder | subagent | claude-sonnet | Find existing patterns |
-| planner | subagent | claude-opus-4-5 | Create detailed implementation plans |
-| executor | subagent | claude-opus-4-5 | Orchestrate implement → review cycle |
-| implementer | subagent | claude-opus-4-5 | Execute implementation tasks |
-| reviewer | subagent | claude-opus-4-5 | Review correctness and style |
-| ledger-creator | subagent | claude-sonnet | Create/update continuity ledgers |
-| artifact-searcher | subagent | claude-sonnet | Search past work for precedent |
+| Agent | Purpose |
+|-------|---------|
+| commander | Orchestrator |
+| brainstormer | Design exploration |
+| planner | Implementation plans |
+| executor | Orchestrate implement→review |
+| implementer | Execute tasks |
+| reviewer | Check correctness |
+| codebase-locator | Find file locations |
+| codebase-analyzer | Deep code analysis |
+| pattern-finder | Find existing patterns |
+| project-initializer | Generate project docs |
+| ledger-creator | Continuity ledgers |
+| artifact-searcher | Search past work |
## Tools
@@ -255,147 +68,67 @@ Searches across:
|------|-------------|
| `ast_grep_search` | AST-aware code pattern search |
| `ast_grep_replace` | AST-aware code pattern replacement |
-| `look_at` | Extract file structure for large files |
-| `artifact_search` | Search past plans and ledgers |
-| `btca_ask` | Query library source code (requires btca CLI) |
-| `background_task` | Fire subagent to run in background, returns task_id |
-| `background_list` | List all tasks and status (use to poll for completion) |
-| `background_output` | Get results from completed task |
-| `background_cancel` | Cancel running task(s) |
-
-### Background Task Pattern
-
-Research agents (brainstormer, planner, project-initializer) use the **fire-poll-collect** pattern. Executor uses **fire-and-check** (starts reviewers as implementers complete).
-
-```
-# FIRE: Launch all in ONE message
-task_1 = background_task(agent="locator", prompt="...")
-task_2 = background_task(agent="analyzer", prompt="...")
-
-# POLL: Check until complete
-background_list() # repeat until all show "completed" or "error"
-
-# COLLECT: Get results (skip errored tasks)
-background_output(task_id=task_1)
-background_output(task_id=task_2)
-```
+| `look_at` | Extract file structure |
+| `artifact_search` | Search past plans/ledgers |
+| `btca_ask` | Query library source code |
+| `background_task` | Fire subagent in background |
+| `background_list` | List tasks and status |
+| `background_output` | Get task results |
+| `background_cancel` | Cancel task(s) |
+| `pty_spawn` | Start background terminal session |
+| `pty_write` | Send input to PTY |
+| `pty_read` | Read PTY output |
+| `pty_list` | List PTY sessions |
+| `pty_kill` | Terminate PTY |
+
+### Background Tasks vs PTY
+
+| System | Purpose | Use Case |
+|--------|---------|----------|
+| `background_task` | Async AI subagents | Research, implementation, reviews |
+| `pty_spawn` | Async bash processes | Dev servers, watch modes, REPLs |
## Hooks
-| Hook | Description |
-|------|-------------|
-| Think Mode | Keywords like "think hard" enable 32k token thinking budget |
-| Ledger Loader | Injects continuity ledger into system prompt |
-| Auto-Clear Ledger | At 80% context, saves ledger with file ops and clears session |
-| File Ops Tracker | Tracks read/write/edit tool calls for deterministic file operation logging |
-| Artifact Auto-Index | Indexes artifacts when written to thoughts/ directories |
-| Auto-Compact | Summarizes session when hitting token limits |
-| Context Injector | Injects ARCHITECTURE.md, CODE_STYLE.md, .cursorrules |
-| Token-Aware Truncation | Truncates large tool outputs |
-| Context Window Monitor | Tracks token usage |
-| Comment Checker | Validates edit tool comments |
-| Session Recovery | Recovers from crashes |
-
-## Permissions
-
-All permissions are set to `allow` globally - no prompts for tool usage:
-
-```typescript
-config.permission = {
- edit: "allow",
- bash: "allow",
- webfetch: "allow",
- doom_loop: "allow",
- external_directory: "allow",
-};
-```
-
-This enables subagents to work autonomously without getting stuck on permission prompts.
-
-## MCP Servers
-
-| Server | Description | Activation |
-|--------|-------------|------------|
-| context7 | Documentation lookup | Always enabled |
-| perplexity | Web search | Set `PERPLEXITY_API_KEY` |
-| firecrawl | Web crawling | Set `FIRECRAWL_API_KEY` |
-
-## Structure
-
-```
-micode/
-├── src/
-│ ├── agents/ # Agent definitions
-│ ├── tools/ # ast-grep, look-at, artifact-search, background-task
-│ ├── hooks/ # Session management hooks
-│ └── index.ts # Plugin entry
-├── dist/ # Built plugin
-└── thoughts/ # Artifacts (gitignored)
- ├── ledgers/ # Continuity ledgers
- └── shared/
- ├── designs/ # Brainstorm outputs
- └── plans/ # Implementation plans
-```
+- **Think Mode** - Keywords like "think hard" enable 32k token thinking budget
+- **Ledger Loader** - Injects continuity ledger into system prompt
+- **Auto-Clear Ledger** - At 60% context, saves ledger and clears session
+- **File Ops Tracker** - Tracks read/write/edit for deterministic logging
+- **Artifact Auto-Index** - Indexes artifacts in thoughts/ directories
+- **Context Injector** - Injects ARCHITECTURE.md, CODE_STYLE.md
+- **Token-Aware Truncation** - Truncates large tool outputs
## Development
-### From source
-
```bash
git clone git@github.com:vtemian/micode.git ~/.micode
-cd ~/.micode
-bun install
-bun run build
+cd ~/.micode && bun install && bun run build
```
-Then use local path in config:
```json
-{
- "plugin": ["~/.micode"]
-}
-```
-
-### Commands
-
-```bash
-bun install # Install dependencies
-bun run build # Build plugin
-bun run typecheck # Type check
-bun test # Run tests
-bun test --watch # Run tests in watch mode
+// Use local path
+{ "plugin": ["~/.micode"] }
```
### Release
-Releases are automated via GitHub Actions. To publish a new version:
-
```bash
npm version patch # or minor, major
git push --follow-tags
```
-This triggers the release workflow which publishes to npm.
-
-**Manual publish** (first time or if needed):
-```bash
-npm login
-npm publish
-```
-
## Philosophy
1. **Brainstorm first** - Refine ideas before coding
2. **Research before implementing** - Understand the codebase
3. **Plan with human buy-in** - Get approval before coding
-4. **Parallel investigation** - Spawn multiple subagents for speed
-5. **Isolated implementation** - Use git worktrees for features
-6. **Continuous verification** - Implementer + Reviewer per phase
-7. **Session continuity** - Never lose context across clears
+4. **Parallel investigation** - Spawn multiple subagents
+5. **Isolated implementation** - Use git worktrees
+6. **Continuous verification** - Implementer + Reviewer per task
+7. **Session continuity** - Never lose context
## Inspiration
-Built on techniques from:
-
-- **[oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode)** - OpenCode plugin architecture, agent orchestration patterns, and trusted publishing setup
-- **[HumanLayer ACE-FCA](https://github.com/humanlayer/12-factor-agents)** - Advanced Context Engineering for Coding Agents, structured workflows, and the research → plan → implement methodology
-- **[Factory.ai Context Compression](https://factory.ai/blog/context-compression)** - Structured compaction research showing that anchored iterative summarization with deterministic file tracking outperforms generic compression
+- [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) - Plugin architecture
+- [HumanLayer ACE-FCA](https://github.com/humanlayer/12-factor-agents) - Structured workflows
+- [Factory.ai](https://factory.ai/blog/context-compression) - Structured compaction research
diff --git a/bun.lock b/bun.lock
index 8b80a7b..d83a4ab 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,6 +6,7 @@
"name": "@vtemian/opencode-config",
"dependencies": {
"@opencode-ai/plugin": "^1.0.224",
+ "bun-pty": "^0.4.5",
},
"devDependencies": {
"@biomejs/biome": "^2.3.10",
@@ -39,6 +40,8 @@
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
+ "bun-pty": ["bun-pty@0.4.5", "", {}, "sha512-r8NL1C+z0Dicl9gyi0QV0DAPEBgoKO5CJuecbeS8fpfEkxBHy8XrJ7ibVBS+YRLWjcky3EKl8BY7nY+l4Jv8DQ=="],
+
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
diff --git a/package.json b/package.json
index b639bd6..4ef0142 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,8 @@
"url": "https://github.com/vtemian/micode/issues"
},
"dependencies": {
- "@opencode-ai/plugin": "^1.0.224"
+ "@opencode-ai/plugin": "^1.0.224",
+ "bun-pty": "^0.4.5"
},
"devDependencies": {
"@biomejs/biome": "^2.3.10",
diff --git a/src/agents/commander.ts b/src/agents/commander.ts
index 5cb2bdb..b6d1746 100644
--- a/src/agents/commander.ts
+++ b/src/agents/commander.ts
@@ -99,6 +99,23 @@ Just do it - including obvious follow-up actions.
+
+Synchronous commands. Use for: npm install, git, builds, quick commands that complete.
+Background PTY sessions. Use for: dev servers, watch modes, REPLs, long-running processes.
+
+
+
+
+
+
+
+pty_spawn to start the process
+pty_read to check output (use pattern to filter)
+pty_write to send input (\\n for Enter, \\x03 for Ctrl+C)
+pty_kill when done (cleanup=true to remove)
+
+
+
Use TodoWrite to track what you're doing
Never discard tasks without explicit approval
diff --git a/src/agents/executor.ts b/src/agents/executor.ts
index fff1ff0..2dcdd5e 100644
--- a/src/agents/executor.ts
+++ b/src/agents/executor.ts
@@ -18,6 +18,24 @@ You have access to background task management tools:
- background_list: List all background tasks and their status
+
+PTY tools manage background terminal sessions (different from background_task which runs subagents):
+- pty_spawn: Start a background process (dev server, watch mode, REPL)
+- pty_write: Send input to a PTY (commands, Ctrl+C, etc.)
+- pty_read: Read output from a PTY buffer
+- pty_list: List all PTY sessions
+- pty_kill: Terminate a PTY session
+
+Use PTY when:
+- Plan requires starting a dev server before running tests
+- Plan requires a watch mode process running during implementation
+- Plan requires interactive terminal input
+
+Do NOT use PTY for:
+- Quick commands (use bash)
+- Subagent tasks (use background_task)
+
+
Parse plan to extract individual tasks
Analyze task dependencies to build execution graph
diff --git a/src/agents/implementer.ts b/src/agents/implementer.ts
index ebd527a..ddcc415 100644
--- a/src/agents/implementer.ts
+++ b/src/agents/implementer.ts
@@ -31,6 +31,13 @@ Execute the plan. Write code. Verify.
Report results
+
+Use for synchronous commands that complete (npm install, git, builds)
+Use for background processes (dev servers, watch modes, REPLs)
+If plan says "start dev server" or "run in background", use pty_spawn
+If plan says "run command" or "install", use bash
+
+
Verify file exists where expected
Verify code structure matches plan assumptions
diff --git a/src/agents/reviewer.ts b/src/agents/reviewer.ts
index ec666b6..56da996 100644
--- a/src/agents/reviewer.ts
+++ b/src/agents/reviewer.ts
@@ -65,6 +65,12 @@ Check correctness and style. Be specific. Run code, don't just read.
Report with precise references
+
+If implementation includes PTY usage, verify sessions are properly cleaned up
+If tests require a running server, check that pty_spawn was used appropriately
+Check that long-running processes use PTY, not blocking bash
+
+
## Review: [Component]
diff --git a/src/hooks/auto-clear-ledger.ts b/src/hooks/auto-clear-ledger.ts
index 7fb3ddf..46c5bf6 100644
--- a/src/hooks/auto-clear-ledger.ts
+++ b/src/hooks/auto-clear-ledger.ts
@@ -4,7 +4,7 @@ import { findCurrentLedger, formatLedgerInjection } from "./ledger-loader";
import { getFileOps, clearFileOps, formatFileOpsForPrompt } from "./file-ops-tracker";
import { getContextLimit } from "../utils/model-limits";
-export const DEFAULT_THRESHOLD = 0.8;
+export const DEFAULT_THRESHOLD = 0.6; // 60% of context window
const MIN_TOKENS_FOR_CLEAR = 50_000;
export const CLEAR_COOLDOWN_MS = 60_000;
diff --git a/src/index.ts b/src/index.ts
index 708eede..dbff9a4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -25,6 +25,9 @@ import { createFileOpsTrackerHook } from "./hooks/file-ops-tracker";
// Background Task System
import { BackgroundTaskManager, createBackgroundTaskTools } from "./tools/background-task";
+// PTY System
+import { PTYManager, createPtyTools } from "./tools/pty";
+
// Config loader
import { loadMicodeConfig, mergeAgentConfigs } from "./config-loader";
@@ -100,6 +103,10 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
const backgroundTaskManager = new BackgroundTaskManager(ctx);
const backgroundTaskTools = createBackgroundTaskTools(backgroundTaskManager);
+ // PTY System
+ const ptyManager = new PTYManager();
+ const ptyTools = createPtyTools(ptyManager);
+
return {
// Tools
tool: {
@@ -109,6 +116,7 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
look_at,
artifact_search,
...backgroundTaskTools,
+ ...ptyTools,
},
config: async (config) => {
@@ -222,11 +230,12 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
},
event: async ({ event }) => {
- // Think mode cleanup
+ // Session cleanup (think mode + PTY)
if (event.type === "session.deleted") {
const props = event.properties as { info?: { id?: string } } | undefined;
if (props?.info?.id) {
thinkModeState.delete(props.info.id);
+ ptyManager.cleanupBySession(props.info.id);
}
}
diff --git a/src/tools/pty/buffer.ts b/src/tools/pty/buffer.ts
new file mode 100644
index 0000000..4658c87
--- /dev/null
+++ b/src/tools/pty/buffer.ts
@@ -0,0 +1,49 @@
+// src/tools/pty/buffer.ts
+import type { SearchMatch } from "./types";
+
+const parsed = parseInt(process.env.PTY_MAX_BUFFER_LINES || "50000", 10);
+const DEFAULT_MAX_LINES = isNaN(parsed) ? 50000 : parsed;
+
+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/tools/pty/index.ts b/src/tools/pty/index.ts
new file mode 100644
index 0000000..27426e0
--- /dev/null
+++ b/src/tools/pty/index.ts
@@ -0,0 +1,34 @@
+// src/tools/pty/index.ts
+export { PTYManager } from "./manager";
+export { RingBuffer } from "./buffer";
+export { createPtySpawnTool } from "./tools/spawn";
+export { createPtyWriteTool } from "./tools/write";
+export { createPtyReadTool } from "./tools/read";
+export { createPtyListTool } from "./tools/list";
+export { createPtyKillTool } from "./tools/kill";
+export type {
+ PTYSession,
+ PTYSessionInfo,
+ PTYStatus,
+ SpawnOptions,
+ ReadResult,
+ SearchMatch,
+ SearchResult,
+} from "./types";
+
+import type { PTYManager } from "./manager";
+import { createPtySpawnTool } from "./tools/spawn";
+import { createPtyWriteTool } from "./tools/write";
+import { createPtyReadTool } from "./tools/read";
+import { createPtyListTool } from "./tools/list";
+import { createPtyKillTool } from "./tools/kill";
+
+export function createPtyTools(manager: PTYManager) {
+ return {
+ pty_spawn: createPtySpawnTool(manager),
+ pty_write: createPtyWriteTool(manager),
+ pty_read: createPtyReadTool(manager),
+ pty_list: createPtyListTool(manager),
+ pty_kill: createPtyKillTool(manager),
+ };
+}
diff --git a/src/tools/pty/manager.ts b/src/tools/pty/manager.ts
new file mode 100644
index 0000000..ec74ac4
--- /dev/null
+++ b/src/tools/pty/manager.ts
@@ -0,0 +1,159 @@
+// src/tools/pty/manager.ts
+import { spawn, type IPty } from "bun-pty";
+import { RingBuffer } from "./buffer";
+import type { PTYSession, PTYSessionInfo, SpawnOptions, ReadResult, SearchResult } from "./types";
+
+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 = 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;
+ const title = opts.title ?? (`${opts.command} ${args.join(" ")}`.trim() || `Terminal ${id.slice(-4)}`);
+
+ 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 }) => {
+ 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;
+ }
+
+ if (session.status === "running") {
+ try {
+ session.process.kill();
+ } catch {
+ // Process may already be dead
+ }
+ session.status = "killed";
+ }
+
+ if (cleanup) {
+ session.buffer.clear();
+ this.sessions.delete(id);
+ }
+
+ return true;
+ }
+
+ cleanupBySession(parentSessionId: string): void {
+ for (const [id, session] of this.sessions) {
+ if (session.parentSessionId === parentSessionId) {
+ this.kill(id, true);
+ }
+ }
+ }
+
+ cleanupAll(): void {
+ 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,
+ };
+ }
+}
diff --git a/src/tools/pty/tools/kill.ts b/src/tools/pty/tools/kill.ts
new file mode 100644
index 0000000..605ec76
--- /dev/null
+++ b/src/tools/pty/tools/kill.ts
@@ -0,0 +1,68 @@
+// src/tools/pty/tools/kill.ts
+import { tool } from "@opencode-ai/plugin/tool";
+import type { PTYManager } from "../manager";
+
+const DESCRIPTION = `Terminates a PTY session and optionally cleans up its buffer.
+
+Use this tool to:
+- Stop a running process (sends SIGTERM)
+- Clean up an exited session to free memory
+- Remove a session from the list
+
+Usage:
+- \`id\`: The PTY session ID (from pty_spawn or pty_list)
+- \`cleanup\`: If true, removes the session and frees the buffer (default: false)
+
+Behavior:
+- If the session is running, it will be killed (status becomes "killed")
+- If cleanup=false (default), the session remains in the list with its output buffer intact
+- If cleanup=true, the session is removed entirely and the buffer is freed
+- Keeping sessions without cleanup allows you to compare logs between runs
+
+Tips:
+- Use cleanup=false if you might want to read the output later
+- Use cleanup=true when you're done with the session entirely
+- To send Ctrl+C instead of killing, use pty_write with data="\\x03"
+
+Examples:
+- Kill but keep logs: cleanup=false (or omit)
+- Kill and remove: cleanup=true`;
+
+export function createPtyKillTool(manager: PTYManager) {
+ return tool({
+ description: DESCRIPTION,
+ args: {
+ id: tool.schema.string().describe("The PTY session ID (e.g., pty_a1b2c3d4)"),
+ cleanup: tool.schema
+ .boolean()
+ .optional()
+ .describe("If true, removes the session and frees the buffer (default: false)"),
+ },
+ execute: async (args) => {
+ const session = manager.get(args.id);
+ if (!session) {
+ throw new Error(`PTY session '${args.id}' not found. Use pty_list to see active sessions.`);
+ }
+
+ const wasRunning = session.status === "running";
+ const cleanup = args.cleanup ?? false;
+ const success = manager.kill(args.id, cleanup);
+
+ if (!success) {
+ throw new Error(`Failed to kill PTY session '${args.id}'.`);
+ }
+
+ const action = wasRunning ? "Killed" : "Cleaned up";
+ const cleanupNote = cleanup ? " (session removed)" : " (session retained for log access)";
+
+ return [
+ ``,
+ `${action}: ${args.id}${cleanupNote}`,
+ `Title: ${session.title}`,
+ `Command: ${session.command} ${session.args.join(" ")}`,
+ `Final line count: ${session.lineCount}`,
+ ``,
+ ].join("\n");
+ },
+ });
+}
diff --git a/src/tools/pty/tools/list.ts b/src/tools/pty/tools/list.ts
new file mode 100644
index 0000000..de7224d
--- /dev/null
+++ b/src/tools/pty/tools/list.ts
@@ -0,0 +1,55 @@
+// src/tools/pty/tools/list.ts
+import { tool } from "@opencode-ai/plugin/tool";
+import type { PTYManager } from "../manager";
+
+const DESCRIPTION = `Lists all PTY sessions (active and exited).
+
+Use this tool to:
+- See all running and exited PTY sessions
+- Get session IDs for use with other pty_* tools
+- Check the status and output line count of each session
+- Monitor which processes are still running
+
+Returns for each session:
+- \`id\`: Unique identifier for use with other tools
+- \`title\`: Human-readable name
+- \`command\`: The command that was executed
+- \`status\`: Current status (running, exited, killed)
+- \`exitCode\`: Exit code (if exited/killed)
+- \`pid\`: Process ID
+- \`lineCount\`: Number of lines in the output buffer
+- \`createdAt\`: When the session was created
+
+Tips:
+- Use the session ID with pty_read, pty_write, or pty_kill
+- Sessions remain in the list after exit until explicitly cleaned up with pty_kill
+- This allows you to compare output from multiple sessions`;
+
+export function createPtyListTool(manager: PTYManager) {
+ return tool({
+ description: DESCRIPTION,
+ args: {},
+ execute: async () => {
+ const sessions = manager.list();
+
+ if (sessions.length === 0) {
+ return "\nNo active PTY sessions.\n";
+ }
+
+ const lines = [""];
+ for (const session of sessions) {
+ const exitInfo = session.exitCode !== undefined ? ` (exit: ${session.exitCode})` : "";
+ lines.push(`[${session.id}] ${session.title}`);
+ lines.push(` Command: ${session.command} ${session.args.join(" ")}`);
+ lines.push(` Status: ${session.status}${exitInfo}`);
+ lines.push(` PID: ${session.pid} | Lines: ${session.lineCount} | Workdir: ${session.workdir}`);
+ lines.push(` Created: ${session.createdAt.toISOString()}`);
+ lines.push("");
+ }
+ lines.push(`Total: ${sessions.length} session(s)`);
+ lines.push("");
+
+ return lines.join("\n");
+ },
+ });
+}
diff --git a/src/tools/pty/tools/read.ts b/src/tools/pty/tools/read.ts
new file mode 100644
index 0000000..cb99f41
--- /dev/null
+++ b/src/tools/pty/tools/read.ts
@@ -0,0 +1,152 @@
+// src/tools/pty/tools/read.ts
+import { tool } from "@opencode-ai/plugin/tool";
+import type { PTYManager } from "../manager";
+
+const DESCRIPTION = `Reads output from a PTY session's buffer.
+
+The PTY maintains a rolling buffer of output lines. Use offset and limit to paginate through the output, similar to reading a file.
+
+Usage:
+- \`id\`: The PTY session ID (from pty_spawn or pty_list)
+- \`offset\`: Line number to start reading from (0-based, defaults to 0)
+- \`limit\`: Number of lines to read (defaults to 500)
+- \`pattern\`: Regex pattern to filter lines (optional)
+- \`ignoreCase\`: Case-insensitive pattern matching (default: false)
+
+Returns:
+- Numbered lines of output (similar to cat -n format)
+- Total line count in the buffer
+- Indicator if more lines are available
+
+The buffer stores up to PTY_MAX_BUFFER_LINES (default: 50000) lines. Older lines are discarded when the limit is reached.
+
+Pattern Filtering:
+- When \`pattern\` is set, lines are FILTERED FIRST using the regex, then offset/limit apply to the MATCHES
+- Original line numbers are preserved so you can see where matches occurred in the buffer
+- Supports full regex syntax (e.g., "error", "ERROR|WARN", "failed.*connection", etc.)
+- If the pattern is invalid, an error message is returned explaining the issue
+- If no lines match the pattern, a clear message indicates zero matches
+
+Tips:
+- To see the latest output, use a high offset or omit offset to read from the start
+- To tail recent output, calculate offset as (totalLines - N) where N is how many recent lines you want
+- Lines longer than 2000 characters are truncated
+- Empty output may mean the process hasn't produced output yet
+
+Examples:
+- Read first 100 lines: offset=0, limit=100
+- Read lines 500-600: offset=500, limit=100
+- Read all available: omit both parameters
+- Find errors: pattern="error", ignoreCase=true
+- Find specific log levels: pattern="ERROR|WARN|FATAL"
+- First 10 matches only: pattern="error", limit=10`;
+
+const DEFAULT_LIMIT = 500;
+const MAX_LINE_LENGTH = 2000;
+
+export function createPtyReadTool(manager: PTYManager) {
+ return tool({
+ description: DESCRIPTION,
+ args: {
+ id: tool.schema.string().describe("The PTY session ID (e.g., pty_a1b2c3d4)"),
+ offset: tool.schema.number().optional().describe("Line number to start reading from (0-based, defaults to 0)"),
+ limit: tool.schema.number().optional().describe("Number of lines to read (defaults to 500)"),
+ pattern: tool.schema.string().optional().describe("Regex pattern to filter lines"),
+ ignoreCase: tool.schema.boolean().optional().describe("Case-insensitive pattern matching (default: false)"),
+ },
+ execute: async (args) => {
+ const session = manager.get(args.id);
+ if (!session) {
+ throw new Error(`PTY session '${args.id}' not found. Use pty_list to see active sessions.`);
+ }
+
+ const offset = Math.max(0, args.offset ?? 0);
+ const limit = args.limit ?? DEFAULT_LIMIT;
+
+ if (args.pattern) {
+ let regex: RegExp;
+ try {
+ regex = new RegExp(args.pattern, args.ignoreCase ? "i" : "");
+ } catch (e) {
+ const error = e instanceof Error ? e.message : String(e);
+ throw new Error(`Invalid regex pattern '${args.pattern}': ${error}`);
+ }
+
+ const result = manager.search(args.id, regex, offset, limit);
+ if (!result) {
+ throw new Error(`PTY session '${args.id}' not found.`);
+ }
+
+ if (result.matches.length === 0) {
+ return [
+ ``,
+ `No lines matched the pattern '${args.pattern}'.`,
+ `Total lines in buffer: ${result.totalLines}`,
+ ``,
+ ].join("\n");
+ }
+
+ const formattedLines = result.matches.map((match) => {
+ const lineNum = match.lineNumber.toString().padStart(5, "0");
+ const truncatedLine =
+ match.text.length > MAX_LINE_LENGTH ? `${match.text.slice(0, MAX_LINE_LENGTH)}...` : match.text;
+ return `${lineNum}| ${truncatedLine}`;
+ });
+
+ const output = [
+ ``,
+ ...formattedLines,
+ "",
+ ];
+
+ if (result.hasMore) {
+ output.push(
+ `(${result.matches.length} of ${result.totalMatches} matches shown. Use offset=${offset + result.matches.length} to see more.)`,
+ );
+ } else {
+ output.push(
+ `(${result.totalMatches} match${result.totalMatches === 1 ? "" : "es"} from ${result.totalLines} total lines)`,
+ );
+ }
+ output.push(``);
+
+ return output.join("\n");
+ }
+
+ const result = manager.read(args.id, offset, limit);
+ if (!result) {
+ throw new Error(`PTY session '${args.id}' not found.`);
+ }
+
+ if (result.lines.length === 0) {
+ return [
+ ``,
+ `(No output available - buffer is empty)`,
+ `Total lines: ${result.totalLines}`,
+ ``,
+ ].join("\n");
+ }
+
+ const formattedLines = result.lines.map((line, index) => {
+ const lineNum = (result.offset + index + 1).toString().padStart(5, "0");
+ const truncatedLine = line.length > MAX_LINE_LENGTH ? `${line.slice(0, MAX_LINE_LENGTH)}...` : line;
+ return `${lineNum}| ${truncatedLine}`;
+ });
+
+ const output = [``, ...formattedLines];
+
+ if (result.hasMore) {
+ output.push("");
+ output.push(
+ `(Buffer has more lines. Use offset=${result.offset + result.lines.length} to read beyond line ${result.offset + result.lines.length})`,
+ );
+ } else {
+ output.push("");
+ output.push(`(End of buffer - total ${result.totalLines} lines)`);
+ }
+ output.push(``);
+
+ return output.join("\n");
+ },
+ });
+}
diff --git a/src/tools/pty/tools/spawn.ts b/src/tools/pty/tools/spawn.ts
new file mode 100644
index 0000000..7141d41
--- /dev/null
+++ b/src/tools/pty/tools/spawn.ts
@@ -0,0 +1,78 @@
+// src/tools/pty/tools/spawn.ts
+import { tool } from "@opencode-ai/plugin/tool";
+import type { PTYManager } from "../manager";
+
+const DESCRIPTION = `Spawns a new interactive PTY (pseudo-terminal) session that runs in the background.
+
+Unlike the built-in bash tool which runs commands synchronously and waits for completion, PTY sessions persist and allow you to:
+- Run long-running processes (dev servers, watch modes, etc.)
+- Send interactive input (including Ctrl+C, arrow keys, etc.)
+- Read output at any time
+- Manage multiple concurrent terminal sessions
+
+Usage:
+- The \`command\` parameter is required (e.g., "npm", "python", "bash")
+- Use \`args\` to pass arguments to the command (e.g., ["run", "dev"])
+- Use \`workdir\` to set the working directory (defaults to project root)
+- Use \`env\` to set additional environment variables
+- Use \`title\` to give the session a human-readable name
+- Use \`description\` for a clear, concise 5-10 word description (optional)
+
+Returns the session info including:
+- \`id\`: Unique identifier (pty_XXXXXXXX) for use with other pty_* tools
+- \`pid\`: Process ID
+- \`status\`: Current status ("running")
+
+After spawning, use:
+- \`pty_write\` to send input to the PTY
+- \`pty_read\` to read output from the PTY
+- \`pty_list\` to see all active PTY sessions
+- \`pty_kill\` to terminate the PTY
+
+Examples:
+- Start a dev server: command="npm", args=["run", "dev"], title="Dev Server"
+- Start a Python REPL: command="python3", title="Python REPL"
+- Run tests in watch mode: command="npm", args=["test", "--", "--watch"]`;
+
+export function createPtySpawnTool(manager: PTYManager) {
+ return tool({
+ description: DESCRIPTION,
+ args: {
+ command: tool.schema.string().describe("The command/executable to run"),
+ args: tool.schema.array(tool.schema.string()).optional().describe("Arguments to pass to the command"),
+ workdir: tool.schema.string().optional().describe("Working directory for the PTY session"),
+ env: tool.schema
+ .record(tool.schema.string(), tool.schema.string())
+ .optional()
+ .describe("Additional environment variables"),
+ title: tool.schema.string().optional().describe("Human-readable title for the session"),
+ description: tool.schema
+ .string()
+ .optional()
+ .describe("Clear, concise description of what this PTY session is for in 5-10 words"),
+ },
+ execute: async (args, ctx) => {
+ const info = manager.spawn({
+ command: args.command,
+ args: args.args,
+ workdir: args.workdir,
+ env: args.env,
+ title: args.title,
+ parentSessionId: ctx.sessionID,
+ });
+
+ const output = [
+ ``,
+ `ID: ${info.id}`,
+ `Title: ${info.title}`,
+ `Command: ${info.command} ${info.args.join(" ")}`,
+ `Workdir: ${info.workdir}`,
+ `PID: ${info.pid}`,
+ `Status: ${info.status}`,
+ ``,
+ ].join("\n");
+
+ return output;
+ },
+ });
+}
diff --git a/src/tools/pty/tools/write.ts b/src/tools/pty/tools/write.ts
new file mode 100644
index 0000000..c865c59
--- /dev/null
+++ b/src/tools/pty/tools/write.ts
@@ -0,0 +1,97 @@
+// src/tools/pty/tools/write.ts
+import { tool } from "@opencode-ai/plugin/tool";
+import type { PTYManager } from "../manager";
+
+const DESCRIPTION = `Sends input data to an active PTY session.
+
+Use this tool to:
+- Type commands or text into an interactive terminal
+- Send special key sequences (Ctrl+C, Enter, arrow keys, etc.)
+- Respond to prompts in interactive programs
+
+Usage:
+- \`id\`: The PTY session ID (from pty_spawn or pty_list)
+- \`data\`: The input to send (text, commands, or escape sequences)
+
+Common escape sequences:
+- Enter/newline: "\\n" or "\\r"
+- Ctrl+C (interrupt): "\\x03"
+- Ctrl+D (EOF): "\\x04"
+- Ctrl+Z (suspend): "\\x1a"
+- Tab: "\\t"
+- Arrow Up: "\\x1b[A"
+- Arrow Down: "\\x1b[B"
+- Arrow Right: "\\x1b[C"
+- Arrow Left: "\\x1b[D"
+
+Returns success or error message.
+
+Examples:
+- Send a command: data="ls -la\\n"
+- Interrupt a process: data="\\x03"
+- Answer a prompt: data="yes\\n"`;
+
+/**
+ * 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}|[nrt0\\])/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 "0":
+ return "\0";
+ case "\\":
+ return "\\";
+ default:
+ return match;
+ }
+ });
+}
+
+export function createPtyWriteTool(manager: PTYManager) {
+ return tool({
+ description: DESCRIPTION,
+ args: {
+ id: tool.schema.string().describe("The PTY session ID (e.g., pty_a1b2c3d4)"),
+ data: tool.schema.string().describe("The input data to send to the PTY"),
+ },
+ execute: async (args) => {
+ const session = manager.get(args.id);
+ if (!session) {
+ throw new Error(`PTY session '${args.id}' not found. Use pty_list to see active sessions.`);
+ }
+
+ if (session.status !== "running") {
+ throw new Error(`Cannot write to PTY '${args.id}' - session status is '${session.status}'.`);
+ }
+
+ // Parse escape sequences to actual bytes
+ const parsedData = parseEscapeSequences(args.data);
+
+ const success = manager.write(args.id, parsedData);
+ if (!success) {
+ throw new Error(`Failed to write to PTY '${args.id}'.`);
+ }
+
+ const preview = args.data.length > 50 ? `${args.data.slice(0, 50)}...` : args.data;
+ const displayPreview = preview
+ .replace(/\x03/g, "^C")
+ .replace(/\x04/g, "^D")
+ .replace(/\n/g, "\\n")
+ .replace(/\r/g, "\\r");
+ return `Sent ${parsedData.length} bytes to ${args.id}: "${displayPreview}"`;
+ },
+ });
+}
diff --git a/src/tools/pty/types.ts b/src/tools/pty/types.ts
new file mode 100644
index 0000000..8872914
--- /dev/null
+++ b/src/tools/pty/types.ts
@@ -0,0 +1,62 @@
+// src/tools/pty/types.ts
+import type { RingBuffer } from "./buffer";
+
+export type PTYStatus = "running" | "exited" | "killed";
+
+export interface PTYSession {
+ id: string;
+ title: string;
+ command: string;
+ args: string[];
+ workdir: string;
+ env?: Record;
+ status: PTYStatus;
+ exitCode?: number;
+ pid: number;
+ createdAt: Date;
+ parentSessionId: string;
+ buffer: RingBuffer;
+ process: import("bun-pty").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;
+ title?: string;
+ parentSessionId: string;
+}
+
+export interface ReadResult {
+ lines: string[];
+ totalLines: number;
+ offset: number;
+ hasMore: boolean;
+}
+
+export interface SearchMatch {
+ lineNumber: number;
+ text: string;
+}
+
+export interface SearchResult {
+ matches: SearchMatch[];
+ totalMatches: number;
+ totalLines: number;
+ offset: number;
+ hasMore: boolean;
+}
diff --git a/tests/hooks/auto-clear-ledger.test.ts b/tests/hooks/auto-clear-ledger.test.ts
index 77c0933..36b97af 100644
--- a/tests/hooks/auto-clear-ledger.test.ts
+++ b/tests/hooks/auto-clear-ledger.test.ts
@@ -7,9 +7,9 @@ describe("auto-clear-ledger", () => {
expect(typeof module.createAutoClearLedgerHook).toBe("function");
});
- it("should use 80% threshold", async () => {
+ it("should use 60% threshold", async () => {
const { DEFAULT_THRESHOLD } = await import("../../src/hooks/auto-clear-ledger");
- expect(DEFAULT_THRESHOLD).toBe(0.8);
+ expect(DEFAULT_THRESHOLD).toBe(0.6);
});
it("should have 60 second cooldown", async () => {
diff --git a/tests/tools/pty/buffer.test.ts b/tests/tools/pty/buffer.test.ts
new file mode 100644
index 0000000..31ab665
--- /dev/null
+++ b/tests/tools/pty/buffer.test.ts
@@ -0,0 +1,97 @@
+// tests/tools/pty/buffer.test.ts
+import { describe, it, expect } from "bun:test";
+import { RingBuffer } from "../../../src/tools/pty/buffer";
+
+describe("RingBuffer", () => {
+ describe("append", () => {
+ it("should store appended lines", () => {
+ const buffer = new RingBuffer(100);
+ buffer.append("line1\nline2\nline3");
+
+ expect(buffer.length).toBe(3);
+ });
+
+ it("should evict oldest lines when max reached", () => {
+ const buffer = new RingBuffer(3);
+ buffer.append("line1\nline2\nline3\nline4\nline5");
+
+ expect(buffer.length).toBe(3);
+ const lines = buffer.read(0);
+ expect(lines).toEqual(["line3", "line4", "line5"]);
+ });
+ });
+
+ describe("read", () => {
+ it("should return lines from offset", () => {
+ const buffer = new RingBuffer(100);
+ buffer.append("a\nb\nc\nd\ne");
+
+ const lines = buffer.read(2, 2);
+ expect(lines).toEqual(["c", "d"]);
+ });
+
+ it("should return all lines when no limit", () => {
+ const buffer = new RingBuffer(100);
+ buffer.append("a\nb\nc");
+
+ const lines = buffer.read(0);
+ expect(lines).toEqual(["a", "b", "c"]);
+ });
+
+ it("should normalize negative offset to 0", () => {
+ const buffer = new RingBuffer(100);
+ buffer.append("a\nb\nc");
+
+ const lines = buffer.read(-5, 2);
+ expect(lines).toEqual(["a", "b"]);
+ });
+
+ it("should handle empty string input", () => {
+ const buffer = new RingBuffer(100);
+ buffer.append("");
+
+ expect(buffer.length).toBe(1);
+ const lines = buffer.read(0);
+ expect(lines).toEqual([""]);
+ });
+
+ it("should handle unicode characters", () => {
+ const buffer = new RingBuffer(100);
+ buffer.append("Hello 世界\n🎉 emoji\nкириллица");
+
+ expect(buffer.length).toBe(3);
+ const lines = buffer.read(0);
+ expect(lines).toEqual(["Hello 世界", "🎉 emoji", "кириллица"]);
+ });
+ });
+
+ describe("search", () => {
+ it("should find lines matching pattern", () => {
+ const buffer = new RingBuffer(100);
+ buffer.append("info: starting\nerror: failed\ninfo: done\nerror: timeout");
+
+ const matches = buffer.search(/error/);
+ expect(matches).toHaveLength(2);
+ expect(matches[0]).toEqual({ lineNumber: 2, text: "error: failed" });
+ expect(matches[1]).toEqual({ lineNumber: 4, text: "error: timeout" });
+ });
+
+ it("should return empty array when no matches", () => {
+ const buffer = new RingBuffer(100);
+ buffer.append("line1\nline2");
+
+ const matches = buffer.search(/notfound/);
+ expect(matches).toEqual([]);
+ });
+ });
+
+ describe("clear", () => {
+ it("should remove all lines", () => {
+ const buffer = new RingBuffer(100);
+ buffer.append("line1\nline2");
+ buffer.clear();
+
+ expect(buffer.length).toBe(0);
+ });
+ });
+});
diff --git a/tests/tools/pty/integration.test.ts b/tests/tools/pty/integration.test.ts
new file mode 100644
index 0000000..a21761e
--- /dev/null
+++ b/tests/tools/pty/integration.test.ts
@@ -0,0 +1,122 @@
+// tests/tools/pty/integration.test.ts
+import { describe, it, expect, beforeEach, afterEach } from "bun:test";
+import { PTYManager } from "../../../src/tools/pty/manager";
+import { createPtySpawnTool } from "../../../src/tools/pty/tools/spawn";
+import { createPtyWriteTool } from "../../../src/tools/pty/tools/write";
+import { createPtyReadTool } from "../../../src/tools/pty/tools/read";
+import { createPtyKillTool } from "../../../src/tools/pty/tools/kill";
+
+describe("PTY Integration", () => {
+ let manager: PTYManager;
+ let pty_spawn: ReturnType;
+ let pty_write: ReturnType;
+ let pty_read: ReturnType;
+ let pty_kill: ReturnType;
+
+ const mockContext = {
+ sessionID: "test-session",
+ messageID: "test-message",
+ } as any;
+
+ beforeEach(() => {
+ manager = new PTYManager();
+ pty_spawn = createPtySpawnTool(manager);
+ pty_write = createPtyWriteTool(manager);
+ pty_read = createPtyReadTool(manager);
+ pty_kill = createPtyKillTool(manager);
+ });
+
+ afterEach(() => {
+ manager.cleanupAll();
+ });
+
+ function extractId(output: string): string {
+ const match = output.match(/ID: (pty_[a-f0-9]+)/);
+ if (!match) throw new Error(`Could not extract PTY ID from: ${output}`);
+ return match[1];
+ }
+
+ describe("spawn → write → read → kill flow", () => {
+ it("should complete full lifecycle with cat", async () => {
+ // 1. Spawn a cat process (echoes input back)
+ const spawnResult = await pty_spawn.execute({ command: "cat", description: "Interactive cat" }, mockContext);
+ expect(spawnResult).toContain("ID:");
+ const id = extractId(spawnResult);
+
+ // 2. Write some input
+ const writeResult = await pty_write.execute({ id, data: "hello world\\n" }, mockContext);
+ expect(writeResult).toContain("Sent");
+
+ // 3. Wait for output to be captured
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // 4. Read the output
+ const readResult = await pty_read.execute({ id }, mockContext);
+ expect(readResult).toContain(" {
+ const spawnResult = await pty_spawn.execute({ command: "cat", description: "Multi-cycle test" }, mockContext);
+ const id = extractId(spawnResult);
+
+ // Write and read multiple times
+ for (let i = 1; i <= 3; i++) {
+ await pty_write.execute({ id, data: `line ${i}\\n` }, mockContext);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+
+ const readResult = await pty_read.execute({ id }, mockContext);
+ expect(readResult).toContain("line 1");
+ expect(readResult).toContain("line 2");
+ expect(readResult).toContain("line 3");
+
+ await pty_kill.execute({ id }, mockContext);
+ });
+
+ it("should handle Ctrl+C interrupt", async () => {
+ const spawnResult = await pty_spawn.execute({ command: "cat", description: "Interrupt test" }, mockContext);
+ const id = extractId(spawnResult);
+
+ // Send Ctrl+C
+ const writeResult = await pty_write.execute({ id, data: "\\x03" }, mockContext);
+ expect(writeResult).toContain("Sent");
+
+ // Wait for process to exit
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // Session should have exited
+ const session = manager.get(id);
+ expect(["exited", "killed"]).toContain(session?.status);
+ });
+ });
+
+ describe("error handling", () => {
+ it("should reject write to killed session", async () => {
+ const spawnResult = await pty_spawn.execute({ command: "cat", description: "Kill test" }, mockContext);
+ const id = extractId(spawnResult);
+
+ await pty_kill.execute({ id }, mockContext);
+
+ await expect(pty_write.execute({ id, data: "test" }, mockContext)).rejects.toThrow("killed");
+ });
+
+ it("should reject operations on non-existent session", async () => {
+ const fakeId = "pty_00000000";
+
+ await expect(pty_read.execute({ id: fakeId }, mockContext)).rejects.toThrow("not found");
+
+ await expect(pty_write.execute({ id: fakeId, data: "test" }, mockContext)).rejects.toThrow("not found");
+
+ await expect(pty_kill.execute({ id: fakeId }, mockContext)).rejects.toThrow("not found");
+ });
+ });
+});
diff --git a/tests/tools/pty/kill.test.ts b/tests/tools/pty/kill.test.ts
new file mode 100644
index 0000000..60cdbb9
--- /dev/null
+++ b/tests/tools/pty/kill.test.ts
@@ -0,0 +1,87 @@
+// tests/tools/pty/kill.test.ts
+import { describe, it, expect, beforeEach, afterEach } from "bun:test";
+import { PTYManager } from "../../../src/tools/pty/manager";
+import { createPtyKillTool } from "../../../src/tools/pty/tools/kill";
+import { createPtySpawnTool } from "../../../src/tools/pty/tools/spawn";
+
+describe("pty_kill tool", () => {
+ let manager: PTYManager;
+ let pty_kill: ReturnType;
+ let pty_spawn: ReturnType;
+
+ beforeEach(() => {
+ manager = new PTYManager();
+ pty_kill = createPtyKillTool(manager);
+ pty_spawn = createPtySpawnTool(manager);
+ });
+
+ afterEach(() => {
+ manager.cleanupAll();
+ });
+
+ it("should have correct description", () => {
+ expect(pty_kill.description).toContain("Terminates");
+ expect(pty_kill.description).toContain("PTY");
+ });
+
+ it("should require id parameter", () => {
+ expect(pty_kill.args).toHaveProperty("id");
+ });
+
+ it("should have optional cleanup parameter", () => {
+ expect(pty_kill.args).toHaveProperty("cleanup");
+ });
+
+ it("should throw error for unknown session", async () => {
+ await expect(pty_kill.execute({ id: "pty_nonexistent" }, {} as any)).rejects.toThrow("not found");
+ });
+
+ it("should kill a running session", async () => {
+ const spawnResult = await pty_spawn.execute(
+ { command: "sleep", args: ["10"], title: "Sleeper", description: "Test" },
+ { sessionID: "test", messageID: "msg" } as any,
+ );
+
+ const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/);
+ const id = idMatch?.[1];
+ expect(id).toBeDefined();
+
+ const result = await pty_kill.execute({ id: id! }, {} as any);
+
+ expect(result).toContain("");
+ expect(result).toContain("Killed:");
+ expect(result).toContain(id!);
+ expect(result).toContain("Sleeper");
+ expect(result).toContain("");
+ });
+
+ it("should cleanup session when cleanup=true", async () => {
+ const spawnResult = await pty_spawn.execute({ command: "echo", args: ["test"], description: "Test" }, {
+ sessionID: "test",
+ messageID: "msg",
+ } as any);
+
+ const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/);
+ const id = idMatch?.[1];
+
+ await pty_kill.execute({ id: id!, cleanup: true }, {} as any);
+
+ const sessions = manager.list();
+ expect(sessions).toHaveLength(0);
+ });
+
+ it("should retain session when cleanup=false", async () => {
+ const spawnResult = await pty_spawn.execute({ command: "echo", args: ["test"], description: "Test" }, {
+ sessionID: "test",
+ messageID: "msg",
+ } as any);
+
+ const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/);
+ const id = idMatch?.[1];
+
+ await pty_kill.execute({ id: id!, cleanup: false }, {} as any);
+
+ const sessions = manager.list();
+ expect(sessions).toHaveLength(1);
+ });
+});
diff --git a/tests/tools/pty/list.test.ts b/tests/tools/pty/list.test.ts
new file mode 100644
index 0000000..0582fad
--- /dev/null
+++ b/tests/tools/pty/list.test.ts
@@ -0,0 +1,66 @@
+// tests/tools/pty/list.test.ts
+import { describe, it, expect, beforeEach, afterEach } from "bun:test";
+import { PTYManager } from "../../../src/tools/pty/manager";
+import { createPtyListTool } from "../../../src/tools/pty/tools/list";
+import { createPtySpawnTool } from "../../../src/tools/pty/tools/spawn";
+
+describe("pty_list tool", () => {
+ let manager: PTYManager;
+ let pty_list: ReturnType;
+ let pty_spawn: ReturnType;
+
+ beforeEach(() => {
+ manager = new PTYManager();
+ pty_list = createPtyListTool(manager);
+ pty_spawn = createPtySpawnTool(manager);
+ });
+
+ afterEach(() => {
+ manager.cleanupAll();
+ });
+
+ it("should have correct description", () => {
+ expect(pty_list.description).toContain("PTY sessions");
+ });
+
+ it("should return empty message when no sessions", async () => {
+ const result = await pty_list.execute({}, {} as any);
+
+ expect(result).toContain("");
+ expect(result).toContain("No active PTY sessions");
+ expect(result).toContain("");
+ });
+
+ it("should list all sessions", async () => {
+ await pty_spawn.execute({ command: "echo", args: ["1"], title: "First", description: "Test 1" }, {
+ sessionID: "test",
+ messageID: "msg",
+ } as any);
+ await pty_spawn.execute({ command: "echo", args: ["2"], title: "Second", description: "Test 2" }, {
+ sessionID: "test",
+ messageID: "msg",
+ } as any);
+
+ const result = await pty_list.execute({}, {} as any);
+
+ expect(result).toContain("");
+ expect(result).toContain("First");
+ expect(result).toContain("Second");
+ expect(result).toContain("Total: 2 session(s)");
+ expect(result).toContain("");
+ });
+
+ it("should show session details", async () => {
+ await pty_spawn.execute({ command: "sleep", args: ["10"], title: "Sleeper", description: "Test" }, {
+ sessionID: "test",
+ messageID: "msg",
+ } as any);
+
+ const result = await pty_list.execute({}, {} as any);
+
+ expect(result).toContain("Command: sleep 10");
+ expect(result).toContain("Status: running");
+ expect(result).toContain("PID:");
+ expect(result).toContain("Lines:");
+ });
+});
diff --git a/tests/tools/pty/manager.test.ts b/tests/tools/pty/manager.test.ts
new file mode 100644
index 0000000..af4f6f9
--- /dev/null
+++ b/tests/tools/pty/manager.test.ts
@@ -0,0 +1,195 @@
+// tests/tools/pty/manager.test.ts
+import { describe, it, expect, beforeEach, afterEach } from "bun:test";
+import { PTYManager } from "../../../src/tools/pty/manager";
+
+describe("PTYManager", () => {
+ let manager: PTYManager;
+
+ beforeEach(() => {
+ manager = new PTYManager();
+ });
+
+ afterEach(() => {
+ manager.cleanupAll();
+ });
+
+ describe("spawn", () => {
+ it("should create a new PTY session", () => {
+ const info = manager.spawn({
+ command: "echo",
+ args: ["hello"],
+ parentSessionId: "test-session",
+ });
+
+ expect(info.id).toMatch(/^pty_[a-f0-9]{8}$/);
+ expect(info.command).toBe("echo");
+ expect(info.args).toEqual(["hello"]);
+ expect(info.status).toBe("running");
+ expect(info.pid).toBeGreaterThan(0);
+ });
+
+ it("should use default title from command", () => {
+ const info = manager.spawn({
+ command: "ls",
+ args: ["-la"],
+ parentSessionId: "test-session",
+ });
+
+ expect(info.title).toBe("ls -la");
+ });
+
+ it("should use custom title when provided", () => {
+ const info = manager.spawn({
+ command: "npm",
+ args: ["run", "dev"],
+ title: "Dev Server",
+ parentSessionId: "test-session",
+ });
+
+ expect(info.title).toBe("Dev Server");
+ });
+ });
+
+ describe("list", () => {
+ it("should return all sessions", () => {
+ manager.spawn({ command: "echo", args: ["1"], parentSessionId: "s1" });
+ manager.spawn({ command: "echo", args: ["2"], parentSessionId: "s1" });
+
+ const sessions = manager.list();
+ expect(sessions).toHaveLength(2);
+ });
+
+ it("should return empty array when no sessions", () => {
+ const sessions = manager.list();
+ expect(sessions).toEqual([]);
+ });
+ });
+
+ describe("get", () => {
+ it("should return session by id", () => {
+ const spawned = manager.spawn({
+ command: "echo",
+ parentSessionId: "test",
+ });
+
+ const info = manager.get(spawned.id);
+ expect(info).not.toBeNull();
+ expect(info?.id).toBe(spawned.id);
+ });
+
+ it("should return null for unknown id", () => {
+ const info = manager.get("pty_nonexistent");
+ expect(info).toBeNull();
+ });
+ });
+
+ describe("write", () => {
+ it("should return false for unknown session", () => {
+ const result = manager.write("pty_nonexistent", "test");
+ expect(result).toBe(false);
+ });
+
+ it("should return false for killed session", async () => {
+ const info = manager.spawn({
+ command: "cat",
+ parentSessionId: "test",
+ });
+
+ manager.kill(info.id);
+
+ const result = manager.write(info.id, "test");
+ expect(result).toBe(false);
+ });
+ });
+
+ describe("read", () => {
+ it("should return null for unknown session", () => {
+ const result = manager.read("pty_nonexistent");
+ expect(result).toBeNull();
+ });
+
+ it("should return lines with offset and limit", async () => {
+ const info = manager.spawn({
+ command: "echo",
+ args: ["-e", "a\\nb\\nc\\nd\\ne"],
+ parentSessionId: "test",
+ });
+
+ // Wait for output
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const result = manager.read(info.id, 1, 2);
+ expect(result).not.toBeNull();
+ expect(result!.offset).toBe(1);
+ expect(result!.lines.length).toBeLessThanOrEqual(2);
+ });
+ });
+
+ describe("search", () => {
+ it("should return null for unknown session", () => {
+ const result = manager.search("pty_nonexistent", /test/);
+ expect(result).toBeNull();
+ });
+
+ it("should find matching lines", async () => {
+ const info = manager.spawn({
+ command: "echo",
+ args: ["-e", "info: ok\\nerror: bad\\ninfo: done"],
+ parentSessionId: "test",
+ });
+
+ // Wait for output
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const result = manager.search(info.id, /error/);
+ expect(result).not.toBeNull();
+ expect(result!.matches.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe("kill", () => {
+ it("should kill a running session", () => {
+ const info = manager.spawn({
+ command: "sleep",
+ args: ["10"],
+ parentSessionId: "test",
+ });
+
+ const killed = manager.kill(info.id);
+ expect(killed).toBe(true);
+
+ const updated = manager.get(info.id);
+ expect(updated?.status).toBe("killed");
+ });
+
+ it("should cleanup session when cleanup=true", () => {
+ const info = manager.spawn({
+ command: "echo",
+ parentSessionId: "test",
+ });
+
+ manager.kill(info.id, true);
+
+ const sessions = manager.list();
+ expect(sessions).toHaveLength(0);
+ });
+
+ it("should return false for unknown session", () => {
+ const result = manager.kill("pty_nonexistent");
+ expect(result).toBe(false);
+ });
+ });
+
+ describe("cleanupBySession", () => {
+ it("should cleanup all PTYs for a parent session", () => {
+ manager.spawn({ command: "echo", parentSessionId: "session-a" });
+ manager.spawn({ command: "echo", parentSessionId: "session-a" });
+ manager.spawn({ command: "echo", parentSessionId: "session-b" });
+
+ manager.cleanupBySession("session-a");
+
+ const sessions = manager.list();
+ expect(sessions).toHaveLength(1);
+ });
+ });
+});
diff --git a/tests/tools/pty/read.test.ts b/tests/tools/pty/read.test.ts
new file mode 100644
index 0000000..698983f
--- /dev/null
+++ b/tests/tools/pty/read.test.ts
@@ -0,0 +1,90 @@
+// tests/tools/pty/read.test.ts
+import { describe, it, expect, beforeEach, afterEach } from "bun:test";
+import { PTYManager } from "../../../src/tools/pty/manager";
+import { createPtyReadTool } from "../../../src/tools/pty/tools/read";
+import { createPtySpawnTool } from "../../../src/tools/pty/tools/spawn";
+
+describe("pty_read tool", () => {
+ let manager: PTYManager;
+ let pty_read: ReturnType;
+ let pty_spawn: ReturnType;
+
+ beforeEach(() => {
+ manager = new PTYManager();
+ pty_read = createPtyReadTool(manager);
+ pty_spawn = createPtySpawnTool(manager);
+ });
+
+ afterEach(() => {
+ manager.cleanupAll();
+ });
+
+ it("should have correct description", () => {
+ expect(pty_read.description).toContain("output");
+ expect(pty_read.description).toContain("buffer");
+ });
+
+ it("should require id parameter", () => {
+ expect(pty_read.args).toHaveProperty("id");
+ });
+
+ it("should have optional offset, limit, pattern parameters", () => {
+ expect(pty_read.args).toHaveProperty("offset");
+ expect(pty_read.args).toHaveProperty("limit");
+ expect(pty_read.args).toHaveProperty("pattern");
+ });
+
+ it("should throw error for unknown session", async () => {
+ await expect(pty_read.execute({ id: "pty_nonexistent" }, {} as any)).rejects.toThrow("not found");
+ });
+
+ it("should read output from a session", async () => {
+ const spawnResult = await pty_spawn.execute({ command: "echo", args: ["hello world"], description: "Test echo" }, {
+ sessionID: "test",
+ messageID: "msg",
+ } as any);
+
+ const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/);
+ const id = idMatch?.[1];
+ expect(id).toBeDefined();
+
+ // Wait a bit for output
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const result = await pty_read.execute({ id: id! }, {} as any);
+
+ expect(result).toContain("");
+ expect(result).toContain(id!);
+ });
+
+ it("should handle pattern filtering", async () => {
+ const spawnResult = await pty_spawn.execute(
+ { command: "echo", args: ["-e", "line1\\nerror: bad\\nline3"], description: "Test" },
+ { sessionID: "test", messageID: "msg" } as any,
+ );
+
+ const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/);
+ const id = idMatch?.[1];
+
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ const result = await pty_read.execute({ id: id!, pattern: "error" }, {} as any);
+
+ expect(result).toContain("pattern=");
+ // Verify filtering actually works - should contain the matched line
+ expect(result).toContain("error");
+ });
+
+ it("should throw error for invalid regex", async () => {
+ const spawnResult = await pty_spawn.execute({ command: "echo", args: ["test"], description: "Test" }, {
+ sessionID: "test",
+ messageID: "msg",
+ } as any);
+
+ const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/);
+ const id = idMatch?.[1];
+
+ await expect(pty_read.execute({ id: id!, pattern: "[invalid" }, {} as any)).rejects.toThrow("Invalid regex");
+ });
+});
diff --git a/tests/tools/pty/spawn.test.ts b/tests/tools/pty/spawn.test.ts
new file mode 100644
index 0000000..180e753
--- /dev/null
+++ b/tests/tools/pty/spawn.test.ts
@@ -0,0 +1,52 @@
+// tests/tools/pty/spawn.test.ts
+import { describe, it, expect, beforeEach, afterEach } from "bun:test";
+import { PTYManager } from "../../../src/tools/pty/manager";
+import { createPtySpawnTool } from "../../../src/tools/pty/tools/spawn";
+
+describe("pty_spawn tool", () => {
+ let manager: PTYManager;
+ let pty_spawn: ReturnType;
+
+ beforeEach(() => {
+ manager = new PTYManager();
+ pty_spawn = createPtySpawnTool(manager);
+ });
+
+ afterEach(() => {
+ manager.cleanupAll();
+ });
+
+ it("should have correct description", () => {
+ expect(pty_spawn.description).toContain("PTY");
+ expect(pty_spawn.description).toContain("pseudo-terminal");
+ });
+
+ it("should require command parameter", () => {
+ expect(pty_spawn.args).toHaveProperty("command");
+ });
+
+ it("should have optional args, workdir, env, title parameters", () => {
+ expect(pty_spawn.args).toHaveProperty("args");
+ expect(pty_spawn.args).toHaveProperty("workdir");
+ expect(pty_spawn.args).toHaveProperty("env");
+ expect(pty_spawn.args).toHaveProperty("title");
+ });
+
+ it("should spawn a PTY and return formatted output", async () => {
+ // Use sleep to ensure process is still running when we check status
+ const result = await pty_spawn.execute(
+ {
+ command: "sleep",
+ args: ["10"],
+ description: "Test sleep command",
+ },
+ { sessionID: "test-session", messageID: "msg-1" } as any,
+ );
+
+ expect(result).toContain("");
+ expect(result).toContain("");
+ expect(result).toContain("ID: pty_");
+ expect(result).toContain("Command: sleep 10");
+ expect(result).toContain("Status: running");
+ });
+});
diff --git a/tests/tools/pty/write.test.ts b/tests/tools/pty/write.test.ts
new file mode 100644
index 0000000..a88dbce
--- /dev/null
+++ b/tests/tools/pty/write.test.ts
@@ -0,0 +1,66 @@
+// tests/tools/pty/write.test.ts
+import { describe, it, expect, beforeEach, afterEach } from "bun:test";
+import { PTYManager } from "../../../src/tools/pty/manager";
+import { createPtyWriteTool } from "../../../src/tools/pty/tools/write";
+import { createPtySpawnTool } from "../../../src/tools/pty/tools/spawn";
+
+describe("pty_write tool", () => {
+ let manager: PTYManager;
+ let pty_write: ReturnType;
+ let pty_spawn: ReturnType;
+
+ beforeEach(() => {
+ manager = new PTYManager();
+ pty_write = createPtyWriteTool(manager);
+ pty_spawn = createPtySpawnTool(manager);
+ });
+
+ afterEach(() => {
+ manager.cleanupAll();
+ });
+
+ it("should have correct description", () => {
+ expect(pty_write.description).toContain("input");
+ expect(pty_write.description).toContain("PTY");
+ });
+
+ it("should require id and data parameters", () => {
+ expect(pty_write.args).toHaveProperty("id");
+ expect(pty_write.args).toHaveProperty("data");
+ });
+
+ it("should throw error for unknown session", async () => {
+ await expect(pty_write.execute({ id: "pty_nonexistent", data: "test" }, {} as any)).rejects.toThrow("not found");
+ });
+
+ it("should write to a running session", async () => {
+ const spawnResult = await pty_spawn.execute({ command: "cat", description: "Test cat" }, {
+ sessionID: "test",
+ messageID: "msg",
+ } as any);
+
+ const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/);
+ const id = idMatch?.[1];
+ expect(id).toBeDefined();
+
+ const result = await pty_write.execute({ id: id!, data: "hello\\n" }, {} as any);
+
+ expect(result).toContain("Sent");
+ expect(result).toContain(id!);
+ });
+
+ it("should parse escape sequences", async () => {
+ const spawnResult = await pty_spawn.execute({ command: "cat", description: "Test cat" }, {
+ sessionID: "test",
+ messageID: "msg",
+ } as any);
+
+ const idMatch = spawnResult.match(/ID: (pty_[a-f0-9]+)/);
+ const id = idMatch?.[1];
+
+ // Send Ctrl+C
+ const result = await pty_write.execute({ id: id!, data: "\\x03" }, {} as any);
+
+ expect(result).toContain("Sent");
+ });
+});