Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
395 changes: 64 additions & 331 deletions README.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions src/agents/commander.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,23 @@ Just do it - including obvious follow-up actions.
</when-to-use>
</library-research>

<terminal-tools description="Choose the right terminal tool">
<tool name="bash">Synchronous commands. Use for: npm install, git, builds, quick commands that complete.</tool>
<tool name="pty_spawn">Background PTY sessions. Use for: dev servers, watch modes, REPLs, long-running processes.</tool>
<when-to-use>
<use tool="bash">Command completes quickly (npm install, git status, mkdir)</use>
<use tool="pty_spawn">Process runs indefinitely (npm run dev, pytest --watch, python REPL)</use>
<use tool="pty_spawn">Need to send interactive input (Ctrl+C, responding to prompts)</use>
<use tool="pty_spawn">Want to check output later without blocking</use>
</when-to-use>
<pty-workflow>
<step>pty_spawn to start the process</step>
<step>pty_read to check output (use pattern to filter)</step>
<step>pty_write to send input (\\n for Enter, \\x03 for Ctrl+C)</step>
<step>pty_kill when done (cleanup=true to remove)</step>
</pty-workflow>
</terminal-tools>

<tracking>
<rule>Use TodoWrite to track what you're doing</rule>
<rule>Never discard tasks without explicit approval</rule>
Expand Down
18 changes: 18 additions & 0 deletions src/agents/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@ You have access to background task management tools:
- background_list: List all background tasks and their status
</background-tools>

<pty-tools description="For background bash processes">
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)
</pty-tools>

<workflow pattern="fire-and-check">
<step>Parse plan to extract individual tasks</step>
<step>Analyze task dependencies to build execution graph</step>
Expand Down
7 changes: 7 additions & 0 deletions src/agents/implementer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ Execute the plan. Write code. Verify.
<step>Report results</step>
</process>

<terminal-tools>
<bash>Use for synchronous commands that complete (npm install, git, builds)</bash>
<pty>Use for background processes (dev servers, watch modes, REPLs)</pty>
<rule>If plan says "start dev server" or "run in background", use pty_spawn</rule>
<rule>If plan says "run command" or "install", use bash</rule>
</terminal-tools>

<before-each-change>
<check>Verify file exists where expected</check>
<check>Verify code structure matches plan assumptions</check>
Expand Down
6 changes: 6 additions & 0 deletions src/agents/reviewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ Check correctness and style. Be specific. Run code, don't just read.
<step>Report with precise references</step>
</process>

<terminal-verification>
<rule>If implementation includes PTY usage, verify sessions are properly cleaned up</rule>
<rule>If tests require a running server, check that pty_spawn was used appropriately</rule>
<rule>Check that long-running processes use PTY, not blocking bash</rule>
</terminal-verification>

<output-format>
<template>
## Review: [Component]
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/auto-clear-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
11 changes: 10 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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: {
Expand All @@ -109,6 +116,7 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
look_at,
artifact_search,
...backgroundTaskTools,
...ptyTools,
},

config: async (config) => {
Expand Down Expand Up @@ -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);
}
}

Expand Down
49 changes: 49 additions & 0 deletions src/tools/pty/buffer.ts
Original file line number Diff line number Diff line change
@@ -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 = [];
}
}
34 changes: 34 additions & 0 deletions src/tools/pty/index.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
159 changes: 159 additions & 0 deletions src/tools/pty/manager.ts
Original file line number Diff line number Diff line change
@@ -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<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)}`);

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,
};
}
}
Loading