terminal-pilot 0.0.2 → 0.0.4

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/README.md CHANGED
@@ -1,38 +1,23 @@
1
- # @poe-code/terminal-pilot
1
+ # terminal-pilot
2
2
 
3
- `terminal-pilot` is a Playwright-like SDK and MCP server for automating interactive CLI apps through a real pseudoterminal (PTY).
3
+ `terminal-pilot` is a Playwright-like SDK for automating interactive CLI apps through a real pseudoterminal (PTY).
4
4
 
5
5
  Use it when plain stdio is not enough: menus, prompts, arrow-key navigation, confirmations, and terminal redraws such as `poe-code configure`.
6
6
 
7
7
  For design rationale and scope, see `docs/plans/terminal-pilot.md`.
8
8
 
9
+ For the MCP server, see [terminal-pilot-mcp](../terminal-pilot-mcp).
10
+
9
11
  ## What it includes
10
12
 
11
13
  - **SDK:** `TerminalPilot` → `TerminalSession` → `TerminalScreen`
12
- - **MCP server:** 10 tools for AI-driven CLI automation
13
14
  - **Real PTY execution:** works with interactive CLIs that expect a terminal
14
15
  - **Headless by default:** optional `observe: true` mirrors PTY output for debugging
15
16
 
16
17
  ## Entry points
17
18
 
18
19
  ```ts
19
- import { TerminalPilot } from "@poe-code/terminal-pilot";
20
- ```
21
-
22
- ```ts
23
- import { createTerminalPilotMcpServer, main } from "@poe-code/terminal-pilot/mcp";
24
- ```
25
-
26
- CLI bin:
27
-
28
- ```sh
29
- terminal-pilot-mcp
30
- ```
31
-
32
- Development entry point in this repo:
33
-
34
- ```sh
35
- npx tsx packages/terminal-pilot/src/mcp-server.ts
20
+ import { TerminalPilot } from "terminal-pilot";
36
21
  ```
37
22
 
38
23
  ## SDK API
@@ -42,7 +27,7 @@ npx tsx packages/terminal-pilot/src/mcp-server.ts
42
27
  Creates and tracks active terminal sessions.
43
28
 
44
29
  ```ts
45
- import { TerminalPilot } from "@poe-code/terminal-pilot";
30
+ import { TerminalPilot } from "terminal-pilot";
46
31
 
47
32
  const pilot = await TerminalPilot.launch();
48
33
 
@@ -76,8 +61,9 @@ type NewSessionOptions = {
76
61
  class TerminalPilot {
77
62
  static launch(): Promise<TerminalPilot>;
78
63
  newSession(options: NewSessionOptions): Promise<TerminalSession>;
79
- getSession(id: string): TerminalSession; // throws if missing
80
- sessions(): TerminalSession[]; // active sessions only
64
+ getSession(id: string): TerminalSession; // throws if not in map; works for exited sessions until deleteSession
65
+ deleteSession(id: string): void; // explicit removal from session map
66
+ sessions(): TerminalSession[]; // running sessions only (exitCode === null)
81
67
  close(): Promise<void>;
82
68
  }
83
69
  ```
