seahorse-bash-client 1.0.0

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.
@@ -0,0 +1,430 @@
1
+ /**
2
+ * PTY Manager - Manages multiple PTY sessions with rolling output buffers
3
+ */
4
+
5
+ import * as pty from 'node-pty';
6
+ import { v4 as uuidv4 } from 'uuid';
7
+ import { EventEmitter } from 'events';
8
+ import {
9
+ PTYSession,
10
+ SessionStatus,
11
+ OutputLine,
12
+ BashSpawnInput,
13
+ BashExecInput,
14
+ BashWriteInput,
15
+ BashReadInput,
16
+ BashListInput,
17
+ BashKillInput,
18
+ BashSpawnResponse,
19
+ BashExecResponse,
20
+ BashWriteResponse,
21
+ BashReadResponse,
22
+ BashListResponse,
23
+ BashKillResponse,
24
+ } from './types';
25
+
26
+ // Special key mappings
27
+ const SPECIAL_KEYS: Record<string, string> = {
28
+ 'ctrl+c': '\x03',
29
+ 'ctrl+d': '\x04',
30
+ 'ctrl+z': '\x1a',
31
+ 'enter': '\r',
32
+ 'tab': '\t',
33
+ 'up': '\x1b[A',
34
+ 'down': '\x1b[B',
35
+ 'left': '\x1b[D',
36
+ 'right': '\x1b[C',
37
+ };
38
+
39
+ interface ManagedSession {
40
+ session: PTYSession;
41
+ ptyProcess: pty.IPty;
42
+ outputBuffer: OutputLine[];
43
+ notifyOnExit: boolean;
44
+ }
45
+
46
+ export class PTYManager extends EventEmitter {
47
+ private sessions: Map<string, ManagedSession> = new Map();
48
+ private maxOutputLines: number;
49
+ private defaultShell: string;
50
+
51
+ constructor(options: { maxOutputLines?: number; defaultShell?: string } = {}) {
52
+ super();
53
+ this.maxOutputLines = options.maxOutputLines || 50000;
54
+ this.defaultShell = options.defaultShell || process.env.SHELL || '/bin/bash';
55
+ }
56
+
57
+ /**
58
+ * Spawn a new PTY session
59
+ */
60
+ async spawn(input: BashSpawnInput): Promise<BashSpawnResponse> {
61
+ const sessionId = `pty_${uuidv4().slice(0, 8)}`;
62
+ const shell = input.shell || this.defaultShell;
63
+ const cwd = input.cwd || process.cwd();
64
+
65
+ // Build the command
66
+ const shellArgs = input.command
67
+ ? ['-c', `${input.command}${input.args ? ' ' + input.args.join(' ') : ''}`]
68
+ : [];
69
+
70
+ // Merge environment
71
+ const env = { ...process.env, ...input.env } as Record<string, string>;
72
+
73
+ // Create PTY process
74
+ const ptyProcess = pty.spawn(shell, shellArgs, {
75
+ name: 'xterm-256color',
76
+ cols: input.cols || 120,
77
+ rows: input.rows || 30,
78
+ cwd,
79
+ env,
80
+ });
81
+
82
+ const session: PTYSession = {
83
+ id: sessionId,
84
+ name: input.name,
85
+ pid: ptyProcess.pid,
86
+ command: input.command || '',
87
+ args: input.args || [],
88
+ cwd,
89
+ status: 'running',
90
+ createdAt: new Date(),
91
+ lineCount: 0,
92
+ };
93
+
94
+ const managedSession: ManagedSession = {
95
+ session,
96
+ ptyProcess,
97
+ outputBuffer: [],
98
+ notifyOnExit: input.notifyOnExit !== false,
99
+ };
100
+
101
+ this.sessions.set(sessionId, managedSession);
102
+
103
+ // Handle output
104
+ ptyProcess.onData((data: string) => {
105
+ this.appendOutput(sessionId, data);
106
+ });
107
+
108
+ // Handle exit
109
+ ptyProcess.onExit(({ exitCode, signal }) => {
110
+ const ms = this.sessions.get(sessionId);
111
+ if (ms) {
112
+ ms.session.status = signal ? 'killed' : 'exited';
113
+ ms.session.exitCode = exitCode;
114
+ ms.session.signal = signal?.toString();
115
+
116
+ if (ms.notifyOnExit) {
117
+ this.emit('session:exited', {
118
+ sessionId,
119
+ exitCode,
120
+ signal: signal?.toString(),
121
+ duration: Date.now() - ms.session.createdAt.getTime(),
122
+ finalLineCount: ms.session.lineCount,
123
+ });
124
+ }
125
+ }
126
+ });
127
+
128
+ return {
129
+ sessionId,
130
+ pid: ptyProcess.pid,
131
+ command: input.command || '',
132
+ status: 'running',
133
+ message: 'Session created and command started',
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Execute a command synchronously and wait for completion
139
+ */
140
+ async exec(input: BashExecInput): Promise<BashExecResponse> {
141
+ return new Promise((resolve) => {
142
+ const shell = this.defaultShell;
143
+ const cwd = input.cwd || process.cwd();
144
+ const timeout = Math.min(input.timeout || 30000, 600000); // Max 10 minutes
145
+ const env = { ...process.env, ...input.env } as Record<string, string>;
146
+
147
+ let stdout = '';
148
+ let stderr = '';
149
+ let timedOut = false;
150
+ const startTime = Date.now();
151
+
152
+ const ptyProcess = pty.spawn(shell, ['-c', input.command], {
153
+ name: 'xterm-256color',
154
+ cols: 120,
155
+ rows: 30,
156
+ cwd,
157
+ env,
158
+ });
159
+
160
+ // Timeout handler
161
+ const timeoutId = setTimeout(() => {
162
+ timedOut = true;
163
+ ptyProcess.kill();
164
+ }, timeout);
165
+
166
+ // Collect output
167
+ ptyProcess.onData((data: string) => {
168
+ stdout += data;
169
+ });
170
+
171
+ // Handle exit
172
+ ptyProcess.onExit(({ exitCode }) => {
173
+ clearTimeout(timeoutId);
174
+ resolve({
175
+ exitCode: timedOut ? -1 : exitCode,
176
+ stdout: stdout.trim(),
177
+ stderr: stderr.trim(),
178
+ duration: Date.now() - startTime,
179
+ timedOut,
180
+ });
181
+ });
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Write data to a PTY session
187
+ */
188
+ write(input: BashWriteInput): BashWriteResponse {
189
+ const ms = this.sessions.get(input.sessionId);
190
+ if (!ms) {
191
+ throw new Error(`Session not found: ${input.sessionId}`);
192
+ }
193
+
194
+ if (ms.session.status !== 'running') {
195
+ throw new Error(`Session is not running: ${input.sessionId}`);
196
+ }
197
+
198
+ let data = input.data || '';
199
+ if (input.specialKey) {
200
+ data = SPECIAL_KEYS[input.specialKey] || '';
201
+ }
202
+
203
+ if (!data) {
204
+ throw new Error('No data or specialKey provided');
205
+ }
206
+
207
+ ms.ptyProcess.write(data);
208
+
209
+ return {
210
+ success: true,
211
+ bytesWritten: data.length,
212
+ sessionStatus: ms.session.status,
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Read output from a PTY session
218
+ */
219
+ read(input: BashReadInput): BashReadResponse {
220
+ const ms = this.sessions.get(input.sessionId);
221
+ if (!ms) {
222
+ throw new Error(`Session not found: ${input.sessionId}`);
223
+ }
224
+
225
+ let lines = [...ms.outputBuffer];
226
+
227
+ // Filter by pattern
228
+ if (input.pattern) {
229
+ const regex = new RegExp(input.pattern, input.ignoreCase ? 'i' : '');
230
+ lines = lines.filter((line) => regex.test(line.text));
231
+ }
232
+
233
+ // Filter by timestamp
234
+ if (input.since) {
235
+ const sinceDate = new Date(input.since);
236
+ lines = lines.filter((line) => line.timestamp >= sinceDate);
237
+ }
238
+
239
+ const totalLines = lines.length;
240
+
241
+ // Apply pagination
242
+ const offset = input.offset || 0;
243
+ const limit = Math.min(input.limit || 100, 1000);
244
+
245
+ if (input.tail) {
246
+ // Return last N lines
247
+ lines = lines.slice(Math.max(0, lines.length - limit));
248
+ } else {
249
+ lines = lines.slice(offset, offset + limit);
250
+ }
251
+
252
+ return {
253
+ sessionId: input.sessionId,
254
+ lines: lines.map((l, idx) => ({
255
+ line: input.tail ? totalLines - lines.length + idx + 1 : offset + idx + 1,
256
+ text: l.text,
257
+ timestamp: l.timestamp.toISOString(),
258
+ })),
259
+ totalLines: ms.session.lineCount,
260
+ hasMore: offset + limit < totalLines,
261
+ sessionStatus: ms.session.status,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * List all PTY sessions
267
+ */
268
+ list(input: BashListInput = {}): BashListResponse {
269
+ const sessions: BashListResponse['sessions'] = [];
270
+ let running = 0,
271
+ exited = 0,
272
+ killed = 0;
273
+
274
+ for (const [, ms] of this.sessions) {
275
+ // Count by status
276
+ if (ms.session.status === 'running') running++;
277
+ else if (ms.session.status === 'exited') exited++;
278
+ else if (ms.session.status === 'killed') killed++;
279
+
280
+ // Filter by status
281
+ if (input.status && input.status !== 'all' && ms.session.status !== input.status) {
282
+ continue;
283
+ }
284
+
285
+ const sessionInfo: BashListResponse['sessions'][0] = {
286
+ sessionId: ms.session.id,
287
+ name: ms.session.name,
288
+ pid: ms.session.pid,
289
+ command: ms.session.command,
290
+ status: ms.session.status,
291
+ createdAt: ms.session.createdAt.toISOString(),
292
+ lineCount: ms.session.lineCount,
293
+ cwd: ms.session.cwd,
294
+ };
295
+
296
+ // Include recent output if requested
297
+ if (input.includeOutput) {
298
+ const outputLines = input.outputLines || 5;
299
+ sessionInfo.recentOutput = ms.outputBuffer
300
+ .slice(-outputLines)
301
+ .map((l) => l.text);
302
+ }
303
+
304
+ sessions.push(sessionInfo);
305
+ }
306
+
307
+ return {
308
+ sessions,
309
+ summary: {
310
+ total: this.sessions.size,
311
+ running,
312
+ exited,
313
+ killed,
314
+ },
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Kill a PTY session
320
+ */
321
+ async kill(input: BashKillInput): Promise<BashKillResponse> {
322
+ const ms = this.sessions.get(input.sessionId);
323
+ if (!ms) {
324
+ throw new Error(`Session not found: ${input.sessionId}`);
325
+ }
326
+
327
+ const signal = input.signal || 'SIGTERM';
328
+ const gracePeriod = input.gracePeriod || 5000;
329
+ const finalLineCount = ms.session.lineCount;
330
+
331
+ if (ms.session.status === 'running') {
332
+ // Send signal
333
+ ms.ptyProcess.kill(signal);
334
+
335
+ // Wait for graceful termination
336
+ if (signal === 'SIGTERM') {
337
+ await new Promise<void>((resolve) => {
338
+ const checkInterval = setInterval(() => {
339
+ if (ms.session.status !== 'running') {
340
+ clearInterval(checkInterval);
341
+ resolve();
342
+ }
343
+ }, 100);
344
+
345
+ setTimeout(() => {
346
+ clearInterval(checkInterval);
347
+ if (ms.session.status === 'running') {
348
+ ms.ptyProcess.kill('SIGKILL');
349
+ }
350
+ resolve();
351
+ }, gracePeriod);
352
+ });
353
+ }
354
+
355
+ ms.session.status = 'killed';
356
+ ms.session.signal = signal;
357
+ }
358
+
359
+ // Cleanup if requested
360
+ if (input.cleanup) {
361
+ this.sessions.delete(input.sessionId);
362
+ }
363
+
364
+ return {
365
+ sessionId: input.sessionId,
366
+ exitCode: ms.session.exitCode,
367
+ signal,
368
+ cleaned: input.cleanup || false,
369
+ finalLineCount,
370
+ message: input.cleanup
371
+ ? 'Session terminated and cleaned up'
372
+ : 'Session terminated successfully',
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Get a session by ID
378
+ */
379
+ getSession(sessionId: string): PTYSession | undefined {
380
+ return this.sessions.get(sessionId)?.session;
381
+ }
382
+
383
+ /**
384
+ * Shutdown all sessions
385
+ */
386
+ async shutdown(): Promise<void> {
387
+ const promises: Promise<BashKillResponse>[] = [];
388
+ for (const [sessionId, ms] of this.sessions) {
389
+ if (ms.session.status === 'running') {
390
+ promises.push(this.kill({ sessionId, cleanup: true }));
391
+ }
392
+ }
393
+ await Promise.all(promises);
394
+ this.sessions.clear();
395
+ }
396
+
397
+ /**
398
+ * Append output to a session's buffer
399
+ */
400
+ private appendOutput(sessionId: string, data: string): void {
401
+ const ms = this.sessions.get(sessionId);
402
+ if (!ms) return;
403
+
404
+ // Split by newlines and add to buffer
405
+ const lines = data.split(/\r?\n/);
406
+ const now = new Date();
407
+
408
+ for (const text of lines) {
409
+ if (text || lines.length === 1) {
410
+ ms.outputBuffer.push({
411
+ line: ms.session.lineCount + 1,
412
+ text,
413
+ timestamp: now,
414
+ });
415
+ ms.session.lineCount++;
416
+
417
+ // Trim buffer if too large
418
+ if (ms.outputBuffer.length > this.maxOutputLines) {
419
+ ms.outputBuffer.shift();
420
+ }
421
+ }
422
+ }
423
+
424
+ // Emit output event
425
+ this.emit('output', { sessionId, data });
426
+ }
427
+ }
428
+
429
+ // Export singleton instance
430
+ export const ptyManager = new PTYManager();
package/src/tools.ts ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * MCP Tool Definitions for Seahorse Bash Client
3
+ */
4
+
5
+ import { ToolDefinition } from './types';
6
+
7
+ export const TOOL_DEFINITIONS: ToolDefinition[] = [
8
+ {
9
+ name: 'bash_spawn',
10
+ description:
11
+ 'Create a new PTY session and run a command in background. Use this for long-running processes like dev servers, watchers, or any command that needs persistent terminal state.',
12
+ inputSchema: {
13
+ type: 'object',
14
+ properties: {
15
+ command: {
16
+ type: 'string',
17
+ description: "Command to execute (e.g., 'npm run dev', 'python server.py')",
18
+ },
19
+ args: {
20
+ type: 'array',
21
+ items: { type: 'string' },
22
+ description: 'Command arguments (optional)',
23
+ },
24
+ cwd: {
25
+ type: 'string',
26
+ description: 'Working directory (default: current directory)',
27
+ },
28
+ env: {
29
+ type: 'object',
30
+ additionalProperties: { type: 'string' },
31
+ description: 'Additional environment variables',
32
+ },
33
+ name: {
34
+ type: 'string',
35
+ description: 'Session name/identifier (optional, auto-generated if not provided)',
36
+ },
37
+ shell: {
38
+ type: 'string',
39
+ enum: ['bash', 'sh', 'zsh'],
40
+ default: 'bash',
41
+ description: 'Shell to use',
42
+ },
43
+ cols: {
44
+ type: 'integer',
45
+ default: 120,
46
+ description: 'Terminal columns',
47
+ },
48
+ rows: {
49
+ type: 'integer',
50
+ default: 30,
51
+ description: 'Terminal rows',
52
+ },
53
+ notifyOnExit: {
54
+ type: 'boolean',
55
+ default: true,
56
+ description: 'Whether to send notification when process exits',
57
+ },
58
+ },
59
+ required: ['command'],
60
+ },
61
+ },
62
+ {
63
+ name: 'bash_exec',
64
+ description:
65
+ 'Execute a command synchronously and wait for completion. Use this for quick commands that should finish within the timeout. Output is returned directly.',
66
+ inputSchema: {
67
+ type: 'object',
68
+ properties: {
69
+ command: {
70
+ type: 'string',
71
+ description: 'Bash command to execute',
72
+ },
73
+ cwd: {
74
+ type: 'string',
75
+ description: 'Working directory (optional)',
76
+ },
77
+ timeout: {
78
+ type: 'integer',
79
+ default: 30000,
80
+ description: 'Timeout in milliseconds (default 30s, max 600s)',
81
+ },
82
+ env: {
83
+ type: 'object',
84
+ additionalProperties: { type: 'string' },
85
+ description: 'Additional environment variables',
86
+ },
87
+ },
88
+ required: ['command'],
89
+ },
90
+ },
91
+ {
92
+ name: 'bash_write',
93
+ description:
94
+ 'Write data or send special keys to a running PTY session. Use this to send input to interactive programs, including Ctrl+C to interrupt.',
95
+ inputSchema: {
96
+ type: 'object',
97
+ properties: {
98
+ sessionId: {
99
+ type: 'string',
100
+ description: 'Target session ID',
101
+ },
102
+ data: {
103
+ type: 'string',
104
+ description: 'Text data to send',
105
+ },
106
+ specialKey: {
107
+ type: 'string',
108
+ enum: ['ctrl+c', 'ctrl+d', 'ctrl+z', 'enter', 'tab', 'up', 'down', 'left', 'right'],
109
+ description: 'Special key to send (alternative to data)',
110
+ },
111
+ },
112
+ required: ['sessionId'],
113
+ },
114
+ },
115
+ {
116
+ name: 'bash_read',
117
+ description:
118
+ 'Read output from a PTY session buffer. Supports pagination, filtering by regex pattern, and retrieving output since a specific timestamp.',
119
+ inputSchema: {
120
+ type: 'object',
121
+ properties: {
122
+ sessionId: {
123
+ type: 'string',
124
+ description: 'Target session ID',
125
+ },
126
+ offset: {
127
+ type: 'integer',
128
+ default: 0,
129
+ description: 'Starting line number (0-indexed)',
130
+ },
131
+ limit: {
132
+ type: 'integer',
133
+ default: 100,
134
+ maximum: 1000,
135
+ description: 'Maximum lines to return',
136
+ },
137
+ pattern: {
138
+ type: 'string',
139
+ description: 'Regex pattern to filter lines (optional)',
140
+ },
141
+ ignoreCase: {
142
+ type: 'boolean',
143
+ default: false,
144
+ description: 'Case-insensitive pattern matching',
145
+ },
146
+ since: {
147
+ type: 'string',
148
+ format: 'date-time',
149
+ description: 'Return output after this timestamp (optional)',
150
+ },
151
+ tail: {
152
+ type: 'boolean',
153
+ default: false,
154
+ description: 'If true, return last N lines instead of from offset',
155
+ },
156
+ },
157
+ required: ['sessionId'],
158
+ },
159
+ },
160
+ {
161
+ name: 'bash_list',
162
+ description:
163
+ 'List all PTY sessions with their status. Optionally include recent output from each session.',
164
+ inputSchema: {
165
+ type: 'object',
166
+ properties: {
167
+ status: {
168
+ type: 'string',
169
+ enum: ['all', 'running', 'exited', 'killed'],
170
+ default: 'all',
171
+ description: 'Filter by session status',
172
+ },
173
+ includeOutput: {
174
+ type: 'boolean',
175
+ default: false,
176
+ description: 'Include recent output lines',
177
+ },
178
+ outputLines: {
179
+ type: 'integer',
180
+ default: 5,
181
+ description: 'Number of output lines to include if includeOutput=true',
182
+ },
183
+ },
184
+ },
185
+ },
186
+ {
187
+ name: 'bash_kill',
188
+ description:
189
+ 'Terminate a PTY session. Sends SIGTERM by default with graceful shutdown, then SIGKILL if needed.',
190
+ inputSchema: {
191
+ type: 'object',
192
+ properties: {
193
+ sessionId: {
194
+ type: 'string',
195
+ description: 'Session ID to terminate',
196
+ },
197
+ signal: {
198
+ type: 'string',
199
+ enum: ['SIGTERM', 'SIGKILL', 'SIGINT'],
200
+ default: 'SIGTERM',
201
+ description: 'Signal to send',
202
+ },
203
+ cleanup: {
204
+ type: 'boolean',
205
+ default: false,
206
+ description: 'If true, remove session from list (deletes output buffer)',
207
+ },
208
+ gracePeriod: {
209
+ type: 'integer',
210
+ default: 5000,
211
+ description: 'Time to wait before SIGKILL after SIGTERM (ms)',
212
+ },
213
+ },
214
+ required: ['sessionId'],
215
+ },
216
+ },
217
+ ];