From 4f7975736d7fbddb04a22471f3ce2b648e0364e4 Mon Sep 17 00:00:00 2001 From: rrader26 Date: Tue, 12 May 2026 07:15:35 -0400 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20Process=20Pack=20=E2=80=94=20O?= =?UTF-8?q?S-process=20introspection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-platform process listing + per-PID detail for agents that need OS-level observability ("why isn't this app responding?", "is my bridge still alive?", "what's eating CPU?"). Read-only in v0; kill ships separately with explicit gating. Tools shipped (2): agentmark_process_list list with optional name filter + sort agentmark_process_info detail for one PID Cross-platform via OS-native tools (no Node deps): macOS / Linux: `ps -A -o pid,pcpu,pmem,user,etime,rss,comm` Windows: PowerShell Get-Process + Win32_Process WMI for cmdline Normalised shape regardless of platform: pid, name, user, cpu_percent, memory_kb, command, elapsed, ppid, vsz_kb, state. Sort by cpu / memory / name / pid. Substring filter on process name. Tests (12 new, 354 total): live `ps` calls assert current Node process appears in list, info resolves for own PID, unknown PID returns found=false, sort orders are honoured, name_filter matches case-insensitively. No mocks — real OS calls. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mcp/index.ts | 12 ++ src/plugins/process/index.ts | 83 ++++++++++ src/plugins/process/process-runner.ts | 217 ++++++++++++++++++++++++++ src/plugins/process/tool-defs.ts | 55 +++++++ test/process/process-plugin.test.ts | 130 +++++++++++++++ 5 files changed, 497 insertions(+) create mode 100644 src/plugins/process/index.ts create mode 100644 src/plugins/process/process-runner.ts create mode 100644 src/plugins/process/tool-defs.ts create mode 100644 test/process/process-plugin.test.ts diff --git a/src/mcp/index.ts b/src/mcp/index.ts index e23f627..4c61986 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -75,6 +75,18 @@ export type { ScreenshotResult, } from '../plugins/vision' +// Process Pack — OS-process introspection (list + per-PID detail). Opt-in. +export { + createProcessPlugin, + listProcesses, + getProcessDetail, + PROCESS_TOOLS, +} from '../plugins/process' +export type { + ProcessSummary, + ProcessDetail, +} from '../plugins/process' + // Microsoft Workflows Pack (Graph-only v0) — opt-in; not part of the // default plugin set. Pass it explicitly via `createMcpServer({ plugins })`. export { diff --git a/src/plugins/process/index.ts b/src/plugins/process/index.ts new file mode 100644 index 0000000..aa9da97 --- /dev/null +++ b/src/plugins/process/index.ts @@ -0,0 +1,83 @@ +/** + * Process Pack — OS-process introspection. + * + * Read-only in v0. Useful for agents debugging "why isn't this app + * responding?" or "is the bridge still alive?" or "what's eating CPU?". + * Kill / signal sending lands in a follow-up with explicit gating. + */ +import { + listProcesses, + getProcessDetail, + type ProcessSummary, +} from './process-runner' +import { PROCESS_TOOLS } from './tool-defs' +import type { AgentMarkPlugin, DispatchResult, ToolHandler } from '../../mcp/plugin' + +export function createProcessPlugin(): AgentMarkPlugin { + const handlers: Record = { + agentmark_process_list: async (args): Promise => { + const all = await listProcesses() + const filter = typeof args.name_filter === 'string' ? args.name_filter.toLowerCase() : null + const sortBy = ['cpu', 'memory', 'name', 'pid'].includes(args.sort_by as string) + ? (args.sort_by as 'cpu' | 'memory' | 'name' | 'pid') + : 'cpu' + const limit = typeof args.limit === 'number' && args.limit > 0 ? args.limit : 200 + + const filtered = filter + ? all.filter((p) => p.name.toLowerCase().includes(filter)) + : all + + filtered.sort((a, b) => compareProcesses(a, b, sortBy)) + const trimmed = filtered.slice(0, limit) + + return { + text: JSON.stringify({ + total: all.length, + matched: filtered.length, + returned: trimmed.length, + sort_by: sortBy, + processes: trimmed, + }, null, 2), + } + }, + + agentmark_process_info: async (args): Promise => { + const pid = args.pid + if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) { + return { text: '`pid` must be a positive integer.', isError: true } + } + const detail = await getProcessDetail(pid) + if (!detail) { + return { + text: JSON.stringify({ pid, found: false }, null, 2), + isError: true, + } + } + return { text: JSON.stringify({ found: true, ...detail }, null, 2) } + }, + } + + return { + name: 'process', + version: '0.1.0', + tools: PROCESS_TOOLS, + handlers, + } +} + +function compareProcesses(a: ProcessSummary, b: ProcessSummary, by: 'cpu' | 'memory' | 'name' | 'pid'): number { + switch (by) { + case 'cpu': + return (b.cpu_percent ?? 0) - (a.cpu_percent ?? 0) + case 'memory': + return (b.memory_kb ?? 0) - (a.memory_kb ?? 0) + case 'pid': + return a.pid - b.pid + case 'name': + return a.name.localeCompare(b.name) + } +} + +export { listProcesses, getProcessDetail } from './process-runner' +export { PROCESS_TOOLS } from './tool-defs' +export type { ProcessSummary, ProcessDetail } from './process-runner' diff --git a/src/plugins/process/process-runner.ts b/src/plugins/process/process-runner.ts new file mode 100644 index 0000000..d4d35c9 --- /dev/null +++ b/src/plugins/process/process-runner.ts @@ -0,0 +1,217 @@ +/** + * OS-native process enumeration + introspection. + * + * Cross-platform via shell-out to OS-shipped tools (no Node deps): + * - macOS / Linux: `ps` with format-string output + * - Windows: PowerShell `Get-Process` + WMI `Win32_Process` for cmdline + * + * Output is normalised to a uniform `ProcessSummary` / `ProcessDetail` + * shape regardless of platform so the agent can write platform-agnostic + * logic. + */ +import { spawn } from 'node:child_process' + +export interface ProcessSummary { + pid: number + /** Process name / image name. Just the basename, no path. */ + name: string + /** Username running the process (when available). */ + user?: string + /** CPU percentage (sample-based on POSIX; instantaneous on Win). */ + cpu_percent?: number + /** Memory percentage of total system RAM. */ + memory_percent?: number + /** Resident set size in KB (memory in physical RAM). */ + memory_kb?: number + /** Elapsed time since the process started (formatted string like "01:02:03"). */ + elapsed?: string + /** Best-effort full command line. May be truncated by the OS. */ + command?: string +} + +export interface ProcessDetail extends ProcessSummary { + ppid?: number + /** OS-specific status flag (ps: STAT column; Win: ProcessName). */ + state?: string + /** Virtual memory size in KB. */ + vsz_kb?: number +} + +export async function listProcesses(): Promise { + if (process.platform === 'win32') { + return await listProcessesWindows() + } + return await listProcessesPosix() +} + +export async function getProcessDetail(pid: number): Promise { + if (process.platform === 'win32') { + return await getDetailWindows(pid) + } + return await getDetailPosix(pid) +} + +// ────────────────────────────────────────────────────────────────────── +// macOS / Linux — `ps` +// ────────────────────────────────────────────────────────────────────── + +async function listProcessesPosix(): Promise { + // `ps` formatting: + // pid pcpu pmem user etime rss command (last is rest-of-line) + // -A = all processes + // -ww = no truncation + const out = await runForStdout('ps', ['-Aww', '-o', 'pid=,pcpu=,pmem=,user=,etime=,rss=,comm=']) + return parsePsLines(out, parsePsLine) +} + +async function getDetailPosix(pid: number): Promise { + try { + const out = await runForStdout('ps', ['-p', String(pid), '-ww', '-o', 'pid=,ppid=,pcpu=,pmem=,user=,etime=,vsz=,rss=,stat=,command=']) + const lines = out.split(/\r?\n/).filter(Boolean) + if (lines.length === 0) return null + return parsePsDetailLine(lines[0]) + } catch { + return null + } +} + +function parsePsLines(out: string, lineParser: (line: string) => T | null): T[] { + const result: T[] = [] + for (const line of out.split(/\r?\n/)) { + if (!line.trim()) continue + const parsed = lineParser(line) + if (parsed) result.push(parsed) + } + return result +} + +function parsePsLine(line: string): ProcessSummary | null { + // Fields are space-separated; command is the last field and may + // contain spaces. Split with limit-7 by taking first 6 tokens then + // the remainder. + const m = line.trim().match(/^(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(.+)$/) + if (!m) return null + return { + pid: parseInt(m[1], 10), + cpu_percent: numericOrUndef(m[2]), + memory_percent: numericOrUndef(m[3]), + user: m[4], + elapsed: m[5], + memory_kb: parseInt(m[6], 10), + command: m[7].trim(), + name: basename(m[7].trim()), + } +} + +function parsePsDetailLine(line: string): ProcessDetail | null { + const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/) + if (!m) return null + const command = m[10].trim() + return { + pid: parseInt(m[1], 10), + ppid: parseInt(m[2], 10), + cpu_percent: numericOrUndef(m[3]), + memory_percent: numericOrUndef(m[4]), + user: m[5], + elapsed: m[6], + vsz_kb: parseInt(m[7], 10), + memory_kb: parseInt(m[8], 10), + state: m[9], + command, + name: basename(command), + } +} + +// ────────────────────────────────────────────────────────────────────── +// Windows — PowerShell +// ────────────────────────────────────────────────────────────────────── + +async function listProcessesWindows(): Promise { + // PowerShell emits an array of objects as JSON; we read that back. + const script = `Get-Process | Select-Object Id,ProcessName,@{n='CPU';e={[math]::Round($_.CPU,2)}},@{n='WS_KB';e={[math]::Round($_.WorkingSet64/1024,0)}},@{n='UserName';e={try{$_.UserName}catch{$null}}} | ConvertTo-Json -Depth 1 -Compress` + const out = await runForStdout('powershell', ['-NoProfile', '-Command', script]) + return parseWinJsonList(out, false) as ProcessSummary[] +} + +async function getDetailWindows(pid: number): Promise { + const script = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object Id,@{n='PPid';e={(Get-CimInstance Win32_Process -Filter "ProcessId=$($_.Id)" -ErrorAction SilentlyContinue).ParentProcessId}},ProcessName,@{n='CPU';e={[math]::Round($_.CPU,2)}},@{n='WS_KB';e={[math]::Round($_.WorkingSet64/1024,0)}},@{n='VSZ_KB';e={[math]::Round($_.VirtualMemorySize64/1024,0)}},@{n='UserName';e={try{$_.UserName}catch{$null}}},@{n='CmdLine';e={(Get-CimInstance Win32_Process -Filter "ProcessId=$($_.Id)" -ErrorAction SilentlyContinue).CommandLine}} | ConvertTo-Json -Depth 1 -Compress` + const out = await runForStdout('powershell', ['-NoProfile', '-Command', script]) + const parsed = parseWinJsonList(out, true) as ProcessDetail[] + return parsed[0] ?? null +} + +interface WinRecord { + Id: number + ProcessName: string + CPU?: number + WS_KB?: number + VSZ_KB?: number + UserName?: string + PPid?: number + CmdLine?: string +} + +function parseWinJsonList(out: string, detail: boolean): ProcessSummary[] | ProcessDetail[] { + const trimmed = out.trim() + if (!trimmed) return [] + let parsed: WinRecord[] + try { + const j = JSON.parse(trimmed) as unknown + parsed = Array.isArray(j) ? (j as WinRecord[]) : [j as WinRecord] + } catch { + return [] + } + return parsed.map((r) => { + const base: ProcessSummary = { + pid: r.Id, + name: r.ProcessName, + user: r.UserName ?? undefined, + cpu_percent: r.CPU ?? undefined, + memory_kb: r.WS_KB ?? undefined, + } + if (!detail) return base + const det: ProcessDetail = { + ...base, + ppid: r.PPid ?? undefined, + vsz_kb: r.VSZ_KB ?? undefined, + command: r.CmdLine ?? undefined, + } + return det + }) +} + +// ────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────── + +function runForStdout(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (b: Buffer) => { stdout += b.toString('utf8') }) + child.stderr.on('data', (b: Buffer) => { stderr += b.toString('utf8') }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0 || stdout.length > 0) { + // ps returns 1 if some pids weren't found but others were + // — accept any case where we got data. + resolve(stdout) + } else { + reject(new Error(`${command} exited ${code}: ${stderr.trim()}`)) + } + }) + }) +} + +function basename(commandLine: string): string { + // Strip args, then strip directory components from argv[0]. + const argv0 = commandLine.split(/\s/, 1)[0] ?? '' + const lastSlash = Math.max(argv0.lastIndexOf('/'), argv0.lastIndexOf('\\')) + return lastSlash >= 0 ? argv0.slice(lastSlash + 1) : argv0 +} + +function numericOrUndef(s: string): number | undefined { + const n = parseFloat(s) + return Number.isFinite(n) ? n : undefined +} diff --git a/src/plugins/process/tool-defs.ts b/src/plugins/process/tool-defs.ts new file mode 100644 index 0000000..30b4760 --- /dev/null +++ b/src/plugins/process/tool-defs.ts @@ -0,0 +1,55 @@ +/** + * Process Pack — tool definitions. + * + * Read-only introspection in v0 (list + per-PID detail). Process kill + * is intentionally out — too easy to misuse, lands in a follow-up + * with explicit "allow_kill" gating + signal whitelist. + */ +import type { McpToolDef } from '../../mcp/tool-defs' + +export const PROCESS_TOOLS: McpToolDef[] = [ + { + name: 'agentmark_process_list', + description: + 'List running processes on the host. Returns one summary per ' + + 'process: pid, name, user, cpu_percent, memory_kb, command, ' + + 'elapsed. Use `name_filter` to substring-match the process ' + + 'name (case-insensitive) when you only want to see specific ' + + 'apps.\n' + + '\nCross-platform via OS-native tools: `ps` on macOS/Linux, ' + + 'PowerShell `Get-Process` on Windows.', + inputSchema: { + type: 'object', + properties: { + name_filter: { + type: 'string', + description: 'Case-insensitive substring match on process name. Omit to list everything.', + }, + limit: { + type: 'number', + description: 'Truncate to this many results (after sorting). Default: 200.', + }, + sort_by: { + type: 'string', + enum: ['cpu', 'memory', 'name', 'pid'], + description: 'Sort order. Default: cpu (descending).', + }, + }, + }, + }, + { + name: 'agentmark_process_info', + description: + 'Return detailed info about a single process by PID. Includes ' + + 'parent PID, virtual + resident memory, OS-specific state ' + + 'flag, and full command line where available. Returns null ' + + 'when the PID is not running.', + inputSchema: { + type: 'object', + properties: { + pid: { type: 'number' }, + }, + required: ['pid'], + }, + }, +] diff --git a/test/process/process-plugin.test.ts b/test/process/process-plugin.test.ts new file mode 100644 index 0000000..ff73aa3 --- /dev/null +++ b/test/process/process-plugin.test.ts @@ -0,0 +1,130 @@ +/** + * Tests for the Process Pack. + * + * Exercises against real `ps` / PowerShell — no mocks. We assert on + * shape + a few invariants (current process must be in the list, info + * for our own pid must resolve) rather than specific values. + */ +import { describe, it, expect } from 'vitest' +import { + createProcessPlugin, + listProcesses, + getProcessDetail, + PROCESS_TOOLS, +} from '../../src/plugins/process' +import { Dispatcher } from '../../src/mcp/plugin' + +describe('Process plugin — registration', () => { + it('registers every tool with a matching handler', () => { + const plugin = createProcessPlugin() + const dispatcher = new Dispatcher([plugin]) + expect(dispatcher.toolNames.sort()).toEqual(PROCESS_TOOLS.map((t) => t.name).sort()) + }) + + it('exposes the v0 tool set', () => { + expect(PROCESS_TOOLS.map((t) => t.name).sort()).toEqual([ + 'agentmark_process_info', + 'agentmark_process_list', + ]) + }) +}) + +describe('listProcesses — live OS call', () => { + it('includes the current Node process in the list', async () => { + const all = await listProcesses() + expect(all.length).toBeGreaterThan(0) + const self = all.find((p) => p.pid === process.pid) + expect(self).toBeDefined() + }) + + it('every entry has at least pid and name', async () => { + const all = await listProcesses() + for (const p of all.slice(0, 20)) { + expect(p.pid).toBeTypeOf('number') + expect(p.name).toBeTypeOf('string') + expect(p.name.length).toBeGreaterThan(0) + } + }) +}) + +describe('getProcessDetail — live OS call', () => { + it('returns detail for the current process', async () => { + const detail = await getProcessDetail(process.pid) + expect(detail).not.toBeNull() + expect(detail!.pid).toBe(process.pid) + // ppid + command should usually populate. + expect(typeof detail!.ppid === 'number' || detail!.ppid === undefined).toBe(true) + }) + + it('returns null for a PID that does not exist', async () => { + // PIDs in the very-high range are virtually never assigned. + const detail = await getProcessDetail(99_999_999) + expect(detail).toBeNull() + }) +}) + +describe('agentmark_process_list — dispatched through the plugin', () => { + it('returns a structured response with total/matched/returned + processes', async () => { + const plugin = createProcessPlugin() + const dispatcher = new Dispatcher([plugin]) + const result = await dispatcher.dispatch('agentmark_process_list', { limit: 10 }) + expect(result.isError).toBeFalsy() + const body = JSON.parse(result.text) + expect(body.total).toBeGreaterThan(0) + expect(body.returned).toBeLessThanOrEqual(10) + expect(Array.isArray(body.processes)).toBe(true) + expect(body.sort_by).toBe('cpu') + }) + + it('substring-matches via name_filter (case-insensitive)', async () => { + const plugin = createProcessPlugin() + const dispatcher = new Dispatcher([plugin]) + // 'node' should match the current Node process on every platform + // we care about (and basically everything that runs JS). + const result = await dispatcher.dispatch('agentmark_process_list', { name_filter: 'NODE' }) + const body = JSON.parse(result.text) + expect(body.matched).toBeGreaterThan(0) + for (const p of body.processes) { + expect(p.name.toLowerCase()).toContain('node') + } + }) + + it('sorts by memory descending when requested', async () => { + const plugin = createProcessPlugin() + const dispatcher = new Dispatcher([plugin]) + const result = await dispatcher.dispatch('agentmark_process_list', { sort_by: 'memory', limit: 5 }) + const body = JSON.parse(result.text) + for (let i = 1; i < body.processes.length; i++) { + expect(body.processes[i - 1].memory_kb ?? 0).toBeGreaterThanOrEqual(body.processes[i].memory_kb ?? 0) + } + }) +}) + +describe('agentmark_process_info — dispatched through the plugin', () => { + it('returns found=true with details for the current PID', async () => { + const plugin = createProcessPlugin() + const dispatcher = new Dispatcher([plugin]) + const result = await dispatcher.dispatch('agentmark_process_info', { pid: process.pid }) + expect(result.isError).toBeFalsy() + const body = JSON.parse(result.text) + expect(body.found).toBe(true) + expect(body.pid).toBe(process.pid) + }) + + it('returns isError + found=false for unknown PID', async () => { + const plugin = createProcessPlugin() + const dispatcher = new Dispatcher([plugin]) + const result = await dispatcher.dispatch('agentmark_process_info', { pid: 99_999_999 }) + expect(result.isError).toBe(true) + const body = JSON.parse(result.text) + expect(body.found).toBe(false) + }) + + it('rejects non-positive pid', async () => { + const plugin = createProcessPlugin() + const dispatcher = new Dispatcher([plugin]) + const result = await dispatcher.dispatch('agentmark_process_info', { pid: -1 }) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/positive integer/) + }) +})