@agi-cli/sdk 0.1.88 → 0.1.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agi-cli/sdk",
3
- "version": "0.1.88",
3
+ "version": "0.1.90",
4
4
  "description": "AI agent SDK for building intelligent assistants - tree-shakable and comprehensive",
5
5
  "author": "ntishxyz",
6
6
  "license": "MIT",
@@ -65,6 +65,10 @@
65
65
  "import": "./src/core/src/tools/builtin/websearch.ts",
66
66
  "types": "./src/core/src/tools/builtin/websearch.ts"
67
67
  },
68
+ "./tools/builtin/terminal": {
69
+ "import": "./src/core/src/tools/builtin/terminal.ts",
70
+ "types": "./src/core/src/tools/builtin/terminal.ts"
71
+ },
68
72
  "./tools/error": {
69
73
  "import": "./src/core/src/tools/error.ts",
70
74
  "types": "./src/core/src/tools/error.ts"
@@ -82,18 +86,19 @@
82
86
  "typecheck": "tsc --noEmit"
83
87
  },
84
88
  "dependencies": {
85
- "@openauthjs/openauth": "^0.4.3",
86
- "opencode-anthropic-auth": "^0.0.2",
87
- "ai": "^5.0.43",
88
89
  "@ai-sdk/anthropic": "^2.0.16",
89
- "@ai-sdk/openai": "^2.0.30",
90
90
  "@ai-sdk/google": "^2.0.14",
91
- "@openrouter/ai-sdk-provider": "^1.2.0",
91
+ "@ai-sdk/openai": "^2.0.30",
92
92
  "@ai-sdk/openai-compatible": "^1.0.18",
93
- "hono": "^4.9.7",
94
- "zod": "^4.1.8",
93
+ "@openauthjs/openauth": "^0.4.3",
94
+ "@openrouter/ai-sdk-provider": "^1.2.0",
95
+ "ai": "^5.0.43",
96
+ "bun-pty": "^0.3.2",
97
+ "diff": "^8.0.2",
95
98
  "fast-glob": "^3.3.2",
96
- "diff": "^8.0.2"
99
+ "hono": "^4.9.7",
100
+ "opencode-anthropic-auth": "^0.0.2",
101
+ "zod": "^4.1.8"
97
102
  },