@@ -137,12 +123,13 @@ class TerminalSession {
137
123
  readonly pid: number;
138
124
  exitCode: number | null;
139
125
 
140
- type(text: string): Promise<void>; // character-by-character
141
- fill(text: string): Promise<void>; // bulk write
126
+ type(text: string): Promise<void>; // character-by-character with delay
127
+ fill(text: string): Promise<void>; // bulk write (\n → \r)
142
128
  press(key: TerminalKey): Promise<void>;
143
129
  send(raw: string): Promise<void>; // raw bytes / escape sequences
144
130
  signal(signal: string): Promise<void>;
145
131
  waitFor(pattern: string | RegExp, options?: WaitForOptions): Promise<string>;
132
+ waitForExit(options?: { timeout?: number }): Promise<number>; // throws on timeout
146
133
  waitForQuiet(ms: number): Promise<void>;
147
134
  screen(): Promise<TerminalScreen>;
148
135
  history(options?: HistoryOptions): Promise<string[]>;
@@ -188,125 +175,11 @@ class TerminalScreen {
188
175
  }
189
176
  ```
190
177
 
191
- ## MCP server
192
-
193
- The MCP server holds one in-memory `TerminalPilot` instance and exposes terminal automation over stdio.
194
-
195
- ### Run it
196
-
197
- Development:
198
-
199
- ```sh
200
- npx tsx packages/terminal-pilot/src/mcp-server.ts
201
- ```
202
-
203
- Built / installed package:
204
-
205
- ```sh
206
- terminal-pilot-mcp
207
- ```
208
-
209
- Programmatic:
210
-
211
- ```ts
212
- import { main } from "@poe-code/terminal-pilot/mcp";
213
-
214
- await main();
215
- ```
216
-
217
- ### Connect to an MCP client
218
-
219
- Install the package globally from a local build:
220
-
221
- ```sh
222
- cd packages/terminal-pilot
223
- npm pack --pack-destination /tmp && npm install -g /tmp/poe-code-terminal-pilot-*.tgz && rm /tmp/poe-code-terminal-pilot-*.tgz
224
- ```
225
-
226
- This makes the `terminal-pilot-mcp` bin available globally.
227
-
228
- **Claude Code** (`~/.claude.json` or project `.mcp.json`):
229
-
230
- ```json
231
- {
232
- "mcpServers": {
233
- "terminal-pilot": {
234
- "command": "terminal-pilot-mcp"
235
- }
236
- }
237
- }
238
- ```
239
-
240
- **Claude Desktop** (`claude_desktop_config.json`):
241
-
242
- ```json
243
- {
244
- "mcpServers": {
245
- "terminal-pilot": {
246
- "command": "terminal-pilot-mcp"
247
- }
248
- }
249
- }
250
- ```
251
-
252
- **Cursor** (`.cursor/mcp.json`):
253
-
254
- ```json
255
- {
256
- "mcpServers": {
257
- "terminal-pilot": {
258
- "command": "terminal-pilot-mcp"
259
- }
260
- }
261
- }
262
- ```
263
-
264
- ### Tools
265
-
266
- `void` means the tool returns no payload.
267
-
268
- | Tool | Input | Output |
269
- | --- | --- | --- |
270
- | `terminal_create_session` | `{ command: string, args?: string[], cwd?: string, cols?: number, rows?: number, observe?: boolean }` | `{ sessionId: string, pid: number }` |
271
- | `terminal_type` | `{ sessionId: string, text: string }` | `void` |
272
- | `terminal_press_key` | `{ sessionId: string, key: TerminalKey }` | `void` |
273
- | `terminal_send_signal` | `{ sessionId: string, signal: string }` | `void` |
274
- | `terminal_wait_for` | `{ sessionId: string, pattern: string, timeout?: number }` | `{ matched: true, output: string }` |
275
- | `terminal_read_screen` | `{ sessionId: string }` | `{ lines: string[], cursor: { row: number, col: number }, size: { rows: number, cols: number } }` |
276
- | `terminal_read_history` | `{ sessionId: string, last?: number }` | `{ lines: string[] }` |
277
- | `terminal_resize` | `{ sessionId: string, cols: number, rows: number }` | `void` |
278
- | `terminal_close_session` | `{ sessionId: string }` | `{ exitCode: number }` |
279
- | `terminal_list_sessions` | `{}` | `{ sessions: Array<{ id: string, command: string, pid: number }> }` |
280
-
281
- Practical notes:
282
-
283
- - `terminal_wait_for.pattern` is compiled as a JavaScript `RegExp` on the server.
284
- - `terminal_type` maps to `session.fill(...)` for bulk text entry.
285
- - `terminal_read_screen` returns the **current visible screen**, not scrollback.
286
- - `terminal_read_history` returns ANSI-stripped output since session start.
287
- - `terminal_list_sessions` returns **active** sessions only.
288
- - `observe: true` mirrors PTY output to `stderr`, which is useful when debugging MCP-driven runs.
289
-
290
- Minimal MCP flow:
291
-
292
- ```json
293
- {"tool":"terminal_create_session","arguments":{"command":"poe-code","args":["configure"]}}
294
- {"tool":"terminal_wait_for","arguments":{"sessionId":"<id>","pattern":"Pick an agent to configure:"}}
295
- {"tool":"terminal_press_key","arguments":{"sessionId":"<id>","key":"Enter"}}
296
- {"tool":"terminal_read_screen","arguments":{"sessionId":"<id>"}}
297
- {"tool":"terminal_close_session","arguments":{"sessionId":"<id>"}}
298
- ```
299
-
300
178
  ## Environment variables
301
179
 
302
180
  There are **no terminal-pilot-specific environment variables**.
303
181
 
304
- Runtime environment is controlled per session:
305
-
306
- - SDK: `newSession({ env })`
307
- - MCP server: spawned commands inherit the MCP server process environment unless you override it in your command wrapper
308
-
309
- There are also **no package-level config files or config options** beyond the per-session options above.
182
+ Runtime environment is controlled per session via `newSession({ env })`. There are no package-level config files or config options beyond the per-session options.
310
183
 
311
184
  ## Testing
312
185
 
@@ -340,7 +213,7 @@ Example fixture-based test:
340
213
 
341
214
  ```ts
342
215
  import path from "node:path";
343
- import { TerminalPilot } from "@poe-code/terminal-pilot";
216
+ import { TerminalPilot } from "terminal-pilot";
344
217
 
345
218
  const pilot = await TerminalPilot.launch();
346
219
  const tsxPath = path.join(process.cwd(), "node_modules", ".bin", "tsx");
@@ -371,7 +244,7 @@ Use a temporary home directory so you do not touch your real config while testin
371
244
  import { mkdtempSync, rmSync } from "node:fs";
372
245
  import os from "node:os";
373
246
  import path from "node:path";
374
- import { TerminalPilot } from "@poe-code/terminal-pilot";
247
+ import { TerminalPilot } from "terminal-pilot";
375
248
 
376
249
  const tmpHome = mkdtempSync(path.join(os.tmpdir(), "poe-configure-test-"));
377
250
  const pilot = await TerminalPilot.launch();
@@ -0,0 +1,38 @@
1
+ type Cell = [number, string] | null;
2
+ type Row = Cell[];
3
+ export declare class TerminalBuffer {
4
+ private _cols;
5
+ private _rows;
6
+ private _screen;
7
+ private _cursorX;
8
+ private _cursorY;
9
+ private _savedCursor;
10
+ private _scrollTop;
11
+ private _scrollBottom;
12
+ private _state;
13
+ private _csiParams;
14
+ private _csiPrivate;
15
+ readonly displayBuffer: {
16
+ readonly cursorX: number;
17
+ readonly cursorY: number;
18
+ readonly data: Array<Row | undefined>;
19
+ };
20
+ constructor(cols: number, rows: number);
21
+ write(data: string): void;
22
+ resize(cols: number, rows: number): void;
23
+ private _makeScreen;
24
+ private _makeRow;
25
+ private _clamp;
26
+ private _setChar;
27
+ private _eraseLine;
28
+ private _scrollUp;
29
+ private _scrollDown;
30
+ private _newline;
31
+ private _parseCsiParams;
32
+ private _execCsi;
33
+ private _feed;
34
+ private _feedNormal;
35
+ private _feedEscape;
36
+ private _feedCsi;
37
+ }
38
+ export {};
@@ -0,0 +1,445 @@
1
+ export class TerminalBuffer {
2
+ _cols;
3
+ _rows;
4
+ _screen;
5
+ _cursorX = 0;
6
+ _cursorY = 0;
7
+ _savedCursor = { x: 0, y: 0 };
8
+ _scrollTop = 0;
9
+ _scrollBottom;
10
+ _state = 0 /* State.Normal */;
11
+ _csiParams = "";
12
+ _csiPrivate = "";
13
+ displayBuffer;
14
+ constructor(cols, rows) {
15
+ this._cols = cols;
16
+ this._rows = rows;
17
+ this._scrollBottom = rows - 1;
18
+ this._screen = this._makeScreen(cols, rows);
19
+ this.displayBuffer = Object.defineProperties({}, {
20
+ cursorX: { get: () => this._cursorX, enumerable: true },
21
+ cursorY: { get: () => this._cursorY, enumerable: true },
22
+ data: { get: () => this._screen, enumerable: true },
23
+ });
24
+ }
25
+ write(data) {
26
+ for (const ch of data) {
27
+ this._feed(ch);
28
+ }
29
+ }
30
+ resize(cols, rows) {
31
+ // Adjust row count
32
+ while (this._screen.length < rows) {
33
+ this._screen.push(this._makeRow(cols));
34
+ }
35
+ this._screen.length = rows;
36
+ // Adjust col count for each row
37
+ for (let y = 0; y < rows; y++) {
38
+ const row = this._screen[y] ?? this._makeRow(cols);
39
+ while (row.length < cols)
40
+ row.push(null);
41
+ row.length = cols;
42
+ this._screen[y] = row;
43
+ }
44
+ this._cols = cols;
45
+ this._rows = rows;
46
+ this._scrollTop = 0;
47
+ this._scrollBottom = rows - 1;
48
+ this._cursorX = this._clamp(this._cursorX, 0, cols - 1);
49
+ this._cursorY = this._clamp(this._cursorY, 0, rows - 1);
50
+ }
51
+ _makeScreen(cols, rows) {
52
+ return Array.from({ length: rows }, () => this._makeRow(cols));
53
+ }
54
+ _makeRow(cols) {
55
+ return Array(cols).fill(null);
56
+ }
57
+ _clamp(value, min, max) {
58
+ return Math.max(min, Math.min(max, value));
59
+ }
60
+ _setChar(y, x, ch) {
61
+ const row = this._screen[y];
62
+ if (row && x >= 0 && x < this._cols) {
63
+ row[x] = [ch.charCodeAt(0), ch];
64
+ }
65
+ }
66
+ _eraseLine(y, fromX, toX) {
67
+ const row = this._screen[y];
68
+ if (!row)
69
+ return;
70
+ for (let x = fromX; x <= toX && x < this._cols; x++) {
71
+ row[x] = null;
72
+ }
73
+ }
74
+ _scrollUp(count) {
75
+ for (let i = 0; i < count; i++) {
76
+ this._screen.splice(this._scrollTop, 1);
77
+ this._screen.splice(this._scrollBottom, 0, this._makeRow(this._cols));
78
+ }
79
+ }
80
+ _scrollDown(count) {
81
+ for (let i = 0; i < count; i++) {
82
+ this._screen.splice(this._scrollBottom, 1);
83
+ this._screen.splice(this._scrollTop, 0, this._makeRow(this._cols));
84
+ }
85
+ }
86
+ _newline() {
87
+ if (this._cursorY === this._scrollBottom) {
88
+ this._scrollUp(1);
89
+ }
90
+ else {
91
+ this._cursorY = Math.min(this._cursorY + 1, this._rows - 1);
92
+ }
93
+ }
94
+ _parseCsiParams() {
95
+ if (!this._csiParams)
96
+ return [];
97
+ return this._csiParams.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)));
98
+ }
99
+ _execCsi(final) {
100
+ const params = this._parseCsiParams();
101
+ const p0 = params[0] ?? 0;
102
+ const p1 = params[1] ?? 0;
103
+ if (this._csiPrivate === "?") {
104
+ if (final === "h" || final === "l") {
105
+ // Only handle alt screen (1049) — ignore everything else
106
+ if (params.includes(1049)) {
107
+ if (final === "h") {
108
+ this._screen = this._makeScreen(this._cols, this._rows);
109
+ this._cursorX = 0;
110
+ this._cursorY = 0;
111
+ }
112
+ else {
113
+ this._screen = this._makeScreen(this._cols, this._rows);
114
+ this._cursorX = 0;
115
+ this._cursorY = 0;
116
+ }
117
+ }
118
+ }
119
+ return;
120
+ }
121
+ switch (final) {
122
+ case "A": // cursor up
123
+ this._cursorY = this._clamp(this._cursorY - Math.max(1, p0), 0, this._rows - 1);
124
+ break;
125
+ case "B": // cursor down
126
+ this._cursorY = this._clamp(this._cursorY + Math.max(1, p0), 0, this._rows - 1);
127
+ break;
128
+ case "C": // cursor forward
129
+ case "a":
130
+ this._cursorX = this._clamp(this._cursorX + Math.max(1, p0), 0, this._cols - 1);
131
+ break;
132
+ case "D": // cursor backward
133
+ this._cursorX = this._clamp(this._cursorX - Math.max(1, p0), 0, this._cols - 1);
134
+ break;
135
+ case "E": // cursor next line
136
+ this._cursorY = this._clamp(this._cursorY + Math.max(1, p0), 0, this._rows - 1);
137
+ this._cursorX = 0;
138
+ break;
139
+ case "F": // cursor preceding line
140
+ this._cursorY = this._clamp(this._cursorY - Math.max(1, p0), 0, this._rows - 1);
141
+ this._cursorX = 0;
142
+ break;
143
+ case "G": // cursor horizontal absolute
144
+ case "`":
145
+ this._cursorX = this._clamp(Math.max(1, p0) - 1, 0, this._cols - 1);
146
+ break;
147
+ case "H": // cursor position
148
+ case "f":
149
+ this._cursorY = this._clamp(Math.max(1, p0) - 1, 0, this._rows - 1);
150
+ this._cursorX = this._clamp(Math.max(1, p1) - 1, 0, this._cols - 1);
151
+ break;
152
+ case "I": // cursor forward tabulation
153
+ for (let i = 0; i < Math.max(1, p0); i++) {
154
+ this._cursorX = Math.min(this._cols - 1, (Math.floor(this._cursorX / 8) + 1) * 8);
155
+ }
156
+ break;
157
+ case "J": // erase in display
158
+ if (p0 === 0) {
159
+ this._eraseLine(this._cursorY, this._cursorX, this._cols - 1);
160
+ for (let y = this._cursorY + 1; y < this._rows; y++)
161
+ this._eraseLine(y, 0, this._cols - 1);
162
+ }
163
+ else if (p0 === 1) {
164
+ for (let y = 0; y < this._cursorY; y++)
165
+ this._eraseLine(y, 0, this._cols - 1);
166
+ this._eraseLine(this._cursorY, 0, this._cursorX);
167
+ }
168
+ else if (p0 === 2 || p0 === 3) {
169
+ for (let y = 0; y < this._rows; y++)
170
+ this._eraseLine(y, 0, this._cols - 1);
171
+ }
172
+ break;
173
+ case "K": // erase in line
174
+ if (p0 === 0)
175
+ this._eraseLine(this._cursorY, this._cursorX, this._cols - 1);
176
+ else if (p0 === 1)
177
+ this._eraseLine(this._cursorY, 0, this._cursorX);
178
+ else if (p0 === 2)
179
+ this._eraseLine(this._cursorY, 0, this._cols - 1);
180
+ break;
181
+ case "X": // erase characters (ECH)
182
+ this._eraseLine(this._cursorY, this._cursorX, this._cursorX + Math.max(1, p0) - 1);
183
+ break;
184
+ case "L": { // insert lines
185
+ const n = Math.max(1, p0);
186
+ for (let i = 0; i < n; i++) {
187
+ this._screen.splice(this._scrollBottom, 1);
188
+ this._screen.splice(this._cursorY, 0, this._makeRow(this._cols));
189
+ }
190
+ break;
191
+ }
192
+ case "M": { // delete lines
193
+ const n = Math.max(1, p0);
194
+ for (let i = 0; i < n; i++) {
195
+ this._screen.splice(this._cursorY, 1);
196
+ this._screen.splice(this._scrollBottom, 0, this._makeRow(this._cols));
197
+ }
198
+ break;
199
+ }
200
+ case "P": { // delete characters
201
+ const row = this._screen[this._cursorY];
202
+ if (row) {
203
+ const n = Math.max(1, p0);
204
+ row.splice(this._cursorX, n);
205
+ while (row.length < this._cols)
206
+ row.push(null);
207
+ }
208
+ break;
209
+ }
210
+ case "@": { // insert blank characters
211
+ const row = this._screen[this._cursorY];
212
+ if (row) {
213
+ const n = Math.max(1, p0);
214
+ for (let i = 0; i < n; i++)
215
+ row.splice(this._cursorX, 0, null);
216
+ row.splice(this._cols);
217
+ }
218
+ break;
219
+ }
220
+ case "S": // scroll up
221
+ this._scrollUp(Math.max(1, p0));
222
+ break;
223
+ case "T": // scroll down
224
+ if (params.length <= 1)
225
+ this._scrollDown(Math.max(1, p0));
226
+ break;
227
+ case "Z": { // cursor backward tabulation
228
+ const n = Math.max(1, p0);
229
+ for (let i = 0; i < n; i++) {
230
+ this._cursorX = Math.max(0, (Math.ceil(this._cursorX / 8) - 1) * 8);
231
+ }
232
+ break;
233
+ }
234
+ case "d": // line position absolute
235
+ this._cursorY = this._clamp(Math.max(1, p0) - 1, 0, this._rows - 1);
236
+ break;
237
+ case "e": // vertical position relative
238
+ this._cursorY = this._clamp(this._cursorY + Math.max(1, p0), 0, this._rows - 1);
239
+ break;
240
+ case "r": { // set scrolling region
241
+ const top = this._clamp(Math.max(1, p0) - 1, 0, this._rows - 1);
242
+ const bottom = this._clamp((p1 === 0 ? this._rows : p1) - 1, 0, this._rows - 1);
243
+ if (top < bottom) {
244
+ this._scrollTop = top;
245
+ this._scrollBottom = bottom;
246
+ }
247
+ this._cursorX = 0;
248
+ this._cursorY = 0;
249
+ break;
250
+ }
251
+ case "s": // save cursor
252
+ this._savedCursor = { x: this._cursorX, y: this._cursorY };
253
+ break;
254
+ case "u": // restore cursor
255
+ this._cursorX = this._clamp(this._savedCursor.x, 0, this._cols - 1);
256
+ this._cursorY = this._clamp(this._savedCursor.y, 0, this._rows - 1);
257
+ break;
258
+ case "m": // SGR — ignore (we don't track attributes)
259
+ break;
260
+ default:
261
+ break;
262
+ }
263
+ }
264
+ _feed(ch) {
265
+ const code = ch.charCodeAt(0);
266
+ switch (this._state) {
267
+ case 0 /* State.Normal */:
268
+ this._feedNormal(ch, code);
269
+ break;
270
+ case 1 /* State.Escape */:
271
+ this._feedEscape(ch, code);
272
+ break;
273
+ case 2 /* State.Csi */:
274
+ this._feedCsi(ch, code);
275
+ break;
276
+ case 3 /* State.Osc */:
277
+ // consume until BEL or ESC (ESC \ = ST)
278
+ if (code === 0x07 || code === 0x9c) {
279
+ this._state = 0 /* State.Normal */;
280
+ }
281
+ else if (code === 0x1b) {
282
+ // next char should be `\` — just return to normal, it will be consumed
283
+ this._state = 0 /* State.Normal */;
284
+ }
285
+ break;
286
+ case 4 /* State.Str */:
287
+ // consume until ST (0x9c) or BEL
288
+ if (code === 0x9c || code === 0x07) {
289
+ this._state = 0 /* State.Normal */;
290
+ }
291
+ else if (code === 0x1b) {
292
+ this._state = 0 /* State.Normal */;
293
+ }
294
+ break;
295
+ case 5 /* State.EscCharset */:
296
+ // consume one character for charset designation
297
+ this._state = 0 /* State.Normal */;
298
+ break;
299
+ case 6 /* State.EscHash */:
300
+ // consume one character for line attributes
301
+ this._state = 0 /* State.Normal */;
302
+ break;
303
+ }
304
+ }
305
+ _feedNormal(ch, code) {
306
+ if (code === 0x1b) {
307
+ this._state = 1 /* State.Escape */;
308
+ }
309
+ else if (code === 0x9b) {
310
+ // C1 CSI
311
+ this._csiParams = "";
312
+ this._csiPrivate = "";
313
+ this._state = 2 /* State.Csi */;
314
+ }
315
+ else if (code === 0x9d) {
316
+ // C1 OSC
317
+ this._state = 3 /* State.Osc */;
318
+ }
319
+ else if (code === 0x90 || code === 0x98 || code === 0x9e || code === 0x9f) {
320
+ // DCS, SOS, PM, APC
321
+ this._state = 4 /* State.Str */;
322
+ }
323
+ else if (code === 0x07 || code === 0x05 || code === 0x06) {
324
+ // BEL, ENQ, ACK — ignore
325
+ }
326
+ else if (code === 0x08) {
327
+ // BS
328
+ if (this._cursorX > 0)
329
+ this._cursorX--;
330
+ }
331
+ else if (code === 0x7f) {
332
+ // DEL
333
+ if (this._cursorX > 0) {
334
+ this._cursorX--;
335
+ this._setChar(this._cursorY, this._cursorX, " ");
336
+ }
337
+ }
338
+ else if (code === 0x09) {
339
+ // HT
340
+ this._cursorX = Math.min(this._cols - 1, (Math.floor(this._cursorX / 8) + 1) * 8);
341
+ }
342
+ else if (code === 0x0a || code === 0x0b || code === 0x0c) {
343
+ // LF, VT, FF
344
+ this._newline();
345
+ }
346
+ else if (code === 0x0d) {
347
+ // CR
348
+ this._cursorX = 0;
349
+ }
350
+ else if (code === 0x0e || code === 0x0f) {
351
+ // SO, SI — charset switch, ignore
352
+ }
353
+ else if (code >= 0x20 && code !== 0x7f) {
354
+ // Printable character (including multi-byte Unicode via code points)
355
+ this._setChar(this._cursorY, this._cursorX, ch);
356
+ this._cursorX++;
357
+ if (this._cursorX >= this._cols) {
358
+ // Auto-wrap
359
+ this._cursorX = 0;
360
+ this._newline();
361
+ }
362
+ }
363
+ }
364
+ _feedEscape(ch, code) {
365
+ this._state = 0 /* State.Normal */;
366
+ if (code === 0x5b) {
367
+ // ESC [ = CSI
368
+ this._csiParams = "";
369
+ this._csiPrivate = "";
370
+ this._state = 2 /* State.Csi */;
371
+ }
372
+ else if (code === 0x5d) {
373
+ // ESC ] = OSC
374
+ this._state = 3 /* State.Osc */;
375
+ }
376
+ else if (code === 0x50 || code === 0x58 || code === 0x5e || code === 0x5f) {
377
+ // DCS, SOS, PM, APC
378
+ this._state = 4 /* State.Str */;
379
+ }
380
+ else if (code === 0x28 || code === 0x29 || code === 0x2a || code === 0x2b || code === 0x2d || code === 0x2e) {
381
+ // ESC ( ) * + - . = charset designation (consume next char)
382
+ this._state = 5 /* State.EscCharset */;
383
+ }
384
+ else if (code === 0x23) {
385
+ // ESC # = line attributes (consume next char)
386
+ this._state = 6 /* State.EscHash */;
387
+ }
388
+ else if (code === 0x37) {
389
+ // ESC 7 = save cursor
390
+ this._savedCursor = { x: this._cursorX, y: this._cursorY };
391
+ }
392
+ else if (code === 0x38) {
393
+ // ESC 8 = restore cursor
394
+ this._cursorX = this._clamp(this._savedCursor.x, 0, this._cols - 1);
395
+ this._cursorY = this._clamp(this._savedCursor.y, 0, this._rows - 1);
396
+ }
397
+ else if (code === 0x44) {
398
+ // ESC D = index (LF)
399
+ this._newline();
400
+ }
401
+ else if (code === 0x45) {
402
+ // ESC E = next line
403
+ this._cursorX = 0;
404
+ this._newline();
405
+ }
406
+ else if (code === 0x4d) {
407
+ // ESC M = reverse index
408
+ if (this._cursorY === this._scrollTop) {
409
+ this._scrollDown(1);
410
+ }
411
+ else {
412
+ this._cursorY = Math.max(0, this._cursorY - 1);
413
+ }
414
+ }
415
+ else if (code === 0x48) {
416
+ // ESC H = tab set (ignore)
417
+ }
418
+ else if (code === 0x63) {
419
+ // ESC c = full reset
420
+ this._screen = this._makeScreen(this._cols, this._rows);
421
+ this._cursorX = 0;
422
+ this._cursorY = 0;
423
+ this._savedCursor = { x: 0, y: 0 };
424
+ this._scrollTop = 0;
425
+ this._scrollBottom = this._rows - 1;
426
+ }
427
+ // All other ESC sequences: two-char, already consumed — ignore
428
+ }
429
+ _feedCsi(ch, code) {
430
+ if (code >= 0x40 && code <= 0x7e) {
431
+ // Final byte
432
+ this._execCsi(ch);
433
+ this._state = 0 /* State.Normal */;
434
+ }
435
+ else if (code === 0x3f || code === 0x21 || code === 0x3e || code === 0x20) {
436
+ // Private/intermediate marker (?, !, >, space)
437
+ this._csiPrivate = ch;
438
+ }
439
+ else if ((code >= 0x30 && code <= 0x39) || code === 0x3b) {
440
+ // Digit or semicolon — parameter byte
441
+ this._csiParams += ch;
442
+ }
443
+ // Other bytes ignored
444
+ }
445
+ }
@@ -13,6 +13,7 @@ export declare class TerminalPilot {
13
13
  static launch(): Promise<TerminalPilot>;
14
14
  newSession(opts: NewSessionOptions): Promise<TerminalSession>;
15
15
  getSession(id: string): TerminalSession;
16
+ deleteSession(id: string): void;
16
17
  sessions(): TerminalSession[];
17
18
  close(): Promise<void>;
18
19
  }
