├── .gitignore ├── .github └── images │ └── demo.gif ├── tsconfig.json ├── smithery.yaml ├── src ├── TtyOutputReader.ts ├── SendControlCharacter.ts ├── CommandExecutor.ts ├── index.ts └── ProcessTracker.ts ├── LICENSE.md ├── Dockerfile ├── test └── CommandExecutor.test.ts ├── package.json ├── README.md └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | *.log 4 | .env* 5 | tmp -------------------------------------------------------------------------------- /.github/images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lite/iterm-mcp/main/.github/images/demo.gif -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: 9 | - nodeVersion 10 | properties: 11 | nodeVersion: 12 | type: string 13 | description: Ensure Node version 18 or greater is installed. 14 | commandFunction: 15 | # A function that produces the CLI command to start the MCP on stdio. 16 | |- 17 | (config) => ({ command: 'node', args: ['build/index.js'], env: {} }) 18 | -------------------------------------------------------------------------------- /src/TtyOutputReader.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'node:child_process'; 2 | import { promisify } from 'node:util'; 3 | 4 | const execPromise = promisify(exec); 5 | 6 | export default class TtyOutputReader { 7 | static async call(linesOfOutput?: number) { 8 | const buffer = await this.retrieveBuffer(); 9 | if (!linesOfOutput) { 10 | return buffer; 11 | } 12 | const lines = buffer.split('\n'); 13 | return lines.slice(-linesOfOutput - 1).join('\n'); 14 | } 15 | 16 | static async retrieveBuffer(): Promise { 17 | const ascript = ` 18 | tell application "iTerm2" 19 | tell front window 20 | tell current session of current tab 21 | set numRows to number of rows 22 | set allContent to contents 23 | return allContent 24 | end tell 25 | end tell 26 | end tell 27 | `; 28 | 29 | const { stdout: finalContent } = await execPromise(`osascript -e '${ascript}'`); 30 | return finalContent.trim(); 31 | } 32 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2025 Ferris Lucas 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | # Use an official Node runtime as a parent image 3 | FROM node:18-alpine AS builder 4 | 5 | # Set the working directory 6 | WORKDIR /app 7 | 8 | # Copy the package.json and yarn.lock 9 | COPY package.json yarn.lock ./ 10 | 11 | # Install dependencies 12 | RUN yarn install --frozen-lockfile 13 | 14 | # Copy the rest of the application code 15 | COPY . . 16 | 17 | # Build the TypeScript application 18 | RUN yarn run build 19 | 20 | # Start a new stage from the official Node.js image 21 | FROM node:18-alpine 22 | 23 | # Set the working directory 24 | WORKDIR /app 25 | 26 | # Copy only the built files from the builder stage 27 | COPY --from=builder /app/build ./build 28 | COPY --from=builder /app/package.json ./ 29 | COPY --from=builder /app/yarn.lock ./ 30 | 31 | # Install production dependencies only 32 | RUN yarn install --production --frozen-lockfile 33 | 34 | # Expose port if needed (example: 3000) 35 | # EXPOSE 3000 36 | 37 | # Run the application 38 | ENTRYPOINT ["node", "build/index.js"] 39 | -------------------------------------------------------------------------------- /test/CommandExecutor.test.ts: -------------------------------------------------------------------------------- 1 | import CommandExecutor from '../src/CommandExecutor.js'; 2 | import TtyOutputReader from '../src/TtyOutputReader.js'; 3 | 4 | async function testExecuteCommand() { 5 | const executor = new CommandExecutor(); 6 | // Combine all arguments after the script name into a single command 7 | const command = process.argv.slice(2).join(' ') || 'date'; 8 | 9 | try { 10 | const beforeCommandBuffer = await TtyOutputReader.retrieveBuffer(); 11 | const beforeCommandBufferLines = beforeCommandBuffer.split("\n").length; 12 | 13 | await executor.executeCommand(command); 14 | 15 | const afterCommandBuffer = await TtyOutputReader.retrieveBuffer(); 16 | const afterCommandBufferLines = afterCommandBuffer.split("\n").length; 17 | const outputLines = afterCommandBufferLines - beforeCommandBufferLines 18 | 19 | const buffer = await TtyOutputReader.call(outputLines) 20 | console.log(buffer); 21 | 22 | console.log(`Lines: ${outputLines}`); 23 | } catch (error) { 24 | console.error('Error executing command:', (error as Error).message); 25 | } 26 | 27 | 28 | } 29 | 30 | testExecuteCommand(); -------------------------------------------------------------------------------- /src/SendControlCharacter.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'node:child_process'; 2 | import { promisify } from 'node:util'; 3 | 4 | const execPromise = promisify(exec); 5 | 6 | class SendControlCharacter { 7 | async send(letter: string): Promise { 8 | // Validate input 9 | letter = letter.toUpperCase(); 10 | if (!/^[A-Z]$/.test(letter)) { 11 | throw new Error('Invalid control character letter'); 12 | } 13 | 14 | // Convert to control code 15 | const controlCode = letter.charCodeAt(0) - 64; 16 | 17 | // AppleScript to send the control character 18 | const ascript = ` 19 | tell application "iTerm2" 20 | tell front window 21 | tell current session of current tab 22 | -- Send the control character 23 | write text (ASCII character ${controlCode}) 24 | end tell 25 | end tell 26 | end tell 27 | `; 28 | 29 | try { 30 | await execPromise(`osascript -e '${ascript}'`); 31 | } catch (error: unknown) { 32 | throw new Error(`Failed to send control character: ${(error as Error).message}`); 33 | } 34 | } 35 | } 36 | 37 | export default SendControlCharacter; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "iterm-mcp", 3 | "version": "1.2.3", 4 | "description": "A Model Context Protocol server that provides access to the currently active tab of iTerm", 5 | "homepage": "https://github.com/ferrislucas/iterm-mcp#readme", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/ferrislucas/iterm-mcp.git" 9 | }, 10 | "author": "Ferris Lucas", 11 | "bugs": { 12 | "url": "https://github.com/ferrislucas/iterm-mcp/issues" 13 | }, 14 | "type": "module", 15 | "license": "MIT", 16 | "bin": { 17 | "iterm-mcp": "./build/index.js" 18 | }, 19 | "files": [ 20 | "build" 21 | ], 22 | "scripts": { 23 | "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", 24 | "prepublishOnly": "yarn run build", 25 | "watch": "tsc --watch", 26 | "inspector": "npx @modelcontextprotocol/inspector build/index.js", 27 | "debug": "ts-node --esm test/CommandExecutor.test.ts" 28 | }, 29 | "dependencies": { 30 | "@modelcontextprotocol/sdk": "0.6.0" 31 | }, 32 | "devDependencies": { 33 | "@types/node": "^20.11.24", 34 | "ts-node": "^10.9.2", 35 | "typescript": "^5.3.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # iterm-mcp 2 | A Model Context Protocol server that provides access to your iTerm session. 3 | 4 | ![Main Image](.github/images/demo.gif) 5 | 6 | ### Features 7 | 8 | **Efficient Token Use:** iterm-mcp gives the model the ability to inspect only the output that the model is interested in. The model typically only wants to see the last few lines of output even for long running commands. 9 | 10 | **Natural Integration:** You share iTerm with the model. You can ask questions about what's on the screen, or delegate a task to the model and watch as it performs each step. 11 | 12 | **Full Terminal Control and REPL support:** The model can start and interact with REPL's as well as send control characters like ctrl-c, ctrl-z, etc. 13 | 14 | **Easy on the Dependencies:** iterm-mcp is built with minimal dependencies and is runnable via npx. It's designed to be easy to add to Claude Desktop and other MCP clients. It should just work. 15 | 16 | 17 | iTerm Server MCP server 18 | 19 | ## Safety Considerations 20 | 21 | * The user is responsible for using the tool safely. 22 | * No built-in restrictions: iterm-mcp makes no attempt to evaluate the safety of commands that are executed. 23 | * Models can behave in unexpected ways. The user is expected to monitor activity and abort when appropriate. 24 | * For multi-step tasks, you may need to interrupt the model if it goes off track. Start with smaller, focused tasks until you're familiar with how the model behaves. 25 | 26 | ### Tools 27 | - `write_to_terminal` - Writes to the active iTerm terminal, often used to run a command. Returns the number of lines of output produced by the command. 28 | - `read_terminal_output` - Reads the requested number of lines from the active iTerm terminal. 29 | - `send_control_character` - Sends a control character to the active iTerm terminal. 30 | 31 | ### Requirements 32 | 33 | * iTerm2 must be running 34 | * Node version 18 or greater 35 | 36 | 37 | ## Installation 38 | 39 | To use with Claude Desktop, add the server config: 40 | 41 | On macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` 42 | On Windows: `%APPDATA%/Claude/claude_desktop_config.json` 43 | 44 | ```json 45 | { 46 | "mcpServers": { 47 | "iterm-mcp": { 48 | "command": "npx", 49 | "args": [ 50 | "-y", 51 | "iterm-mcp" 52 | ] 53 | } 54 | } 55 | } 56 | ``` 57 | 58 | ### Installing via Smithery 59 | 60 | To install iTerm for Claude Desktop automatically via [Smithery](https://smithery.ai/server/iterm-mcp): 61 | 62 | ```bash 63 | npx -y @smithery/cli install iterm-mcp --client claude 64 | ``` 65 | 66 | [![smithery badge](https://smithery.ai/badge/@lite/iterm-mcp)](https://smithery.ai/server/@lite/iterm-mcp) 67 | 68 | ## Development 69 | 70 | Install dependencies: 71 | ```bash 72 | yarn install 73 | ``` 74 | 75 | Build the server: 76 | ```bash 77 | yarn run build 78 | ``` 79 | 80 | For development with auto-rebuild: 81 | ```bash 82 | yarn run watch 83 | ``` 84 | 85 | ### Debugging 86 | 87 | Since MCP servers communicate over stdio, debugging can be challenging. We recommend using the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), which is available as a package script: 88 | 89 | ```bash 90 | yarn run inspector 91 | yarn debug 92 | ``` 93 | 94 | The Inspector will provide a URL to access debugging tools in your browser. 95 | -------------------------------------------------------------------------------- /src/CommandExecutor.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'node:child_process'; 2 | import { promisify } from 'node:util'; 3 | import { openSync, closeSync, appendFileSync, writeFileSync, existsSync } from 'node:fs'; 4 | import { join } from 'node:path'; 5 | import ProcessTracker from './ProcessTracker.js'; 6 | import TtyOutputReader from './TtyOutputReader.js'; 7 | import { after } from 'node:test'; 8 | 9 | const execPromise = promisify(exec); 10 | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 11 | 12 | class CommandExecutor { 13 | async executeCommand(command: string): Promise { 14 | const escapedCommand = this.escapeForAppleScript(command); 15 | 16 | try { 17 | await execPromise(`/usr/bin/osascript -e 'tell application "iTerm2" to tell current session of current window to write text "${escapedCommand}"'`); 18 | 19 | // Wait until iterm reports that processing is done 20 | while (await this.isProcessing()) { 21 | await sleep(100); 22 | } 23 | 24 | const ttyPath = await this.retrieveTtyPath(); 25 | while (await this.isWaitingForUserInput(ttyPath) === false) { 26 | await sleep(100); 27 | } 28 | 29 | // Give a small delay for output to settle 30 | await sleep(200); 31 | 32 | const afterCommandBuffer = await TtyOutputReader.retrieveBuffer() 33 | return afterCommandBuffer 34 | } catch (error: unknown) { 35 | throw new Error(`Failed to execute command: ${(error as Error).message}`); 36 | } 37 | } 38 | 39 | async isWaitingForUserInput(ttyPath: string): Promise { 40 | let fd; 41 | try { 42 | // Open the TTY file descriptor in non-blocking mode 43 | fd = openSync(ttyPath, 'r'); 44 | const tracker = new ProcessTracker(); 45 | let belowThresholdTime = 0; 46 | 47 | while (true) { 48 | try { 49 | const activeProcess = await tracker.getActiveProcess(ttyPath); 50 | 51 | if (!activeProcess) return true; 52 | 53 | if (activeProcess.metrics.totalCPUPercent < 1) { 54 | belowThresholdTime += 350; 55 | if (belowThresholdTime >= 1000) return true; 56 | } else { 57 | belowThresholdTime = 0; 58 | } 59 | 60 | } catch { 61 | return true; 62 | } 63 | 64 | await sleep(350); 65 | } 66 | } catch (error: unknown) { 67 | return true; 68 | } finally { 69 | if (fd !== undefined) { 70 | closeSync(fd); 71 | } 72 | return true; 73 | } 74 | } 75 | 76 | private escapeForAppleScript(str: string): string { 77 | // First, escape any backslashes 78 | str = str.replace(/\\/g, '\\\\'); 79 | 80 | // Escape double quotes 81 | str = str.replace(/"/g, '\\"'); 82 | 83 | // Handle single quotes by breaking out of the quote, escaping the quote, and going back in 84 | str = str.replace(/'/g, "'\\''"); 85 | 86 | // Handle special characters 87 | str = str.replace(/[^\x20-\x7E]/g, (char) => { 88 | return '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0'); 89 | }); 90 | 91 | return str; 92 | } 93 | 94 | private async retrieveTtyPath(): Promise { 95 | try { 96 | const { stdout } = await execPromise(`/usr/bin/osascript -e 'tell application "iTerm2" to tell current session of current window to get tty'`); 97 | return stdout.trim(); 98 | } catch (error: unknown) { 99 | throw new Error(`Failed to retrieve TTY path: ${(error as Error).message}`); 100 | } 101 | } 102 | 103 | private async isProcessing(): Promise { 104 | try { 105 | const { stdout } = await execPromise(`/usr/bin/osascript -e 'tell application "iTerm2" to tell current session of current window to get is processing'`); 106 | return stdout.trim() === 'true'; 107 | } catch (error: unknown) { 108 | throw new Error(`Failed to check processing status: ${(error as Error).message}`); 109 | } 110 | } 111 | } 112 | 113 | export default CommandExecutor; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 5 | import { 6 | CallToolRequestSchema, 7 | ListToolsRequestSchema, 8 | } from "@modelcontextprotocol/sdk/types.js"; 9 | import CommandExecutor from "./CommandExecutor.js"; 10 | import TtyOutputReader from "./TtyOutputReader.js"; 11 | import SendControlCharacter from "./SendControlCharacter.js"; 12 | 13 | const server = new Server( 14 | { 15 | name: "iterm-mcp", 16 | version: "0.1.0", 17 | }, 18 | { 19 | capabilities: { 20 | tools: {}, 21 | }, 22 | } 23 | ); 24 | 25 | server.setRequestHandler(ListToolsRequestSchema, async () => { 26 | return { 27 | tools: [ 28 | { 29 | name: "write_to_terminal", 30 | description: "Writes text to the active iTerm terminal - often used to run a command in the terminal", 31 | inputSchema: { 32 | type: "object", 33 | properties: { 34 | command: { 35 | type: "string", 36 | description: "The command to run or text to write to the terminal" 37 | }, 38 | }, 39 | required: ["command"] 40 | } 41 | }, 42 | { 43 | name: "read_terminal_output", 44 | description: "Reads the output from the active iTerm terminal", 45 | inputSchema: { 46 | type: "object", 47 | properties: { 48 | linesOfOutput: { 49 | type: "number", 50 | description: "The number of lines of output to read." 51 | }, 52 | }, 53 | required: ["linesOfOutput"] 54 | } 55 | }, 56 | { 57 | name: "send_control_character", 58 | description: "Sends a control character to the active iTerm terminal (e.g., Control-C)", 59 | inputSchema: { 60 | type: "object", 61 | properties: { 62 | letter: { 63 | type: "string", 64 | description: "The letter corresponding to the control character (e.g., 'C' for Control-C)" 65 | }, 66 | }, 67 | required: ["letter"] 68 | } 69 | } 70 | ] 71 | }; 72 | }); 73 | 74 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 75 | switch (request.params.name) { 76 | case "write_to_terminal": { 77 | let executor = new CommandExecutor(); 78 | const command = String(request.params.arguments?.command); 79 | const beforeCommandBuffer = await TtyOutputReader.retrieveBuffer(); 80 | const beforeCommandBufferLines = beforeCommandBuffer.split("\n").length; 81 | 82 | await executor.executeCommand(command); 83 | 84 | const afterCommandBuffer = await TtyOutputReader.retrieveBuffer(); 85 | const afterCommandBufferLines = afterCommandBuffer.split("\n").length; 86 | const outputLines = afterCommandBufferLines - beforeCommandBufferLines 87 | 88 | return { 89 | content: [{ 90 | type: "text", 91 | text: `${outputLines} lines were output after sending the command to the terminal. Read the last ${outputLines} lines of terminal contents to orient yourself. Never assume that the command was executed or that it was successful.` 92 | }] 93 | }; 94 | } 95 | case "read_terminal_output": { 96 | const linesOfOutput = Number(request.params.arguments?.linesOfOutput) || 25 97 | const output = await TtyOutputReader.call(linesOfOutput) 98 | 99 | return { 100 | content: [{ 101 | type: "text", 102 | text: output 103 | }] 104 | }; 105 | } 106 | case "send_control_character": { 107 | const ttyControl = new SendControlCharacter(); 108 | const letter = String(request.params.arguments?.letter); 109 | await ttyControl.send(letter); 110 | 111 | return { 112 | content: [{ 113 | type: "text", 114 | text: `Sent control character: Control-${letter.toUpperCase()}` 115 | }] 116 | }; 117 | } 118 | default: 119 | throw new Error("Unknown tool"); 120 | } 121 | }); 122 | 123 | async function main() { 124 | const transport = new StdioServerTransport(); 125 | await server.connect(transport); 126 | } 127 | 128 | main().catch((error) => { 129 | console.error("Server error:", error); 130 | process.exit(1); 131 | }); -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@cspotcode/source-map-support@^0.8.0": 6 | version "0.8.1" 7 | resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" 8 | integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== 9 | dependencies: 10 | "@jridgewell/trace-mapping" "0.3.9" 11 | 12 | "@jridgewell/resolve-uri@^3.0.3": 13 | version "3.1.2" 14 | resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" 15 | integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== 16 | 17 | "@jridgewell/sourcemap-codec@^1.4.10": 18 | version "1.5.0" 19 | resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" 20 | integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== 21 | 22 | "@jridgewell/trace-mapping@0.3.9": 23 | version "0.3.9" 24 | resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" 25 | integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== 26 | dependencies: 27 | "@jridgewell/resolve-uri" "^3.0.3" 28 | "@jridgewell/sourcemap-codec" "^1.4.10" 29 | 30 | "@modelcontextprotocol/sdk@0.6.0": 31 | version "0.6.0" 32 | resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-0.6.0.tgz#a691c0aa634a2ac4b61ee7cb4a24603120fc1f4f" 33 | integrity sha512-9rsDudGhDtMbvxohPoMMyAUOmEzQsOK+XFchh6gZGqo8sx9sBuZQs+CUttXqa8RZXKDaJRCN2tUtgGof7jRkkw== 34 | dependencies: 35 | content-type "^1.0.5" 36 | raw-body "^3.0.0" 37 | zod "^3.23.8" 38 | 39 | "@tsconfig/node10@^1.0.7": 40 | version "1.0.11" 41 | resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2" 42 | integrity sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw== 43 | 44 | "@tsconfig/node12@^1.0.7": 45 | version "1.0.11" 46 | resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" 47 | integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== 48 | 49 | "@tsconfig/node14@^1.0.0": 50 | version "1.0.3" 51 | resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" 52 | integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== 53 | 54 | "@tsconfig/node16@^1.0.2": 55 | version "1.0.4" 56 | resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" 57 | integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== 58 | 59 | "@types/node@^20.11.24": 60 | version "20.17.12" 61 | resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.12.tgz#ee3b7d25a522fd95608c1b3f02921c97b93fcbd6" 62 | integrity sha512-vo/wmBgMIiEA23A/knMfn/cf37VnuF52nZh5ZoW0GWt4e4sxNquibrMRJ7UQsA06+MBx9r/H1jsI9grYjQCQlw== 63 | dependencies: 64 | undici-types "~6.19.2" 65 | 66 | acorn-walk@^8.1.1: 67 | version "8.3.4" 68 | resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.4.tgz#794dd169c3977edf4ba4ea47583587c5866236b7" 69 | integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== 70 | dependencies: 71 | acorn "^8.11.0" 72 | 73 | acorn@^8.11.0, acorn@^8.4.1: 74 | version "8.14.0" 75 | resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0" 76 | integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== 77 | 78 | arg@^4.1.0: 79 | version "4.1.3" 80 | resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" 81 | integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== 82 | 83 | bytes@3.1.2: 84 | version "3.1.2" 85 | resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" 86 | integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== 87 | 88 | content-type@^1.0.5: 89 | version "1.0.5" 90 | resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" 91 | integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== 92 | 93 | create-require@^1.1.0: 94 | version "1.1.1" 95 | resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" 96 | integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== 97 | 98 | depd@2.0.0: 99 | version "2.0.0" 100 | resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" 101 | integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== 102 | 103 | diff@^4.0.1: 104 | version "4.0.2" 105 | resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" 106 | integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== 107 | 108 | http-errors@2.0.0: 109 | version "2.0.0" 110 | resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" 111 | integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== 112 | dependencies: 113 | depd "2.0.0" 114 | inherits "2.0.4" 115 | setprototypeof "1.2.0" 116 | statuses "2.0.1" 117 | toidentifier "1.0.1" 118 | 119 | iconv-lite@0.6.3: 120 | version "0.6.3" 121 | resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" 122 | integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== 123 | dependencies: 124 | safer-buffer ">= 2.1.2 < 3.0.0" 125 | 126 | inherits@2.0.4: 127 | version "2.0.4" 128 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" 129 | integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 130 | 131 | make-error@^1.1.1: 132 | version "1.3.6" 133 | resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" 134 | integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== 135 | 136 | raw-body@^3.0.0: 137 | version "3.0.0" 138 | resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f" 139 | integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g== 140 | dependencies: 141 | bytes "3.1.2" 142 | http-errors "2.0.0" 143 | iconv-lite "0.6.3" 144 | unpipe "1.0.0" 145 | 146 | "safer-buffer@>= 2.1.2 < 3.0.0": 147 | version "2.1.2" 148 | resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" 149 | integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== 150 | 151 | setprototypeof@1.2.0: 152 | version "1.2.0" 153 | resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" 154 | integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== 155 | 156 | statuses@2.0.1: 157 | version "2.0.1" 158 | resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" 159 | integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== 160 | 161 | toidentifier@1.0.1: 162 | version "1.0.1" 163 | resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" 164 | integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== 165 | 166 | ts-node@^10.9.2: 167 | version "10.9.2" 168 | resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" 169 | integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== 170 | dependencies: 171 | "@cspotcode/source-map-support" "^0.8.0" 172 | "@tsconfig/node10" "^1.0.7" 173 | "@tsconfig/node12" "^1.0.7" 174 | "@tsconfig/node14" "^1.0.0" 175 | "@tsconfig/node16" "^1.0.2" 176 | acorn "^8.4.1" 177 | acorn-walk "^8.1.1" 178 | arg "^4.1.0" 179 | create-require "^1.1.0" 180 | diff "^4.0.1" 181 | make-error "^1.1.1" 182 | v8-compile-cache-lib "^3.0.1" 183 | yn "3.1.1" 184 | 185 | typescript@^5.3.3: 186 | version "5.7.2" 187 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.2.tgz#3169cf8c4c8a828cde53ba9ecb3d2b1d5dd67be6" 188 | integrity sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg== 189 | 190 | undici-types@~6.19.2: 191 | version "6.19.8" 192 | resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" 193 | integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== 194 | 195 | unpipe@1.0.0: 196 | version "1.0.0" 197 | resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" 198 | integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== 199 | 200 | v8-compile-cache-lib@^3.0.1: 201 | version "3.0.1" 202 | resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" 203 | integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== 204 | 205 | yn@3.1.1: 206 | version "3.1.1" 207 | resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" 208 | integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== 209 | 210 | zod@^3.23.8: 211 | version "3.24.1" 212 | resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.1.tgz#27445c912738c8ad1e9de1bea0359fa44d9d35ee" 213 | integrity sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A== 214 | -------------------------------------------------------------------------------- /src/ProcessTracker.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import { promisify } from 'util'; 3 | import { existsSync } from 'fs'; 4 | import { basename } from 'path'; 5 | 6 | const execAsync = promisify(exec); 7 | 8 | interface ProcessInfo { 9 | pid: string; 10 | ppid: string; 11 | pgid: string; 12 | sess: string; 13 | state: string; 14 | command: string; 15 | children: ProcessInfo[]; 16 | cpuPercent: number; 17 | memory: string; 18 | time: string; 19 | } 20 | 21 | interface ProcessMetrics { 22 | totalCPUPercent: number; 23 | totalMemoryMB: number; 24 | processBreakdown: { 25 | name: string; 26 | pid: string; 27 | cpuPercent: number; 28 | memory: string; 29 | }[]; 30 | } 31 | 32 | interface ActiveProcess { 33 | pid: string; 34 | ppid: string; 35 | pgid: string; 36 | name: string; 37 | command: string; 38 | state: string; 39 | commandChain: string; 40 | environment?: string; 41 | applicationContext?: string; 42 | metrics: ProcessMetrics; 43 | } 44 | 45 | class ProcessTracker { 46 | private readonly shellNames = new Set(['bash', 'zsh', 'sh', 'fish', 'csh', 'tcsh']); 47 | private readonly replNames = new Set([ 48 | 'irb', 'pry', 'rails', 'node', 'python', 'ipython', 49 | 'scala', 'ghci', 'iex', 'lein', 'clj', 'julia', 'R', 'php', 'lua' 50 | ]); 51 | 52 | /** 53 | * Get the active process and its resource usage in an iTerm tab 54 | */ 55 | async getActiveProcess(ttyPath: string): Promise { 56 | try { 57 | if (!existsSync(ttyPath)) { 58 | throw new Error(`TTY path does not exist: ${ttyPath}`); 59 | } 60 | 61 | const ttyName = basename(ttyPath); 62 | const processes = await this.getProcessesForTTY(ttyName); 63 | 64 | if (!processes.length) { 65 | return null; 66 | } 67 | 68 | const fgPgid = await this.getForegroundProcessGroup(ttyName); 69 | if (!fgPgid) { 70 | return null; 71 | } 72 | 73 | // Get all processes in the foreground process group 74 | const fgProcesses = processes.filter(p => p.pgid === fgPgid); 75 | if (!fgProcesses.length) { 76 | return null; 77 | } 78 | 79 | const activeProcess = this.findMostInterestingProcess(fgProcesses); 80 | const commandChain = this.buildCommandChain(activeProcess, processes); 81 | const { environment, applicationContext } = this.detectEnvironment(activeProcess, processes); 82 | 83 | // Build the process tree and calculate metrics 84 | const metrics = this.calculateProcessMetrics(activeProcess, processes); 85 | 86 | return { 87 | pid: activeProcess.pid, 88 | ppid: activeProcess.ppid, 89 | pgid: activeProcess.pgid, 90 | name: this.getProcessName(activeProcess.command), 91 | command: activeProcess.command, 92 | state: activeProcess.state, 93 | commandChain, 94 | environment, 95 | applicationContext, 96 | metrics 97 | }; 98 | 99 | } catch (error) { 100 | console.error('Error getting active process:', error); 101 | return null; 102 | } 103 | } 104 | 105 | /** 106 | * Get all processes associated with a TTY including resource usage 107 | */ 108 | private async getProcessesForTTY(ttyName: string): Promise { 109 | try { 110 | // Include CPU%, memory, and accumulated CPU time in the output 111 | const { stdout } = await execAsync( 112 | `ps -t ${ttyName} -o pid,ppid,pgid,sess,state,%cpu,rss,time,command -w` 113 | ); 114 | 115 | const lines = stdout.trim().split('\n'); 116 | if (lines.length < 2) { 117 | return []; 118 | } 119 | 120 | const processes: ProcessInfo[] = []; 121 | const processByPid: Record = {}; 122 | 123 | // Parse all processes (skip header line) 124 | for (const line of lines.slice(1)) { 125 | const parts = line.trim().split(/\s+/); 126 | if (parts.length >= 9) { 127 | const process: ProcessInfo = { 128 | pid: parts[0], 129 | ppid: parts[1], 130 | pgid: parts[2], 131 | sess: parts[3], 132 | state: parts[4], 133 | cpuPercent: parseFloat(parts[5]), 134 | memory: parts[6], // RSS in KB 135 | time: parts[7], // Accumulated CPU time 136 | command: parts.slice(8).join(' '), 137 | children: [] 138 | }; 139 | processes.push(process); 140 | processByPid[process.pid] = process; 141 | } 142 | } 143 | 144 | // Build process tree 145 | for (const process of processes) { 146 | const parent = processByPid[process.ppid]; 147 | if (parent) { 148 | parent.children.push(process); 149 | } 150 | } 151 | 152 | return processes; 153 | } catch (error) { 154 | console.error('Error getting processes:', error); 155 | return []; 156 | } 157 | } 158 | 159 | /** 160 | * Get the foreground process group ID for a TTY 161 | */ 162 | private async getForegroundProcessGroup(ttyName: string): Promise { 163 | try { 164 | const { stdout } = await execAsync( 165 | `bash -c 'ps -o pgid= -t ${ttyName} | head -n1'` 166 | ); 167 | return stdout.trim(); 168 | } catch { 169 | return null; 170 | } 171 | } 172 | 173 | /** 174 | * Detect the environment and context of the process 175 | */ 176 | private detectEnvironment( 177 | process: ProcessInfo, 178 | allProcesses: ProcessInfo[] 179 | ): { environment?: string; applicationContext?: string } { 180 | const cmd = process.command.toLowerCase(); 181 | const cmdParts = cmd.split(/\s+/); 182 | const name = this.getProcessName(process.command).toLowerCase(); 183 | 184 | // Check for Rails console 185 | if (cmd.includes('rails console') || (name === 'ruby' && cmd.includes('rails server'))) { 186 | // Try to extract Rails environment and app name 187 | const envMatch = cmd.match(/RAILS_ENV=(\w+)/); 188 | const appNameMatch = process.command.match(/\/([^/]+)\/config\/environment/); 189 | 190 | const environment = 'Rails Console'; 191 | const railsEnv = envMatch?.[1] || 'development'; 192 | const appName = appNameMatch?.[1] || 'Rails App'; 193 | 194 | return { 195 | environment, 196 | applicationContext: `${appName} (${railsEnv})` 197 | }; 198 | } 199 | 200 | // Check for other REPLs 201 | if (this.replNames.has(name)) { 202 | const replMap: Record = { 203 | 'irb': 'Ruby IRB', 204 | 'pry': 'Pry Console', 205 | 'node': 'Node.js REPL', 206 | 'python': 'Python REPL', 207 | 'ipython': 'IPython Console' 208 | }; 209 | 210 | return { 211 | environment: replMap[name] || `${name.toUpperCase()} REPL` 212 | }; 213 | } 214 | 215 | // Check for package managers 216 | if (name === 'brew' || name === 'npm' || name === 'yarn' || name === 'pip') { 217 | return { 218 | environment: `${name.charAt(0).toUpperCase() + name.slice(1)} Package Manager` 219 | }; 220 | } 221 | 222 | return {}; 223 | } 224 | 225 | /** 226 | * Find the most interesting process from a list of processes 227 | */ 228 | private findMostInterestingProcess(processes: ProcessInfo[]): ProcessInfo { 229 | return processes.reduce((best, current) => { 230 | const bestScore = this.calculateProcessScore(best); 231 | const currentScore = this.calculateProcessScore(current); 232 | return currentScore > bestScore ? current : best; 233 | }, processes[0]); 234 | } 235 | 236 | /** 237 | * Calculate how interesting a process is based on various factors 238 | */ 239 | private calculateProcessScore(process: ProcessInfo): number { 240 | const cmdName = this.getProcessName(process.command); 241 | const cmd = process.command.toLowerCase(); 242 | 243 | let score = 0; 244 | 245 | // Base scores for process state 246 | // 'R' (running) processes get 2 points, 'S' (sleeping) get 1 point 247 | score += process.state === 'R' ? 2 : process.state === 'S' ? 1 : 0; 248 | 249 | // CPU usage bonus 250 | // Add up to 5 points based on CPU usage percentage (1 point per 10%) 251 | score += Math.min(process.cpuPercent / 10, 5); 252 | 253 | // Penalize shell processes unless they're the only option 254 | // Shell processes are less interesting, so deduct 1 point 255 | if (this.shellNames.has(cmdName)) { 256 | score -= 1; 257 | } 258 | 259 | // Give high priority to REPL processes 260 | // Add 3 points for REPLY processes 261 | if (this.replNames.has(cmdName)) { 262 | score += 3; 263 | } 264 | 265 | 266 | // Bonus for active package manager operations 267 | // Add 2 points for package managers like 'brew', 'npm', or 'yarn' if they are using CPU 268 | if ((cmdName === 'brew' || cmdName === 'npm' || cmdName === 'yarn') && 269 | process.cpuPercent > 0) { 270 | score += 2; 271 | } 272 | 273 | return score; 274 | } 275 | 276 | /** 277 | * Get the base process name from a command 278 | */ 279 | private getProcessName(command: string): string { 280 | return basename(command.split(/\s+/)[0]); 281 | } 282 | 283 | /** 284 | * Build the command chain showing process hierarchy 285 | */ 286 | private buildCommandChain( 287 | process: ProcessInfo, 288 | allProcesses: ProcessInfo[] 289 | ): string { 290 | const processByPid: Record = {}; 291 | for (const p of allProcesses) { 292 | processByPid[p.pid] = p; 293 | } 294 | 295 | const chain: string[] = []; 296 | let current: ProcessInfo | undefined = process; 297 | const maxChainLength = 10; 298 | 299 | while (current && chain.length < maxChainLength) { 300 | const name = this.getProcessName(current.command); 301 | 302 | // Add context for special processes 303 | if (name === 'ruby' && current.command.includes('rails console')) { 304 | chain.push('rails console'); 305 | } else if (name === 'brew' && current.command.includes('install')) { 306 | chain.push(`brew install ${current.command.split('install')[1].trim()}`); 307 | } else { 308 | chain.push(name); 309 | } 310 | 311 | current = processByPid[current.ppid]; 312 | } 313 | 314 | return chain.reverse().join(' -> '); 315 | } 316 | 317 | /** 318 | * Calculate resource metrics for a process and all its descendants 319 | */ 320 | private calculateProcessMetrics( 321 | process: ProcessInfo, 322 | allProcesses: ProcessInfo[] 323 | ): ProcessMetrics { 324 | // Get all descendant PIDs 325 | const descendants = this.getAllDescendants(process, allProcesses); 326 | const allRelatedProcesses = [process, ...descendants]; 327 | 328 | // Calculate totals 329 | let totalCPUPercent = 0; 330 | let totalMemoryMB = 0; 331 | const processBreakdown: ProcessMetrics['processBreakdown'] = []; 332 | 333 | for (const proc of allRelatedProcesses) { 334 | const cpuPercent = proc.cpuPercent; 335 | const memoryMB = this.parseMemoryString(proc.memory); 336 | 337 | totalCPUPercent += cpuPercent; 338 | totalMemoryMB += memoryMB; 339 | 340 | // Only include in breakdown if using significant resources 341 | if (cpuPercent > 0.1 || memoryMB > 5) { 342 | processBreakdown.push({ 343 | name: this.getProcessName(proc.command), 344 | pid: proc.pid, 345 | cpuPercent: cpuPercent, 346 | memory: proc.memory 347 | }); 348 | } 349 | } 350 | 351 | // Sort breakdown by CPU usage 352 | processBreakdown.sort((a, b) => b.cpuPercent - a.cpuPercent); 353 | 354 | return { 355 | totalCPUPercent, 356 | totalMemoryMB, 357 | processBreakdown 358 | }; 359 | } 360 | 361 | /** 362 | * Get all descendant processes of a given process 363 | */ 364 | private getAllDescendants( 365 | process: ProcessInfo, 366 | allProcesses: ProcessInfo[] 367 | ): ProcessInfo[] { 368 | const descendants: ProcessInfo[] = []; 369 | const processByPid: Record = {}; 370 | 371 | // Build lookup table 372 | for (const p of allProcesses) { 373 | processByPid[p.pid] = p; 374 | } 375 | 376 | // Recursive function to collect descendants 377 | const collect = (proc: ProcessInfo) => { 378 | const children = allProcesses.filter(p => p.ppid === proc.pid); 379 | for (const child of children) { 380 | descendants.push(child); 381 | collect(child); 382 | } 383 | }; 384 | 385 | collect(process); 386 | return descendants; 387 | } 388 | 389 | /** 390 | * Parse memory string (KB) to MB 391 | */ 392 | private parseMemoryString(memory: string): number { 393 | const kb = parseInt(memory, 10); 394 | return kb / 1024; // Convert KB to MB 395 | } 396 | } 397 | 398 | // Example usage 399 | async function main() { 400 | const tracker = new ProcessTracker(); 401 | const ttyPath = '/dev/ttys001'; // Example TTY path 402 | 403 | const process = await tracker.getActiveProcess(ttyPath); 404 | 405 | if (process) { 406 | console.log('Active process:'); 407 | console.log(` Name: ${process.name}`); 408 | console.log(` Command: ${process.command}`); 409 | console.log(` Command Chain: ${process.commandChain}`); 410 | if (process.environment) { 411 | console.log(` Environment: ${process.environment}`); 412 | } 413 | 414 | console.log('\nResource Usage:'); 415 | console.log(` Total CPU: ${process.metrics.totalCPUPercent.toFixed(1)}%`); 416 | console.log(` Total Memory: ${process.metrics.totalMemoryMB.toFixed(1)} MB`); 417 | 418 | if (process.metrics.processBreakdown.length > 0) { 419 | console.log('\nProcess Breakdown:'); 420 | for (const proc of process.metrics.processBreakdown) { 421 | console.log(` ${proc.name} (${proc.pid}):`); 422 | console.log(` CPU: ${proc.cpuPercent.toFixed(1)}%`); 423 | console.log(` Memory: ${proc.memory} KB`); 424 | } 425 | } 426 | } else { 427 | console.log('No active process found'); 428 | } 429 | } 430 | 431 | export default ProcessTracker; --------------------------------------------------------------------------------