98
103
  "devDependencies": {
99
104
  "@types/bun": "latest",
@@ -31,6 +31,7 @@ export type { ProviderId, ModelInfo } from '../../types/src/index.ts';
31
31
  // =======================
32
32
  export { discoverProjectTools } from './tools/loader';
33
33
  export type { DiscoveredTool } from './tools/loader';
34
+ export { setTerminalManager, getTerminalManager } from './tools/loader';
34
35
 
35
36
  // Tool error handling utilities
36
37
  export {
@@ -48,6 +49,19 @@ export type {
48
49
  // Re-export builtin tools for direct access
49
50
  export { buildFsTools } from './tools/builtin/fs/index';
50
51
  export { buildGitTools } from './tools/builtin/git';
52
+ export { buildTerminalTool } from './tools/builtin/terminal';
53
+
54
+ // =======================
55
+ // Terminals
56
+ // =======================
57
+ export { TerminalManager } from './terminals/index';
58
+ export type {
59
+ Terminal,
60
+ TerminalOptions,
61
+ TerminalStatus,
62
+ TerminalCreator,
63
+ CreateTerminalOptions,
64
+ } from './terminals/index';
51
65
 
52
66
  // =======================
53
67
  // Streaming & Artifacts
@@ -0,0 +1,2 @@
1
+ export { spawn } from 'bun-pty';
2
+ export type { IPty, IPtyForkOptions as PtyOptions, IExitEvent } from 'bun-pty';
@@ -0,0 +1,30 @@
1
+ export class CircularBuffer {
2
+ private buffer: string[] = [];
3
+ private maxSize: number;
4
+
5
+ constructor(maxSize = 500) {
6
+ this.maxSize = maxSize;
7
+ }
8
+
9
+ push(line: string): void {
10
+ this.buffer.push(line);
11
+ if (this.buffer.length > this.maxSize) {
12
+ this.buffer.shift();
13
+ }
14
+ }
15
+
16
+ read(lines?: number): string[] {
17
+ if (lines === undefined) {
18
+ return [...this.buffer];
19
+ }
20
+ return this.buffer.slice(-lines);
21
+ }
22
+
23
+ clear(): void {
24
+ this.buffer = [];
25
+ }
26
+
27
+ get length(): number {
28
+ return this.buffer.length;
29
+ }
30
+ }
@@ -0,0 +1,8 @@
1
+ export { TerminalManager, type CreateTerminalOptions } from './manager.ts';
2
+ export {
3
+ Terminal,
4
+ type TerminalOptions,
5
+ type TerminalStatus,
6
+ type TerminalCreator,
7
+ } from './terminal.ts';
8
+ export { CircularBuffer } from './circular-buffer.ts';
@@ -0,0 +1,154 @@
1
+ import { spawn as spawnPty } from './bun-pty.ts';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { Terminal } from './terminal.ts';
4
+ import type { PtyOptions } from './bun-pty.ts';
5
+
6
+ const MAX_TERMINALS = 10;
7
+ const CLEANUP_DELAY_MS = 5 * 60 * 1000;
8
+
9
+ export interface CreateTerminalOptions {
10
+ command: string;
11
+ args?: string[];
12
+ cwd: string;
13
+ purpose: string;
14
+ createdBy: 'user' | 'llm';
15
+ title?: string;
16
+ }
17
+
18
+ export class TerminalManager {
19
+ private terminals = new Map<string, Terminal>();
20
+ private cleanupTimers = new Map<string, NodeJS.Timeout>();
21
+
22
+ constructor() {
23
+ process.on('SIGTERM', () => this.killAll());
24
+ process.on('SIGINT', () => this.killAll());
25
+ }
26
+
27
+ create(options: CreateTerminalOptions): Terminal {
28
+ if (this.terminals.size >= MAX_TERMINALS) {
29
+ throw new Error(`Maximum ${MAX_TERMINALS} terminals reached`);
30
+ }
31
+
32
+ const id = this.generateId();
33
+
34
+ try {
35
+ console.log('[TerminalManager] Creating terminal:', {
36
+ id,
37
+ command: options.command,
38
+ args: options.args,
39
+ cwd: options.cwd,
40
+ purpose: options.purpose,
41
+ });
42
+
43
+ const ptyOptions: PtyOptions = {
44
+ name: 'xterm-256color',
45
+ cols: 80,
46
+ rows: 30,
47
+ cwd: options.cwd,
48
+ env: process.env as Record<string, string>,
49
+ };
50
+
51
+ const pty = spawnPty(options.command, options.args || [], ptyOptions);
52
+
53
+ console.log('[TerminalManager] PTY created successfully:', pty.pid);
54
+
55
+ const terminal = new Terminal(id, pty, options);
56
+
57
+ terminal.onExit((_exitCode) => {
58
+ const timer = setTimeout(() => {
59
+ this.delete(id);
60
+ }, CLEANUP_DELAY_MS);
61
+
62
+ this.cleanupTimers.set(id, timer);
63
+ });
64
+
65
+ this.terminals.set(id, terminal);
66
+
67
+ console.log('[TerminalManager] Terminal added to map');
68
+
69
+ return terminal;
70
+ } catch (error) {
71
+ console.error('[TerminalManager] Failed to create terminal:', error);
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ get(id: string): Terminal | undefined {
77
+ return this.terminals.get(id);
78
+ }
79
+
80
+ list(): Terminal[] {
81
+ return Array.from(this.terminals.values());
82
+ }
83
+
84
+ async kill(id: string): Promise<void> {
85
+ const terminal = this.terminals.get(id);
86
+ if (!terminal) {
87
+ throw new Error(`Terminal ${id} not found`);
88
+ }
89
+
90
+ terminal.kill();
91
+
92
+ await new Promise<void>((resolve) => {
93
+ if (terminal.status === 'exited') {
94
+ resolve();
95
+ return;
96
+ }
97
+
98
+ const exitHandler = () => {
99
+ terminal.removeExitListener(exitHandler);
100
+ resolve();
101
+ };
102
+
103
+ terminal.onExit(exitHandler);
104
+
105
+ setTimeout(() => {
106
+ terminal.removeExitListener(exitHandler);
107
+ resolve();
108
+ }, 5000);
109
+ });
110
+
111
+ this.delete(id);
112
+ }
113
+
114
+ async killAll(): Promise<void> {
115
+ const killPromises = Array.from(this.terminals.keys()).map((id) =>
116
+ this.kill(id).catch((err) =>
117
+ console.error(`Failed to kill terminal ${id}:`, err),
118
+ ),
119
+ );
120
+
121
+ await Promise.all(killPromises);
122
+ }
123
+
124
+ delete(id: string): boolean {
125
+ const timer = this.cleanupTimers.get(id);
126
+ if (timer) {
127
+ clearTimeout(timer);
128
+ this.cleanupTimers.delete(id);
129
+ }
130
+
131
+ return this.terminals.delete(id);
132
+ }
133
+
134
+ private generateId(): string {
135
+ return `term-${randomBytes(8).toString('hex')}`;
136
+ }
137
+
138
+ getContext(): string {
139
+ const terminals = this.list();
140
+
141
+ if (terminals.length === 0) {
142
+ return '';
143
+ }
144
+
145
+ const summary = terminals
146
+ .map(
147
+ (t) =>
148
+ `- [${t.id}] ${t.purpose} (${t.status}, ${t.createdBy}, pid: ${t.pid})`,
149
+ )
150
+ .join('\n');
151
+
152
+ return `\n\n## Active Terminals (${terminals.length}):\n${summary}\n\nYou can read from any terminal using the 'terminal' tool with operation: 'read'.`;
153
+ }
154
+ }
@@ -0,0 +1,124 @@
1
+ import type { IPty } from './bun-pty.ts';
2
+ import { EventEmitter } from 'node:events';
3
+ import { CircularBuffer } from './circular-buffer.ts';
4
+
5
+ export type TerminalStatus = 'running' | 'exited';
6
+ export type TerminalCreator = 'user' | 'llm';
7
+
8
+ export interface TerminalOptions {
9
+ command: string;
10
+ args?: string[];
11
+ cwd: string;
12
+ purpose: string;
13
+ createdBy: TerminalCreator;
14
+ title?: string;
15
+ }
16
+
17
+ export class Terminal {
18
+ readonly id: string;
19
+ readonly pty: IPty;
20
+ readonly command: string;
21
+ readonly args: string[];
22
+ readonly cwd: string;
23
+ readonly purpose: string;
24
+ readonly createdBy: TerminalCreator;
25
+ readonly createdAt: Date;
26
+
27
+ private buffer: CircularBuffer;
28
+ private _status: TerminalStatus = 'running';
29
+ private _exitCode?: number;
30
+ private _title?: string;
31
+ private dataEmitter = new EventEmitter();
32
+ private exitEmitter = new EventEmitter();
33
+
34
+ constructor(id: string, pty: IPty, options: TerminalOptions) {
35
+ this.id = id;
36
+ this.pty = pty;
37
+ this.command = options.command;
38
+ this.args = options.args || [];
39
+ this.cwd = options.cwd;
40
+ this.purpose = options.purpose;
41
+ this.createdBy = options.createdBy;
42
+ this._title = options.title;
43
+ this.createdAt = new Date();
44
+ this.buffer = new CircularBuffer(500);
45
+
46
+ this.pty.onData((data) => {
47
+ // Store in buffer for history
48
+ this.buffer.push(data);
49
+ // Emit raw data - terminals need control chars, ANSI codes, etc.
50
+ this.dataEmitter.emit('data', data);
51
+ });
52
+
53
+ this.pty.onExit(({ exitCode }) => {
54
+ this._status = 'exited';
55
+ this._exitCode = exitCode;
56
+ this.exitEmitter.emit('exit', exitCode);
57
+ });
58
+ }
59
+
60
+ get pid(): number {
61
+ return this.pty.pid;
62
+ }
63
+
64
+ get status(): TerminalStatus {
65
+ return this._status;
66
+ }
67
+
68
+ get exitCode(): number | undefined {
69
+ return this._exitCode;
70
+ }
71
+
72
+ get title(): string {
73
+ return this._title || this.purpose;
74
+ }
75
+
76
+ set title(value: string) {
77
+ this._title = value;
78
+ }
79
+
80
+ read(lines?: number): string[] {
81
+ return this.buffer.read(lines);
82
+ }
83
+
84
+ write(input: string): void {
85
+ this.pty.write(input);
86
+ }
87
+
88
+ kill(signal?: string): void {
89
+ this.pty.kill(signal);
90
+ }
91
+
92
+ onData(callback: (line: string) => void): void {
93
+ this.dataEmitter.on('data', callback);
94
+ }
95
+
96
+ onExit(callback: (exitCode: number) => void): void {
97
+ this.exitEmitter.on('exit', callback);
98
+ }
99
+
100
+ removeDataListener(callback: (line: string) => void): void {
101
+ this.dataEmitter.off('data', callback);
102
+ }
103
+
104
+ removeExitListener(callback: (exitCode: number) => void): void {
105
+ this.exitEmitter.off('exit', callback);
106
+ }
107
+
108
+ toJSON() {
109
+ return {
110
+ id: this.id,
111
+ pid: this.pid,
112
+ command: this.command,
113
+ args: this.args,
114
+ cwd: this.cwd,
115
+ purpose: this.purpose,
116
+ createdBy: this.createdBy,
117
+ title: this.title,
118
+ status: this.status,
119
+ exitCode: this.exitCode,
120
+ createdAt: this.createdAt,
121
+ uptime: Date.now() - this.createdAt.getTime(),
122
+ };
123
+ }
124
+ }
@@ -259,6 +259,12 @@ function applyHunkToLines(
259
259
  .map((line) => line.content);
260
260
 
261
261
  const removals = hunk.lines.filter((line) => line.kind === 'remove');
262
+ const additions = hunk.lines
263
+ .filter((line) => line.kind === 'add')
264
+ .map((line) => line.content);
265
+ const contextLines = hunk.lines
266
+ .filter((line) => line.kind === 'context')
267
+ .map((line) => line.content);
262
268
 
263
269
  const hasExpected = expected.length > 0;
264
270
  const initialHint =
@@ -0,0 +1,299 @@
1
+ import { tool, type Tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import DESCRIPTION from './terminal.txt' with { type: 'text' };
4
+ import { createToolError } from '../error.ts';
5
+ import type { TerminalManager } from '../../terminals/index.ts';
6
+ import type { TerminalStatus } from '../../terminals/terminal.ts';
7
+ import { normalizeTerminalLine } from '../../utils/ansi.ts';
8
+
9
+ function shellQuote(segment: string): string {
10
+ if (/^[a-zA-Z0-9._-]+$/.test(segment)) {
11
+ return segment;
12
+ }
13
+ return `'${segment.replace(/'/g, `'\\''`)}'`;
14
+ }
15
+
16
+ function formatShellCommand(parts: string[]): string {
17
+ return parts.map(shellQuote).join(' ');
18
+ }
19
+
20
+ function normalizePath(p: string) {
21
+ const parts = p.replace(/\\/g, '/').split('/');
22
+ const stack: string[] = [];
23
+ for (const part of parts) {
24
+ if (!part || part === '.') continue;
25
+ if (part === '..') stack.pop();
26
+ else stack.push(part);
27
+ }
28
+ return `/${stack.join('/')}`;
29
+ }
30
+
31
+ function resolveSafePath(projectRoot: string, p: string) {
32
+ const root = normalizePath(projectRoot);
33
+ const abs = normalizePath(`${root}/${p || '.'}`);
34
+ if (!(abs === root || abs.startsWith(`${root}/`))) {
35
+ throw new Error(`cwd escapes project root: ${p}`);
36
+ }
37
+ return abs;
38
+ }
39
+
40
+ export function buildTerminalTool(
41
+ projectRoot: string,
42
+ terminalManager: TerminalManager,
43
+ ): {
44
+ name: string;
45
+ tool: Tool;
46
+ } {
47
+ const terminal = tool({
48
+ description: DESCRIPTION,
49
+ inputSchema: z.object({
50
+ operation: z
51
+ .enum(['start', 'read', 'write', 'interrupt', 'list', 'kill'])
52
+ .describe('Operation to perform'),
53
+
54
+ command: z.string().optional().describe('For start: Command to run'),
55
+ args: z
56
+ .array(z.string())
57
+ .optional()
58
+ .describe('For start: Command arguments'),
59
+ shell: z
60
+ .boolean()
61
+ .default(true)
62
+ .describe(
63
+ 'For start: Launch inside interactive shell and optionally run command',
64
+ ),
65
+ purpose: z
66
+ .string()
67
+ .optional()
68
+ .describe('For start: Description of what this terminal is for'),
69
+ title: z
70
+ .string()
71
+ .optional()
72
+ .describe(
73
+ 'For start: Short name shown in the UI (defaults to purpose)',
74
+ ),
75
+ cwd: z
76
+ .string()
77
+ .default('.')
78
+ .describe('For start: Working directory relative to project root'),
79
+
80
+ terminalId: z
81
+ .string()
82
+ .optional()
83
+ .describe('For read/write/kill: Terminal ID'),
84
+
85
+ lines: z
86
+ .number()
87
+ .default(100)
88
+ .optional()
89
+ .describe('For read: Number of lines to read from end'),
90
+ raw: z
91
+ .boolean()
92
+ .optional()
93
+ .describe(
94
+ 'For read: Include raw output with ANSI escape sequences (default false)',
95
+ ),
96
+
97
+ input: z
98
+ .string()
99
+ .optional()
100
+ .describe('For write: String to write to stdin'),
101
+ }),
102
+ execute: async (params) => {
103
+ try {
104
+ const { operation } = params;
105
+
106
+ switch (operation) {
107
+ case 'start': {
108
+ const runInShell = params.shell;
109
+
110
+ if (!params.command && !runInShell) {
111
+ return createToolError('command is required for start operation');
112
+ }
113
+ if (!params.purpose) {
114
+ return createToolError('purpose is required for start operation');
115
+ }
116
+
117
+ const cwd = resolveSafePath(projectRoot, params.cwd);
118
+
119
+ const shellPath = process.env.SHELL || '/bin/sh';
120
+
121
+ let command = params.command ?? shellPath;
122
+ let args = params.args ?? [];
123
+ let initialCommand: string | null = null;
124
+
125
+ if (runInShell) {
126
+ command = shellPath;
127
+ args = ['-i'];
128
+ const providedCommand = params.command;
129
+ const providedArgs = params.args ?? [];
130
+
131
+ if (providedCommand || providedArgs.length > 0) {
132
+ if (providedArgs.length === 0 && providedCommand) {
133
+ // Command already contains spaces; treat as full shell snippet
134
+ initialCommand = providedCommand;
135
+ } else {
136
+ const commandParts = [
137
+ providedCommand,
138
+ ...providedArgs,
139
+ ].filter((part): part is string => Boolean(part));
140
+ if (commandParts.length > 0) {
141
+ initialCommand = formatShellCommand(commandParts);
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ const term = terminalManager.create({
148
+ command,
149
+ args,
150
+ cwd,
151
+ purpose: params.purpose,
152
+ title: params.title,
153
+ createdBy: 'llm',
154
+ });
155
+
156
+ if (initialCommand) {
157
+ queueMicrotask(() => {
158
+ term.write(`${initialCommand}\n`);
159
+ });
160
+ }
161
+
162
+ return {
163
+ ok: true,
164
+ terminalId: term.id,
165
+ pid: term.pid,
166
+ purpose: term.purpose,
167
+ command: params.command ?? command,
168
+ args: params.args || [],
169
+ shell: runInShell,
170
+ title: term.title,
171
+ message: `Started: ${params.command ?? command}${params.args ? ` ${params.args.join(' ')}` : ''}`,
172
+ };
173
+ }
174
+
175
+ case 'read': {
176
+ if (!params.terminalId) {
177
+ return createToolError(
178
+ 'terminalId is required for read operation',
179
+ );
180
+ }
181
+
182
+ const term = terminalManager.get(params.terminalId);
183
+ if (!term) {
184
+ return createToolError(`Terminal ${params.terminalId} not found`);
185
+ }
186
+
187
+ const output = term.read(params.lines);
188
+ const normalized = output.map(normalizeTerminalLine);
189
+ const text = normalized.join('\n').replace(/\u0000/g, '');
190
+
191
+ const response: {
192
+ ok: true;
193
+ terminalId: string;
194
+ output: string[];
195
+ status: TerminalStatus;
196
+ exitCode: number | undefined;
197
+ lines: number;
198
+ text: string;
199
+ rawOutput?: string[];
200
+ } = {
201
+ ok: true,
202
+ terminalId: term.id,
203
+ output: normalized,
204
+ status: term.status,
205
+ exitCode: term.exitCode,
206
+ lines: normalized.length,
207
+ text,
208
+ };
209
+
210
+ if (params.raw) {
211
+ response.rawOutput = output;
212
+ }
213
+
214
+ return response;
215
+ }
216
+
217
+ case 'write': {
218
+ if (!params.terminalId) {
219
+ return createToolError(
220
+ 'terminalId is required for write operation',
221
+ );
222
+ }
223
+ if (!params.input) {
224
+ return createToolError('input is required for write operation');
225
+ }
226
+
227
+ const term = terminalManager.get(params.terminalId);
228
+ if (!term) {
229
+ return createToolError(`Terminal ${params.terminalId} not found`);
230
+ }
231
+
232
+ term.write(params.input);
233
+
234
+ return {
235
+ ok: true,
236
+ terminalId: term.id,
237
+ message: `Wrote ${params.input.length} characters to terminal`,
238
+ };
239
+ }
240
+
241
+ case 'interrupt': {
242
+ if (!params.terminalId) {
243
+ return createToolError(
244
+ 'terminalId is required for interrupt operation',
245
+ );
246
+ }
247
+
248
+ const term = terminalManager.get(params.terminalId);
249
+ if (!term) {
250
+ return createToolError(`Terminal ${params.terminalId} not found`);
251
+ }
252
+
253
+ term.write('\u0003');
254
+
255
+ return {
256
+ ok: true,
257
+ terminalId: term.id,
258
+ message: 'Sent SIGINT (Ctrl+C) to terminal',
259
+ };
260
+ }
261
+
262
+ case 'list': {
263
+ const terminals = terminalManager.list();
264
+
265
+ return {
266
+ ok: true,
267
+ terminals: terminals.map((t) => t.toJSON()),
268
+ count: terminals.length,
269
+ };
270
+ }
271
+
272
+ case 'kill': {
273
+ if (!params.terminalId) {
274
+ return createToolError(
275
+ 'terminalId is required for kill operation',
276
+ );
277
+ }
278
+
279
+ await terminalManager.kill(params.terminalId);
280
+
281
+ return {
282
+ ok: true,
283
+ terminalId: params.terminalId,
284
+ message: `Killed terminal ${params.terminalId}`,
285
+ };
286
+ }
287
+
288
+ default:
289
+ return createToolError(`Unknown operation: ${operation}`);
290
+ }
291
+ } catch (error) {
292
+ const message = error instanceof Error ? error.message : String(error);
293
+ return createToolError(`Terminal operation failed: ${message}`);
294
+ }
295
+ },
296
+ });
297
+
298
+ return { name: 'terminal', tool: terminal };
299
+ }
@@ -0,0 +1,93 @@
1
+ - Manage persistent terminals for long-running processes (dev servers, watchers, build processes)
2
+ - Returns terminal information and output
3
+ - Supports creating, reading, writing, listing, and killing terminals
4
+
5
+ ## Operations
6
+
7
+ ### start
8
+ - Spawns a new persistent terminal (interactive shell by default)
9
+ - Returns terminal ID for future operations
10
+ - Use for processes that need to stay alive (dev servers, watchers, logs)
11
+ - Before starting, call `terminal(operation: "list")` to see if a matching service is already running
12
+ - Provide a clear `purpose` or `title` (e.g. "web dev server port 9100") so humans can recognize it
13
+ - Parameters:
14
+ - command (optional when `shell` is true): Command to run
15
+ - purpose (required): Description of what this terminal is for
16
+ - title (optional): Short UI label shown beside the terminal
17
+ - cwd (optional): Working directory relative to project root (default: '.')
18
+ - args (optional): Array of command arguments
19
+ - shell (optional, default: true): Launch an interactive shell and run the command via stdin. Set to `false` to spawn the process directly.
20
+
21
+ ### read
22
+ - Read output from a terminal's buffer (last N lines)
23
+ - Strips ANSI escape codes by default so responses are easy to read
24
+ - Parameters:
25
+ - terminalId (required): Terminal ID from start operation
26
+ - lines (optional): Number of lines to read from end (default: 100)
27
+ - raw (optional): Include `rawOutput` array with ANSI escape sequences (default: false)
28
+ - Returns sanitized output lines, combined `text`, status, and exit code
29
+
30
+ ### write
31
+ - Write input to a terminal's stdin
32
+ - Useful for interactive commands or sending signals
33
+ - Parameters:
34
+ - terminalId (required): Terminal ID
35
+ - input (required): String to write to stdin
36
+
37
+ ### interrupt
38
+ - Sends SIGINT (Ctrl+C) to the terminal without closing the PTY
39
+ - Useful for stopping dev servers or watchers while keeping the shell alive
40
+ - Parameters:
41
+ - terminalId (required): Terminal ID
42
+
43
+ ### list
44
+ - List all active terminals
45
+ - Returns array of terminal metadata (id, purpose, status, pid, uptime)
46
+ - No parameters required
47
+
48
+ ### kill
49
+ - Kill a running terminal
50
+ - Sends SIGTERM by default
51
+ - Parameters:
52
+ - terminalId (required): Terminal ID to kill
53
+
54
+ ## When to Use Terminal vs Bash
55
+
56
+ ### Use terminal for:
57
+ - Dev servers: npm run dev, bun dev
58
+ - File watchers: bun test --watch, nodemon
59
+ - Build watchers: bun build --watch
60
+ - Log tailing: tail -f logs/app.log
61
+ - Background services: docker compose up
62
+ - Any process that needs to stay alive and produce continuous output
63
+
64
+ ### Use bash for:
65
+ - Status checks: git status, ls, ps
66
+ - One-off commands: mkdir, rm, curl
67
+ - Quick scripts: bun run build, git commit
68
+ - File operations: cat, grep, sed
69
+ - Short-lived commands with immediate output
70
+
71
+ ## Example Workflow
72
+
73
+ 1. Start dev server:
74
+ terminal(operation: "start", command: "npm", args: ["run", "dev"], purpose: "dev server")
75
+ → Returns { terminalId: "term-abc123", pid: 12345 }
76
+
77
+ 2. Later, check for errors:
78
+ terminal(operation: "read", terminalId: "term-abc123", lines: 50)
79
+ → Returns last 50 lines of output
80
+
81
+ 3. Kill when done:
82
+ terminal(operation: "kill", terminalId: "term-abc123")
83
+
84
+ ## Notes
85
+
86
+ - Terminals persist across multiple LLM turns (unlike bash commands)
87
+ - Maximum 10 terminals per session
88
+ - Exited terminals auto-cleanup after 5 minutes
89
+ - Output is buffered (last 500 lines kept)
90
+ - Both user-created and LLM-created terminals are visible
91
+ - You can read from user-created terminals to understand context
92
+ - Prefer `read` over `start` when you only need status — avoid duplicating services that already exist
93
+ - Mention running terminals (purpose, command, port) in your responses so humans know what is active
@@ -12,6 +12,8 @@ import { buildApplyPatchTool } from './builtin/patch.ts';
12
12
  import { updatePlanTool } from './builtin/plan.ts';
13
13
  import { editTool } from './builtin/edit.ts';
14
14
  import { buildWebSearchTool } from './builtin/websearch.ts';
15
+ import { buildTerminalTool } from './builtin/terminal.ts';
16
+ import type { TerminalManager } from '../terminals/index.ts';
15
17
  import fg from 'fast-glob';
16
18
  import { dirname, isAbsolute, join } from 'node:path';
17
19
  import { pathToFileURL } from 'node:url';
@@ -90,6 +92,16 @@ type FsHelpers = {
90
92
 
91
93
  const pluginPatterns = ['tools/*/tool.js', 'tools/*/tool.mjs'];
92
94
 
95
+ let globalTerminalManager: TerminalManager | null = null;
96
+
97
+ export function setTerminalManager(manager: TerminalManager): void {
98
+ globalTerminalManager = manager;
99
+ }
100
+
101
+ export function getTerminalManager(): TerminalManager | null {
102
+ return globalTerminalManager;
103
+ }
104
+
93
105
  export async function discoverProjectTools(
94
106
  projectRoot: string,
95
107
  globalConfigDir?: string,
@@ -120,6 +132,11 @@ export async function discoverProjectTools(
120
132
  // Web search
121
133
  const ws = buildWebSearchTool();
122
134
  tools.set(ws.name, ws.tool);
135
+ // Terminal (if manager is available)
136
+ if (globalTerminalManager) {
137
+ const term = buildTerminalTool(projectRoot, globalTerminalManager);
138
+ tools.set(term.name, term.tool);
139
+ }
123
140
 
124
141
  async function loadFromBase(base: string | null | undefined) {
125
142
  if (!base) return;
@@ -0,0 +1,27 @@
1
+ export function stripAnsi(input: string): string {
2
+ let result = '';
3
+ for (let i = 0; i < input.length; i += 1) {
4
+ const ch = input[i];
5
+ if (ch === '\u001B' || ch === '\u009B') {
6
+ // Skip CSI sequences until we hit a terminating byte (A-Z or a-z)
7
+ i += 1;
8
+ while (i < input.length) {
9
+ const code = input[i];
10
+ if (
11
+ code &&
12
+ ((code >= '@' && code <= 'Z') || (code >= 'a' && code <= 'z'))
13
+ ) {
14
+ break;
15
+ }
16
+ i += 1;
17
+ }
18
+ } else {
19
+ result += ch;
20
+ }
21
+ }
22
+ return result;
23
+ }
24
+
25
+ export function normalizeTerminalLine(line: string): string {
26
+ return stripAnsi(line).replace(/\r/g, '');
27
+ }
package/src/index.ts CHANGED
@@ -120,9 +120,20 @@ export type { ProviderName, ModelConfig } from './core/src/index.ts';
120
120
  // Tools
121
121
  export { discoverProjectTools } from './core/src/index.ts';
122
122
  export type { DiscoveredTool } from './core/src/index.ts';
123
+ export { setTerminalManager, getTerminalManager } from './core/src/index.ts';
123
124
  export { buildFsTools } from './core/src/index.ts';
124
125
  export { buildGitTools } from './core/src/index.ts';
125
126
 
127
+ // Terminals
128
+ export { TerminalManager } from './core/src/index.ts';
129
+ export type {
130
+ Terminal,
131
+ TerminalOptions,
132
+ TerminalStatus,
133
+ TerminalCreator,
134
+ CreateTerminalOptions,
135
+ } from './core/src/index.ts';
136
+
126
137
  // Streaming & Artifacts
127
138
  export {
128
139
  createFileDiffArtifact,
@@ -4,6 +4,14 @@ You help with coding and build tasks.
4
4
  - Keep tool inputs short; avoid long prose inside tool parameters.
5
5
  - Stream your answer, then call finish.
6
6
 
7
+ ## Terminal Tool Workflow
8
+
9
+ - List existing terminals before starting new ones to avoid duplicate dev servers or watchers.
10
+ - Reuse running services when possible; read their output instead of spawning another copy.
11
+ - When starting a terminal, give it a descriptive purpose/title (e.g. "web dev server 9100" or "bun test --watch") and prefer `terminal(...)` over `bash(...)` for long-lived tasks.
12
+ - Use `terminal(operation: "write", input: "\u0003")` or `terminal(operation: "interrupt")` to stop a process before resorting to `kill`.
13
+ - Summarize active terminals (purpose, key command, port) in your updates so collaborators know what's running.
14
+
7
15
  ## File Editing Best Practices
8
16
 
9
17
  **Using the `apply_patch` Tool** (Recommended):
@@ -29,9 +37,45 @@ You help with coding and build tasks.
29
37
  actual line from file ← Context (space prefix) - REQUIRED
30
38
  -line to remove ← Remove this line
31
39
  +line to add ← Add this line
32
- more context ← More context (space prefix)
40
+ more context ← More context (space prefix)
33
41
  ```
34
42
 
43
+ ## ⚠️ Why Patches Fail (Common Mistakes)
44
+
45
+ **Mistake 1: Patching from Memory** (Most Common)
46
+ - ❌ Creating patches based on what you remember from earlier
47
+ - ✅ ALWAYS read the file FIRST in this same turn, then create patch
48
+
49
+ **Mistake 2: Context Lines Don't Match File**
50
+ - ❌ Guessing or inventing what context lines look like
51
+ - ✅ Copy context lines EXACTLY character-for-character from the file you just read
52
+ - Context lines (space prefix) must exist in the actual file
53
+
54
+ **Mistake 3: Wrong Indentation**
55
+ - ❌ File uses 2 spaces; patch uses tabs or 4 spaces
56
+ - ✅ Match indentation exactly: if file uses spaces, patch uses spaces (same count)
57
+
58
+ **Mistake 4: Missing Markers**
59
+ - ❌ Forgot `*** End Patch` or malformed `*** Begin Patch`
60
+ - ✅ Always wrap: `*** Begin Patch` ... hunks ... `*** End Patch`
61
+
62
+ **Mistake 5: Hallucinated Code**
63
+ - ❌ Adding lines that "should" be there but aren't
64
+ - ✅ Only use lines that actually exist in the file
65
+
66
+ **Success Formula:**
67
+ 1. Read file with `read` tool
68
+ 2. Note exact indentation (spaces/tabs), line content
69
+ 3. Extract 2-3 context lines before/after your change
70
+ 4. Copy them EXACTLY into patch with space prefix
71
+ 5. Add your `-old` and `+new` lines
72
+ 6. Verify markers: `*** Begin Patch` and `*** End Patch`
73
+
74
+ **When Patch Fails:**
75
+ - Error means context didn't match or file changed
76
+ - Solution: Read the file AGAIN, check character-for-character
77
+ - If still failing repeatedly, use `edit` tool instead
78
+
35
79
  **Using the `edit` Tool** (Alternative):
36
80
  - Specify the file path and a list of operations
37
81
  - Operations are applied sequentially to the latest file state
@@ -15,7 +15,8 @@ You MUST call the `finish` tool at the end of every response to signal completio
15
15
  **IMPORTANT**: Do NOT call `finish` before streaming your response. Always stream your message first, then call `finish`. If you forget to call `finish`, the system will hang and not complete properly.
16
16
 
17
17
  File Editing Best Practices:
18
- - ALWAYS read a file immediately before using apply_patch on it - never patch from memory
18
+ - ⚠️ CRITICAL: ALWAYS read a file immediately before using apply_patch - never patch from memory
19
+ - Read the file in THIS turn, not from previous context or memory
19
20
  - When making multiple edits to the same file, combine them into a single edit operation with multiple ops
20
21
  - Each edit operation re-reads the file, so ops within a single edit call are applied sequentially to the latest content
21
22
  - If you need to make edits based on previous edits, ensure they're in the same edit call or re-read the file between calls
@@ -210,6 +210,28 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
210
210
 
211
211
  IMPORTANT: Always use the update_plan tool to plan and track tasks throughout the conversation.
212
212
 
213
+ # Apply Patch Tool - Critical Guidelines for Claude
214
+
215
+ **⚠️ Claude-Specific Patch Failures:**
216
+
217
+ You (Claude/Sonnet) generally excel at using patches, but even you can fail when:
218
+ - Relying on memory from earlier in the conversation instead of fresh file reads
219
+ - Making assumptions about unchanged file state between operations
220
+ - Mixing tabs/spaces when file indentation isn't verified first
221
+
222
+ **Your Success Pattern (Follow This):**
223
+ 1. Read file with `read` tool immediately before patching
224
+ 2. Extract exact context lines (space prefix) from what you just read
225
+ 3. Match indentation character-for-character
226
+ 4. Use multiple `@@` hunks for multiple edits in same file
227
+ 5. Wrap in `*** Begin Patch` and `*** End Patch`
228
+
229
+ **If Patch Fails:**
230
+ - Don't retry with same context - read file AGAIN first
231
+ - Check that context lines exist exactly as written
232
+ - Verify indentation matches (spaces vs tabs)
233
+ - If failing 2+ times, switch to `edit` tool instead
234
+
213
235
  # Code References
214
236
 
215
237
  When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
@@ -427,6 +427,38 @@ When using the shell, you must adhere to the following guidelines:
427
427
  - When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
428
428
  - Read files in chunks with a max chunk size of 250 lines. Do not use python scripts to attempt to output larger chunks of a file. Command line output will be truncated after 10 kilobytes or 256 lines of output, regardless of the command used.
429
429
 
430
+ ## Apply Patch Tool - Critical Guidelines
431
+
432
+ **⚠️ Common Patch Failures Across All Models:**
433
+
434
+ Most patch failures happen because:
435
+ - Patches created from memory instead of reading file first
436
+ - Context lines guessed or invented rather than copied exactly
437
+ - Indentation mismatches (tabs vs spaces)
438
+ - Missing or malformed markers (`*** Begin Patch` / `*** End Patch`)
439
+ - Hallucinated code in context lines
440
+
441
+ **Universal Pre-Flight Checklist:**
442
+ Before calling `apply_patch`, verify ALL of these:
443
+ - [ ] File was read with `read` tool in THIS turn (not from memory)
444
+ - [ ] Context lines (space prefix) copied EXACTLY character-for-character
445
+ - [ ] Indentation verified (if file uses spaces, patch uses spaces)
446
+ - [ ] Wrapped in `*** Begin Patch` and `*** End Patch` markers
447
+ - [ ] Used correct directive: `*** Add/Update/Delete File: path`
448
+
449
+ **Success Formula:**
450
+ 1. Use `read` tool on target file
451
+ 2. Identify exact location to edit
452
+ 3. Extract 2-3 lines before/after as context (space prefix)
453
+ 4. Add `-old` lines to remove, `+new` lines to add
454
+ 5. Wrap in `*** Begin Patch` ... `*** End Patch`
455
+ 6. Verify all context matches file exactly
456
+
457
+ **If Patch Fails:**
458
+ - Error = context didn't match OR file content changed
459
+ - Solution: Read file AGAIN, verify context character-by-character
460
+ - After 2+ failures: use `edit` tool instead (more forgiving)
461
+
430
462
  ## `update_plan`
431
463
 
432
464
  A tool named `update_plan` is available to you. You can use it to keep an up‑to‑date, step‑by‑step plan for the task.
@@ -366,6 +366,37 @@ When using the shell, you must adhere to the following guidelines:
366
366
  - Shell commands should be reserved for execution, not discovery
367
367
  - Read files in chunks with a max chunk size of 250 lines. Do not use python scripts to attempt to output larger chunks of a file. Command line output will be truncated after 10 kilobytes or 256 lines of output, regardless of the command used.
368
368
 
369
+ ## Apply Patch Tool - Critical for GPT-4 Models
370
+
371
+ **⚠️ GPT-4 Common Patch Failures:**
372
+
373
+ GPT-4 models (especially GPT-4o) often fail patches by:
374
+ - Creating patches from memory instead of reading the file first
375
+ - Guessing at context lines that don't match the actual file
376
+ - Missing or malformed `*** End Patch` markers
377
+ - Mixing tabs/spaces without checking file's indentation
378
+
379
+ **Mandatory Pre-Flight Checklist (Check EVERY time):**
380
+ - [ ] Read the target file with `read` tool in THIS turn
381
+ - [ ] Copy context lines EXACTLY from what you just read
382
+ - [ ] Verify indentation (spaces vs tabs) matches the file
383
+ - [ ] Include `*** Begin Patch` and `*** End Patch` markers
384
+ - [ ] Use space prefix for context lines (NOT `@@` line - that's just a hint)
385
+
386
+ **GPT-4 Success Formula:**
387
+ 1. Call `read` on the file you want to patch
388
+ 2. Identify the exact lines to change
389
+ 3. Copy 2-3 surrounding lines AS-IS (space prefix)
390
+ 4. Add your `-removal` and `+addition` lines
391
+ 5. Wrap in `*** Begin Patch` ... `*** End Patch`
392
+ 6. Double-check markers and indentation before calling tool
393
+
394
+ **If Your Patch Fails:**
395
+ - You didn't read the file first, OR
396
+ - Context lines don't match file character-for-character
397
+ - Solution: Read file AGAIN, copy exact lines
398
+ - After 2 failures: switch to `edit` tool instead
399
+
369
400
  ## `update_plan`
370
401
 
371
402
  A tool named `update_plan` is available to you. You can use it to keep an up‑to‑date, step‑by‑step plan for the task.