@@ -18,9 +18,6 @@ export class TerminalPilot {
18
18
  rows: opts.rows ?? DEFAULT_ROWS,
19
19
  observe: opts.observe ?? false
20
20
  });
21
- session.on("exit", () => {
22
- this.sessionMap.delete(session.id);
23
- });
24
21
  this.sessionMap.set(session.id, session);
25
22
  return session;
26
23
  }
@@ -31,8 +28,11 @@ export class TerminalPilot {
31
28
  }
32
29
  return session;
33
30
  }
31
+ deleteSession(id) {
32
+ this.sessionMap.delete(id);
33
+ }
34
34
  sessions() {
35
- return [...this.sessionMap.values()];
35
+ return [...this.sessionMap.values()].filter((s) => s.exitCode === null);
36
36
  }
37
37
  async close() {
38
38
  const sessions = [...this.sessionMap.values()];
@@ -42,6 +42,9 @@ export declare class TerminalSession {
42
42
  screen(): Promise<TerminalScreen>;
43
43
  history(opts?: HistoryOptions): Promise<string[]>;
44
44
  resize(cols: number, rows: number): Promise<void>;
45
+ waitForExit(opts?: {
46
+ timeout?: number;
47
+ }): Promise<number>;
45
48
  close(): Promise<number>;
46
49
  on(event: "exit", cb: (code: number) => void): void;
47
50
  }
@@ -1,12 +1,10 @@
1
1
  import { spawn as spawnChildProcess } from "node:child_process";
2
2
  import { EventEmitter } from "node:events";
3
- import { createRequire } from "node:module";
4
3
  import * as nodePty from "node-pty";
5
4
  import { stripAnsi } from "./ansi.js";
5
+ import { TerminalBuffer } from "./terminal-buffer.js";
6
6
  import { keyToSequence } from "./keys.js";
7
7
  import { TerminalScreen } from "./terminal-screen.js";
8
- const require = createRequire(import.meta.url);
9
- const HeadlessTerminal = require("headless-terminal");
10
8
  const DEFAULT_COLS = 120;
11
9
  const DEFAULT_ROWS = 40;
12
10
  const DEFAULT_TIMEOUT_MS = 10_000;
@@ -33,7 +31,7 @@ export class TerminalSession {
33
31
  this.command = command;
34
32
  this.currentCols = cols;
35
33
  this.currentRows = rows;
36
- this.terminal = new HeadlessTerminal(cols, rows);
34
+ this.terminal = new TerminalBuffer(cols, rows);
37
35
  this.pty = createPtyProcess({ command, args, cwd, env, cols, rows });
38
36
  this.pid = this.pty.pid;
39
37
  const dataSubscription = this.pty.onData((chunk) => {
@@ -145,17 +143,31 @@ export class TerminalSession {
145
143
  }
146
144
  this.terminal.resize(cols, rows);
147
145
  }
146
+ async waitForExit(opts) {
147
+ if (this.exitCode !== null) {
148
+ return this.exitCode;
149
+ }
150
+ if (opts?.timeout !== undefined) {
151
+ const result = await waitForExit(this.exitPromise, opts.timeout);
152
+ if (result === null) {
153
+ throw new Error(`Timed out waiting for process to exit after ${opts.timeout}ms`);
154
+ }
155
+ return result;
156
+ }
157
+ return this.exitPromise;
158
+ }
148
159
  async close() {
149
160
  if (this.exitCode !== null) {
150
161
  return this.exitCode;
151
162
  }
152
163
  if (!this.closeRequested) {
153
164
  this.closeRequested = true;
165
+ const gracefulExitCode = await waitForExit(this.exitPromise, CLOSE_AFTER_SIGNAL_GRACE_MS);
166
+ if (gracefulExitCode !== null) {
167
+ return gracefulExitCode;
168
+ }
154
169
  if (this.signalRequested) {
155
- const exitCode = await waitForExit(this.exitPromise, CLOSE_AFTER_SIGNAL_GRACE_MS);
156
- if (exitCode !== null) {
157
- return exitCode;
158
- }
170
+ return this.exitPromise;
159
171
  }
160
172
  if (this.exitCode === null) {
161
173
  this.pty.kill("SIGTERM");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "terminal-pilot",
3
- "version": "0.0.2",
4
- "description": "Headless terminal session manager with MCP server support",
3
+ "version": "0.0.4",
4
+ "description": "Playwright-like SDK for automating interactive CLI apps through a real PTY",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -9,19 +9,12 @@
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
11
  "import": "./dist/index.js"
12
- },
13
- "./mcp": {
14
- "types": "./dist/mcp-server.d.ts",
15
- "import": "./dist/mcp-server.js"
16
12
  }
17
13
  },
18
- "bin": {
19
- "terminal-pilot-mcp": "dist/mcp-server.js"
20
- },
21
14
  "scripts": {
22
15
  "build": "tsc",
23
16
  "prepublishOnly": "tsc",
24
- "install-local-package": "TMPD=$(mktemp -d) && cd ../tiny-stdio-mcp-server && npm pack --pack-destination $TMPD && cd ../terminal-pilot && npm pack --pack-destination $TMPD && npm install -g $TMPD/poe-code-terminal-pilot-*.tgz $TMPD/tiny-stdio-mcp-server-*.tgz && rm -rf $TMPD"
17
+ "install-local-package": "TMPD=$(mktemp -d) && npm pack --pack-destination $TMPD && npm install -g $TMPD/terminal-pilot-*.tgz && rm -rf $TMPD"
25
18
  },
26
19
  "files": [
27
20
  "dist"
@@ -37,14 +30,11 @@
37
30
  "license": "MIT",
38
31
  "keywords": [
39
32
  "terminal",
40
- "mcp",
41
33
  "pty",
42
34
  "headless"
43
35
  ],
44
36
  "dependencies": {
45
- "headless-terminal": "^0.4.0",
46
- "node-pty": "^1.1.0",
47
- "tiny-stdio-mcp-server": "^0.1.0"
37
+ "node-pty": "^1.1.0"
48
38
  },
49
39
  "devDependencies": {}
50
40
  }
@@ -1,11 +0,0 @@
1
- #!/usr/bin/env node
2
- import { type Server } from "tiny-stdio-mcp-server";
3
- import { TerminalPilot } from "./terminal-pilot.js";
4
- type RuntimeProcess = Pick<typeof process, "on" | "off">;
5
- export declare function createTerminalPilotMcpServer(agent: TerminalPilot): Server;
6
- export declare function main({ launchAgent, createMcpServer, runtimeProcess }?: {
7
- launchAgent?: typeof TerminalPilot.launch;
8
- createMcpServer?: (agent: TerminalPilot) => Server;
9
- runtimeProcess?: RuntimeProcess;
10
- }): Promise<void>;
11
- export {};
@@ -1,40 +0,0 @@
1
- #!/usr/bin/env node
2
- import packageJson from "../package.json" with { type: "json" };
3
- import { createServer } from "tiny-stdio-mcp-server";
4
- import { terminalPilotMcpTools } from "./mcp-tools.js";
5
- import { TerminalPilot } from "./terminal-pilot.js";
6
- export function createTerminalPilotMcpServer(agent) {
7
- const server = createServer({
8
- name: "terminal-pilot",
9
- version: packageJson.version
10
- });
11
- for (const tool of terminalPilotMcpTools(agent)) {
12
- server.tool(tool.name, tool.description, tool.inputSchema, tool.handler);
13
- }
14
- return server;
15
- }
16
- export async function main({ launchAgent = TerminalPilot.launch, createMcpServer = createTerminalPilotMcpServer, runtimeProcess = process } = {}) {
17
- const agent = await launchAgent();
18
- let closed = false;
19
- const closeAgent = async () => {
20
- if (closed) {
21
- return;
22
- }
23
- closed = true;
24
- await agent.close();
25
- };
26
- const handleExit = () => {
27
- void closeAgent();
28
- };
29
- runtimeProcess.on("exit", handleExit);
30
- try {
31
- await createMcpServer(agent).listen();
32
- }
33
- finally {
34
- runtimeProcess.off("exit", handleExit);
35
- await closeAgent();
36
- }
37
- }
38
- if (import.meta.url === `file://${process.argv[1]}`) {
39
- void main();
40
- }
@@ -1,46 +0,0 @@
1
- import { type ToolDefinition } from "tiny-stdio-mcp-server";
2
- import type { TerminalKey } from "./keys.js";
3
- import type { TerminalPilot } from "./terminal-pilot.js";
4
- export type TerminalPilotMcpTool<T = Record<string, unknown>> = ToolDefinition<T>;
5
- export declare function terminalCreateSessionTool(agent: TerminalPilot): TerminalPilotMcpTool<{
6
- command: string;
7
- args?: string[];
8
- cwd?: string;
9
- cols?: number;
10
- rows?: number;
11
- observe?: boolean;
12
- }>;
13
- export declare function terminalTypeTool(agent: TerminalPilot): TerminalPilotMcpTool<{
14
- sessionId: string;
15
- text: string;
16
- }>;
17
- export declare function terminalPressKeyTool(agent: TerminalPilot): TerminalPilotMcpTool<{
18
- sessionId: string;
19
- key: TerminalKey;
20
- }>;
21
- export declare function terminalSendSignalTool(agent: TerminalPilot): TerminalPilotMcpTool<{
22
- sessionId: string;
23
- signal: string;
24
- }>;
25
- export declare function terminalWaitForTool(agent: TerminalPilot): TerminalPilotMcpTool<{
26
- sessionId: string;
27
- pattern: string;
28
- timeout?: number;
29
- }>;
30
- export declare function terminalReadScreenTool(agent: TerminalPilot): TerminalPilotMcpTool<{
31
- sessionId: string;
32
- }>;
33
- export declare function terminalReadHistoryTool(agent: TerminalPilot): TerminalPilotMcpTool<{
34
- sessionId: string;
35
- last?: number;
36
- }>;
37
- export declare function terminalResizeTool(agent: TerminalPilot): TerminalPilotMcpTool<{
38
- sessionId: string;
39
- cols: number;
40
- rows: number;
41
- }>;
42
- export declare function terminalCloseSessionTool(agent: TerminalPilot): TerminalPilotMcpTool<{
43
- sessionId: string;
44
- }>;
45
- export declare function terminalPilotMcpTools(agent: TerminalPilot): Array<TerminalPilotMcpTool<any>>;
46
- export declare function terminalListSessionsTool(agent: TerminalPilot): TerminalPilotMcpTool<Record<string, never>>;
package/dist/mcp-tools.js DELETED
@@ -1,169 +0,0 @@
1
- import { defineSchema } from "tiny-stdio-mcp-server";
2
- export function terminalCreateSessionTool(agent) {
3
- return {
4
- name: "terminal_create_session",
5
- description: "Spawn an interactive CLI in a PTY",
6
- inputSchema: defineSchema({
7
- command: { type: "string", description: "Command to execute" },
8
- args: { type: "array", description: "Command arguments", optional: true },
9
- cwd: { type: "string", description: "Working directory", optional: true },
10
- cols: { type: "number", description: "Terminal width in columns", optional: true },
11
- rows: { type: "number", description: "Terminal height in rows", optional: true },
12
- observe: { type: "boolean", description: "Mirror PTY output to stderr", optional: true }
13
- }),
14
- async handler(input) {
15
- const session = await agent.newSession(input);
16
- return { sessionId: session.id, pid: session.pid };
17
- }
18
- };
19
- }
20
- export function terminalTypeTool(agent) {
21
- return {
22
- name: "terminal_type",
23
- description: "Write text to an active terminal session",
24
- inputSchema: defineSchema({
25
- sessionId: { type: "string", description: "Terminal session id" },
26
- text: { type: "string", description: "Text to write to the session" }
27
- }),
28
- async handler(input) {
29
- await agent.getSession(input.sessionId).fill(input.text);
30
- return undefined;
31
- }
32
- };
33
- }
34
- export function terminalPressKeyTool(agent) {
35
- return {
36
- name: "terminal_press_key",
37
- description: "Send a named key press to an active terminal session",
38
- inputSchema: defineSchema({
39
- sessionId: { type: "string", description: "Terminal session id" },
40
- key: { type: "string", description: "Named key to press" }
41
- }),
42
- async handler(input) {
43
- await agent.getSession(input.sessionId).press(input.key);
44
- return undefined;
45
- }
46
- };
47
- }
48
- export function terminalSendSignalTool(agent) {
49
- return {
50
- name: "terminal_send_signal",
51
- description: "Send a process signal to an active terminal session",
52
- inputSchema: defineSchema({
53
- sessionId: { type: "string", description: "Terminal session id" },
54
- signal: { type: "string", description: "Signal to send to the session process" }
55
- }),
56
- async handler(input) {
57
- await agent.getSession(input.sessionId).signal(input.signal);
58
- return undefined;
59
- }
60
- };
61
- }
62
- export function terminalWaitForTool(agent) {
63
- return {
64
- name: "terminal_wait_for",
65
- description: "Wait for terminal output to match a pattern",
66
- inputSchema: defineSchema({
67
- sessionId: { type: "string", description: "Terminal session id" },
68
- pattern: { type: "string", description: "Regular expression pattern to wait for" },
69
- timeout: { type: "number", description: "Maximum wait time in milliseconds", optional: true }
70
- }),
71
- async handler(input) {
72
- const session = agent.getSession(input.sessionId);
73
- const pattern = new RegExp(input.pattern);
74
- const line = input.timeout === undefined
75
- ? await session.waitFor(pattern)
76
- : await session.waitFor(pattern, { timeout: input.timeout });
77
- return { matched: true, line };
78
- }
79
- };
80
- }
81
- export function terminalReadScreenTool(agent) {
82
- return {
83
- name: "terminal_read_screen",
84
- description: "Read the current visible terminal screen",
85
- inputSchema: defineSchema({
86
- sessionId: { type: "string", description: "Terminal session id" }
87
- }),
88
- async handler(input) {
89
- const screen = await agent.getSession(input.sessionId).screen();
90
- return {
91
- lines: [...screen.lines],
92
- cursor: { ...screen.cursor },
93
- size: { ...screen.size }
94
- };
95
- }
96
- };
97
- }
98
- export function terminalReadHistoryTool(agent) {
99
- return {
100
- name: "terminal_read_history",
101
- description: "Read terminal output history",
102
- inputSchema: defineSchema({
103
- sessionId: { type: "string", description: "Terminal session id" },
104
- last: { type: "number", description: "Return only the last N lines", optional: true }
105
- }),
106
- async handler(input) {
107
- const lines = await agent.getSession(input.sessionId).history({ last: input.last });
108
- return { lines };
109
- }
110
- };
111
- }
112
- export function terminalResizeTool(agent) {
113
- return {
114
- name: "terminal_resize",
115
- description: "Resize an active terminal session",
116
- inputSchema: defineSchema({
117
- sessionId: { type: "string", description: "Terminal session id" },
118
- cols: { type: "number", description: "Terminal width in columns" },
119
- rows: { type: "number", description: "Terminal height in rows" }
120
- }),
121
- async handler(input) {
122
- await agent.getSession(input.sessionId).resize(input.cols, input.rows);
123
- return undefined;
124
- }
125
- };
126
- }
127
- export function terminalCloseSessionTool(agent) {
128
- return {
129
- name: "terminal_close_session",
130
- description: "Close an active terminal session",
131
- inputSchema: defineSchema({
132
- sessionId: { type: "string", description: "Terminal session id" }
133
- }),
134
- async handler(input) {
135
- const exitCode = await agent.getSession(input.sessionId).close();
136
- return { exitCode };
137
- }
138
- };
139
- }
140
- export function terminalPilotMcpTools(agent) {
141
- return [
142
- terminalCreateSessionTool(agent),
143
- terminalTypeTool(agent),
144
- terminalPressKeyTool(agent),
145
- terminalSendSignalTool(agent),
146
- terminalWaitForTool(agent),
147
- terminalReadScreenTool(agent),
148
- terminalReadHistoryTool(agent),
149
- terminalResizeTool(agent),
150
- terminalCloseSessionTool(agent),
151
- terminalListSessionsTool(agent)
152
- ];
153
- }
154
- export function terminalListSessionsTool(agent) {
155
- return {
156
- name: "terminal_list_sessions",
157
- description: "List active terminal sessions",
158
- inputSchema: defineSchema({}),
159
- async handler() {
160
- return {
161
- sessions: agent.sessions().map((session) => ({
162
- id: session.id,
163
- command: session.command,
164
- pid: session.pid
165
- }))
166
- };
167
- }
168
- };
169
- }