{"version":3,"file":"process-manager.d.ts","sourceRoot":"","sources":["../../../src/core/tools/process-manager.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAA4C,MAAM,+BAA+B,CAAC;AACzG,OAAO,EAAE,KAAK,MAAM,EAAQ,MAAM,mBAAmB,CAAC;AAStD,MAAM,WAAW,aAAa;IAC7B,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB,yEAAyE;IACzE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oCAAoC;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,wBAAwB;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,8BAA8B;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,8BAA8B;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,2DAA2D;IAC3D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,oBAAoB;IACpB,MAAM,EAAE,UAAU,GAAG,SAAS,GAAG,SAAS,GAAG,QAAQ,CAAC;IACtD,wCAAwC;IACxC,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AA6ID,QAAA,MAAM,oBAAoB;;;;;;;;;;;;;EAoBxB,CAAC;AAEH,MAAM,MAAM,mBAAmB,GAAG,MAAM,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAMtE,MAAM,WAAW,wBAAwB;IACxC,cAAc,CACb,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,MAAM,EACX,OAAO,EAAE;QACR,MAAM,CAAC,EAAE,WAAW,CAAC;QACrB,OAAO,CAAC,EAAE,MAAM,CAAC;KACjB,GACC,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACxE;AAED,MAAM,WAAW,yBAAyB;IACzC,UAAU,CAAC,EAAE,wBAAwB,CAAC;CACtC;AA+DD,wBAAgB,wBAAwB,CACvC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,yBAAyB,GACjC,SAAS,CAAC,OAAO,oBAAoB,CAAC,CA+SxC;AA2BD,mCAAmC;AACnC,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;QAA0C,CAAC","sourcesContent":["import { randomBytes } from \"node:crypto\";\nimport { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from \"@apholdings/jensen-agent-core\";\nimport { type Static, Type } from \"@sinclair/typebox\";\nimport { createLocalPowerShellOperations } from \"./powershell.js\";\n\n// ---------------------------------------------------------------------------\n// Registry: tracks started processes on disk so they survive tool restarts\n// ---------------------------------------------------------------------------\n\nconst REGISTRY_DIR = join(tmpdir(), \"jensen-process-registry\");\n\nexport interface ProcessRecord {\n\t/** Unique run ID generated at start time */\n\trunId: string;\n\t/** Root PID of the spawned process tree */\n\trootPid: number;\n\t/** PID of the process actually listening on the port (when available) */\n\tlistenerPid?: number;\n\t/** The command that was executed */\n\tcommand: string;\n\t/** Working directory */\n\tcwd: string;\n\t/** Path to stdout log file */\n\tstdoutPath: string;\n\t/** Path to stderr log file */\n\tstderrPath: string;\n\t/** Port the process is expected to listen on (optional) */\n\texpectedPort?: number;\n\t/** ISO 8601 timestamp when started */\n\tstartedAt: string;\n\t/** Current state */\n\tstatus: \"starting\" | \"running\" | \"stopped\" | \"failed\";\n\t/** Last N lines of stderr on failure */\n\tlastErrorLines?: string;\n}\n\nfunction ensureRegistry(): void {\n\tif (!existsSync(REGISTRY_DIR)) {\n\t\tmkdirSync(REGISTRY_DIR, { recursive: true });\n\t}\n}\n\nfunction getRecordPath(runId: string): string {\n\treturn join(REGISTRY_DIR, `${runId}.json`);\n}\n\nfunction readRecord(runId: string): ProcessRecord | null {\n\tconst path = getRecordPath(runId);\n\tif (!existsSync(path)) return null;\n\ttry {\n\t\treturn JSON.parse(readFileSync(path, \"utf-8\")) as ProcessRecord;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction writeRecord(record: ProcessRecord): void {\n\tensureRegistry();\n\twriteFileSync(getRecordPath(record.runId), JSON.stringify(record, null, 2), \"utf-8\");\n}\n\nfunction deleteRecord(runId: string): void {\n\tconst path = getRecordPath(runId);\n\tif (existsSync(path)) {\n\t\trmSync(path);\n\t}\n}\n\nfunction listRecords(): ProcessRecord[] {\n\tensureRegistry();\n\tconst entries: ProcessRecord[] = [];\n\ttry {\n\t\tfor (const entry of readdirSync(REGISTRY_DIR)) {\n\t\t\tif (entry.endsWith(\".json\")) {\n\t\t\t\tconst record = readRecord(entry.replace(\".json\", \"\"));\n\t\t\t\tif (record) entries.push(record);\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t// Directory may not exist yet\n\t}\n\treturn entries;\n}\n\nfunction readLastLines(filePath: string, count: number): string {\n\ttry {\n\t\tif (!existsSync(filePath)) return \"\";\n\t\tconst content = readFileSync(filePath, \"utf-8\");\n\t\tconst lines = content.split(\"\\n\");\n\t\treturn lines.slice(-count).join(\"\\n\");\n\t} catch {\n\t\treturn \"\";\n\t}\n}\n\nfunction generateRunId(): string {\n\treturn randomBytes(6).toString(\"hex\");\n}\n\n// ---------------------------------------------------------------------------\n// PowerShell script fragments for process management\n// ---------------------------------------------------------------------------\n\nconst GET_PORT_OWNER_PS = `\n$ProgressPreference = 'SilentlyContinue';\n$port = {PORT};\n$conn = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -First 1;\nif ($conn) {\n  Write-Output \"PID:$($conn.OwningProcess)\"\n} else {\n  $netstat = netstat -ano 2>$null | Select-String \":$port \";\n  if ($netstat) {\n    $parts = $netstat -split '\\\\s+';\n    $ownerPid = $parts[$parts.Length - 1];\n    Write-Output \"PID:$ownerPid\"\n  } else {\n    Write-Output \"NONE\"\n  }\n}`;\n\nconst VERIFY_PROCESS_TREE_PS = `\n$targetPid = {TARGET_PID};\n$rootPid = {ROOT_PID};\n$current = $targetPid;\n$maxDepth = 20;\n$found = $false;\nfor ($i = 0; $i -lt $maxDepth; $i++) {\n  if ($current -eq $rootPid) {\n    $found = $true;\n    break;\n  }\n  $parent = (Get-CimInstance Win32_Process -Filter \"ProcessId = $current\" -ErrorAction SilentlyContinue | Select-Object -First 1).ParentProcessId;\n  if (-not $parent -or $parent -eq 0) { break }\n  $current = $parent;\n}\nif ($found) {\n  Write-Output 'TREE_OK'\n} else {\n  Write-Output 'TREE_NOT_FOUND'\n}`;\n\nconst START_PROCESS_PS = `\n$cmd = '{COMMAND}';\n$cwd = '{CWD}';\n$stdoutPath = '{STDOUT_PATH}';\n$stderrPath = '{STDERR_PATH}';\n$wrapped = \"cmd.exe /c \" + $cmd + \" > \" + '\"' + $stdoutPath + '\"' + \" 2> \" + '\"' + $stderrPath + '\"';\n$proc = Start-Process -FilePath 'cmd.exe' -ArgumentList '/c', $wrapped -WorkingDirectory $cwd -WindowStyle Hidden -PassThru;\nWrite-Output \"PID:$($proc.Id)\"`;\n\nconst STOP_PROCESS_PS = `\n$pidToStop = {PID};\ntry {\n  taskkill /T /PID $pidToStop /F 2>&1 | Out-Null;\n  Write-Output 'STOP_OK'\n} catch {\n  Write-Output \"STOP_ERR:$($_.Exception.Message)\"\n}`;\n\n// NOTE: $procId is used instead of $pid because $PID is a read-only\n// automatic PowerShell variable (current session PID). Using $pid would\n// silently resolve to pwsh.exe's own PID instead of the target.\nconst CHECK_PROCESS_ALIVE_PS = `\n$procId = {PID};\n$proc = Get-Process -Id $procId -ErrorAction SilentlyContinue;\nif ($proc) {\n  Write-Output 'ALIVE'\n} else {\n  Write-Output 'DEAD'\n}`;\n\n// ---------------------------------------------------------------------------\n// Schema\n// ---------------------------------------------------------------------------\n\nconst processManagerSchema = Type.Object({\n\taction: Type.Enum(\n\t\t{\n\t\t\tTODO: \"TODO\",\n\t\t\tSTART: \"start\",\n\t\t\tSTATUS: \"status\",\n\t\t\tSTOP: \"stop\",\n\t\t\tLIST: \"list\",\n\t\t},\n\t\t{ description: \"Action: start, status, stop, or list\" },\n\t),\n\tcommand: Type.Optional(\n\t\tType.String({ description: \"Command to run (required for start). Must be a single executable with args.\" }),\n\t),\n\tcwd: Type.Optional(Type.String({ description: \"Working directory for the process (default: current)\" })),\n\trunId: Type.Optional(Type.String({ description: \"Run ID returned by start. Required for status and stop.\" })),\n\texpectedPort: Type.Optional(Type.Number({ description: \"TCP port the process should listen on\" })),\n\treadyTimeout: Type.Optional(\n\t\tType.Number({ description: \"Maximum seconds to wait for the process to become ready (max 45)\" }),\n\t),\n});\n\nexport type ProcessManagerInput = Static<typeof processManagerSchema>;\n\n// ---------------------------------------------------------------------------\n// Pluggable operations\n// ---------------------------------------------------------------------------\n\nexport interface ProcessManagerOperations {\n\texecPowerShell(\n\t\tcommand: string,\n\t\tcwd: string,\n\t\toptions: {\n\t\t\tsignal?: AbortSignal;\n\t\t\ttimeout?: number;\n\t\t},\n\t): Promise<{ exitCode: number | null; stdout: string; stderr: string }>;\n}\n\nexport interface ProcessManagerToolOptions {\n\toperations?: ProcessManagerOperations;\n}\n\n// ---------------------------------------------------------------------------\n// Polling helper\n// ---------------------------------------------------------------------------\n\nasync function pollForReadiness(\n\trunId: string,\n\trootPid: number,\n\tport: number,\n\ttimeoutSecs: number,\n\tops: ProcessManagerOperations,\n\tsignal?: AbortSignal,\n): Promise<{ ready: boolean; listenerPid?: number; error?: string }> {\n\tconst deadline = Date.now() + timeoutSecs * 1000;\n\tconst pollInterval = 500;\n\n\twhile (Date.now() < deadline) {\n\t\tif (signal?.aborted) {\n\t\t\treturn { ready: false, error: \"Aborted\" };\n\t\t}\n\n\t\tconst aliveScript = CHECK_PROCESS_ALIVE_PS.replace(\"{PID}\", String(rootPid));\n\t\tconst aliveResult = await ops.execPowerShell(aliveScript, process.cwd(), { timeout: 5 });\n\t\tif (!aliveResult.stdout.includes(\"ALIVE\")) {\n\t\t\tconst record = readRecord(runId);\n\t\t\tconst errorLines = record ? readLastLines(record.stderrPath, 20) : \"\";\n\t\t\treturn { ready: false, error: `Process died (PID ${rootPid}).\\nStderr:\\n${errorLines}` };\n\t\t}\n\n\t\tconst portScript = GET_PORT_OWNER_PS.replace(\"{PORT}\", String(port));\n\t\tconst portResult = await ops.execPowerShell(portScript, process.cwd(), { timeout: 5 });\n\t\tconst portPidMatch = portResult.stdout.match(/PID:(\\d+)/);\n\n\t\tif (portPidMatch) {\n\t\t\tconst portPid = Number.parseInt(portPidMatch[1], 10);\n\n\t\t\tconst treeScript = VERIFY_PROCESS_TREE_PS.replace(\"{TARGET_PID}\", String(portPid)).replace(\n\t\t\t\t\"{ROOT_PID}\",\n\t\t\t\tString(rootPid),\n\t\t\t);\n\t\t\tconst treeResult = await ops.execPowerShell(treeScript, process.cwd(), { timeout: 5 });\n\n\t\t\tif (treeResult.stdout.includes(\"TREE_OK\")) {\n\t\t\t\treturn { ready: true, listenerPid: portPid };\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tready: false,\n\t\t\t\terror: `Port ${port} is occupied by PID ${portPid} which does not belong to the process tree of root PID ${rootPid}. This is a conflict.`,\n\t\t\t};\n\t\t}\n\n\t\tawait new Promise((r) => setTimeout(r, pollInterval));\n\t}\n\n\treturn { ready: false, error: `Timed out after ${timeoutSecs}s waiting for port ${port}` };\n}\n\n// ---------------------------------------------------------------------------\n// Tool factory\n// ---------------------------------------------------------------------------\n\nexport function createProcessManagerTool(\n\tcwd: string,\n\toptions?: ProcessManagerToolOptions,\n): AgentTool<typeof processManagerSchema> {\n\treturn {\n\t\tname: \"process_manager\",\n\t\tlabel: \"process_manager\",\n\t\tdescription:\n\t\t\t`Manage persistent background processes on Windows via PowerShell. ` +\n\t\t\t`Use this instead of nohup or Git Bash backgrounding. ` +\n\t\t\t`Actions: start (launch background process), status (check process state), ` +\n\t\t\t`stop (terminate a managed process), list (show all managed processes). ` +\n\t\t\t`For start, provides readiness polling with port-ownership verification. ` +\n\t\t\t`Never kills unknown processes. Only stops processes registered by this tool.`,\n\t\tparameters: processManagerSchema,\n\t\texecute: async (\n\t\t\t_toolCallId: string,\n\t\t\tinput: ProcessManagerInput,\n\t\t\tsignal?: AbortSignal,\n\t\t\tonUpdate?: AgentToolUpdateCallback,\n\t\t): Promise<AgentToolResult<any>> => {\n\t\t\t// Create operations from local PowerShell (no inline import)\n\t\t\tconst psOps = options?.operations ?? createOpsFromLocal();\n\t\t\tconst resolvedOps = psOps;\n\n\t\t\tswitch (input.action) {\n\t\t\t\tcase \"list\": {\n\t\t\t\t\tconst records = listRecords();\n\t\t\t\t\tif (records.length === 0) {\n\t\t\t\t\t\treturn { content: [{ type: \"text\", text: \"No managed processes.\" }], details: { records: [] } };\n\t\t\t\t\t}\n\t\t\t\t\tconst lines = records.map(\n\t\t\t\t\t\t(r) =>\n\t\t\t\t\t\t\t`[${r.runId}] ${r.status.toUpperCase()} | PID:${r.rootPid} | listener:${r.listenerPid ?? \"N/A\"} | port:${r.expectedPort ?? \"N/A\"} | ${r.command.slice(0, 80)}`,\n\t\t\t\t\t);\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: lines.join(\"\\n\") }],\n\t\t\t\t\t\tdetails: { records },\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tcase \"start\": {\n\t\t\t\t\tif (!input.command) {\n\t\t\t\t\t\tthrow new Error(\"command is required for start action\");\n\t\t\t\t\t}\n\n\t\t\t\t\tconst runId = generateRunId();\n\t\t\t\t\tconst workDir = input.cwd ?? cwd;\n\t\t\t\t\tconst stdoutPath = join(tmpdir(), `jensen-proc-${runId}-stdout.log`);\n\t\t\t\t\tconst stderrPath = join(tmpdir(), `jensen-proc-${runId}-stderr.log`);\n\t\t\t\t\tconst readyTimeout = Math.min(input.readyTimeout ?? 30, 45);\n\t\t\t\t\tconst expectedPort = input.expectedPort;\n\n\t\t\t\t\twriteFileSync(stdoutPath, \"\", \"utf-8\");\n\t\t\t\t\twriteFileSync(stderrPath, \"\", \"utf-8\");\n\n\t\t\t\t\tconst escapedCommand = input.command.replace(/'/g, \"''\");\n\t\t\t\t\tconst escapedCwd = workDir.replace(/'/g, \"''\");\n\n\t\t\t\t\tconst startScript = START_PROCESS_PS.replace(\"{COMMAND}\", escapedCommand)\n\t\t\t\t\t\t.replace(\"{CWD}\", escapedCwd)\n\t\t\t\t\t\t.replace(\"{STDOUT_PATH}\", stdoutPath.replace(/\\\\/g, \"\\\\\\\\\"))\n\t\t\t\t\t\t.replace(\"{STDERR_PATH}\", stderrPath.replace(/\\\\/g, \"\\\\\\\\\"));\n\n\t\t\t\t\tif (onUpdate) {\n\t\t\t\t\t\tonUpdate({ content: [{ type: \"text\", text: `Starting process: ${input.command}` }], details: {} });\n\t\t\t\t\t}\n\n\t\t\t\t\tlet startResult: { exitCode: number | null; stdout: string; stderr: string };\n\t\t\t\t\ttry {\n\t\t\t\t\t\tstartResult = await resolvedOps.execPowerShell(startScript, workDir, {\n\t\t\t\t\t\t\tsignal,\n\t\t\t\t\t\t\ttimeout: 10,\n\t\t\t\t\t\t});\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tconst record: ProcessRecord = {\n\t\t\t\t\t\t\trunId,\n\t\t\t\t\t\t\trootPid: 0,\n\t\t\t\t\t\t\tcommand: input.command,\n\t\t\t\t\t\t\tcwd: workDir,\n\t\t\t\t\t\t\tstdoutPath,\n\t\t\t\t\t\t\tstderrPath,\n\t\t\t\t\t\t\texpectedPort,\n\t\t\t\t\t\t\tstartedAt: new Date().toISOString(),\n\t\t\t\t\t\t\tstatus: \"failed\",\n\t\t\t\t\t\t\tlastErrorLines: (err as Error).message,\n\t\t\t\t\t\t};\n\t\t\t\t\t\twriteRecord(record);\n\t\t\t\t\t\tthrow new Error(`Failed to start process: ${(err as Error).message}`);\n\t\t\t\t\t}\n\n\t\t\t\t\tconst pidMatch = startResult.stdout.match(/PID:(\\d+)/);\n\t\t\t\t\tif (!pidMatch) {\n\t\t\t\t\t\tconst record: ProcessRecord = {\n\t\t\t\t\t\t\trunId,\n\t\t\t\t\t\t\trootPid: 0,\n\t\t\t\t\t\t\tcommand: input.command,\n\t\t\t\t\t\t\tcwd: workDir,\n\t\t\t\t\t\t\tstdoutPath,\n\t\t\t\t\t\t\tstderrPath,\n\t\t\t\t\t\t\texpectedPort,\n\t\t\t\t\t\t\tstartedAt: new Date().toISOString(),\n\t\t\t\t\t\t\tstatus: \"failed\",\n\t\t\t\t\t\t\tlastErrorLines: readLastLines(stderrPath, 20) || \"Could not extract PID from PowerShell output\",\n\t\t\t\t\t\t};\n\t\t\t\t\t\twriteRecord(record);\n\t\t\t\t\t\tthrow new Error(`Could not determine PID. PowerShell output: ${startResult.stdout.slice(0, 500)}`);\n\t\t\t\t\t}\n\n\t\t\t\t\tconst rootPid = Number.parseInt(pidMatch[1], 10);\n\t\t\t\t\tconst record: ProcessRecord = {\n\t\t\t\t\t\trunId,\n\t\t\t\t\t\trootPid,\n\t\t\t\t\t\tcommand: input.command,\n\t\t\t\t\t\tcwd: workDir,\n\t\t\t\t\t\tstdoutPath,\n\t\t\t\t\t\tstderrPath,\n\t\t\t\t\t\texpectedPort,\n\t\t\t\t\t\tstartedAt: new Date().toISOString(),\n\t\t\t\t\t\tstatus: \"starting\",\n\t\t\t\t\t};\n\t\t\t\t\twriteRecord(record);\n\n\t\t\t\t\tif (onUpdate) {\n\t\t\t\t\t\tonUpdate({\n\t\t\t\t\t\t\tcontent: [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\t\t\ttext: `Process started. PID: ${rootPid}. Polling for readiness...`,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\tdetails: {},\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\n\t\t\t\t\tif (expectedPort) {\n\t\t\t\t\t\tconst readyResult = await pollForReadiness(\n\t\t\t\t\t\t\trunId,\n\t\t\t\t\t\t\trootPid,\n\t\t\t\t\t\t\texpectedPort,\n\t\t\t\t\t\t\treadyTimeout,\n\t\t\t\t\t\t\tresolvedOps,\n\t\t\t\t\t\t\tsignal,\n\t\t\t\t\t\t);\n\t\t\t\t\t\tif (!readyResult.ready) {\n\t\t\t\t\t\t\trecord.status = \"failed\";\n\t\t\t\t\t\t\trecord.lastErrorLines =\n\t\t\t\t\t\t\t\treadLastLines(stderrPath, 20) || readyResult.error || \"Process did not become ready\";\n\t\t\t\t\t\t\twriteRecord(record);\n\t\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t\t`Process started (PID ${rootPid}) but did not become ready on port ${expectedPort} within ${readyTimeout}s. ` +\n\t\t\t\t\t\t\t\t\t`${readyResult.error ?? \"\"}\\nLast stderr lines:\\n${record.lastErrorLines}`,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t\trecord.listenerPid = readyResult.listenerPid;\n\t\t\t\t\t}\n\n\t\t\t\t\trecord.status = \"running\";\n\t\t\t\t\twriteRecord(record);\n\n\t\t\t\t\tconst resultText = [\n\t\t\t\t\t\t\"Process started successfully.\",\n\t\t\t\t\t\t`  Run ID: ${runId}`,\n\t\t\t\t\t\t`  Root PID: ${rootPid}`,\n\t\t\t\t\t\trecord.listenerPid ? `  Listener PID: ${record.listenerPid}` : null,\n\t\t\t\t\t\texpectedPort ? `  Port: ${expectedPort}` : null,\n\t\t\t\t\t\t`  Stdout: ${stdoutPath}`,\n\t\t\t\t\t\t`  Stderr: ${stderrPath}`,\n\t\t\t\t\t]\n\t\t\t\t\t\t.filter(Boolean)\n\t\t\t\t\t\t.join(\"\\n\");\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: resultText }],\n\t\t\t\t\t\tdetails: { record },\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tcase \"status\": {\n\t\t\t\t\tif (!input.runId) {\n\t\t\t\t\t\tthrow new Error(\"runId is required for status action\");\n\t\t\t\t\t}\n\n\t\t\t\t\tconst record = readRecord(input.runId);\n\t\t\t\t\tif (!record) {\n\t\t\t\t\t\tthrow new Error(`No process found with runId: ${input.runId}`);\n\t\t\t\t\t}\n\n\t\t\t\t\tconst aliveScript = CHECK_PROCESS_ALIVE_PS.replace(\"{PID}\", String(record.rootPid));\n\t\t\t\t\tconst aliveResult = await resolvedOps.execPowerShell(aliveScript, cwd, {\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t\ttimeout: 5,\n\t\t\t\t\t});\n\t\t\t\t\tconst isAlive = aliveResult.stdout.includes(\"ALIVE\");\n\n\t\t\t\t\tconst updatedStatus = isAlive ? record.status : \"stopped\";\n\t\t\t\t\tif (!isAlive && record.status !== \"stopped\") {\n\t\t\t\t\t\trecord.status = \"stopped\";\n\t\t\t\t\t\twriteRecord(record);\n\t\t\t\t\t}\n\n\t\t\t\t\tconst lines = [\n\t\t\t\t\t\t`Run ID: ${record.runId}`,\n\t\t\t\t\t\t`Status: ${updatedStatus}`,\n\t\t\t\t\t\t`Root PID: ${record.rootPid}`,\n\t\t\t\t\t\t`Listener PID: ${record.listenerPid ?? \"N/A\"}`,\n\t\t\t\t\t\t`Port: ${record.expectedPort ?? \"N/A\"}`,\n\t\t\t\t\t\t`Command: ${record.command}`,\n\t\t\t\t\t\t`CWD: ${record.cwd}`,\n\t\t\t\t\t\t`Stdout: ${record.stdoutPath}`,\n\t\t\t\t\t\t`Stderr: ${record.stderrPath}`,\n\t\t\t\t\t\t`Started: ${record.startedAt}`,\n\t\t\t\t\t];\n\n\t\t\t\t\tif (!isAlive) {\n\t\t\t\t\t\tlines.push(\"\", readLastLines(record.stderrPath, 20));\n\t\t\t\t\t}\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [{ type: \"text\", text: lines.join(\"\\n\") }],\n\t\t\t\t\t\tdetails: { record: { ...record, status: updatedStatus } },\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tcase \"stop\": {\n\t\t\t\t\tif (!input.runId) {\n\t\t\t\t\t\tthrow new Error(\"runId is required for stop action\");\n\t\t\t\t\t}\n\n\t\t\t\t\tconst record = readRecord(input.runId);\n\t\t\t\t\tif (!record) {\n\t\t\t\t\t\tthrow new Error(`No process found with runId: ${input.runId}`);\n\t\t\t\t\t}\n\n\t\t\t\t\tif (record.rootPid === 0) {\n\t\t\t\t\t\tdeleteRecord(input.runId);\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tcontent: [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\t\t\ttext: `Process ${input.runId} was never started (PID 0). Record removed.`,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\tdetails: {} as Record<string, never>,\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\n\t\t\t\t\tconst aliveScript = CHECK_PROCESS_ALIVE_PS.replace(\"{PID}\", String(record.rootPid));\n\t\t\t\t\tconst aliveResult = await resolvedOps.execPowerShell(aliveScript, cwd, {\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t\ttimeout: 5,\n\t\t\t\t\t});\n\n\t\t\t\t\tif (!aliveResult.stdout.includes(\"ALIVE\")) {\n\t\t\t\t\t\trecord.status = \"stopped\";\n\t\t\t\t\t\twriteRecord(record);\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tcontent: [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\t\t\ttext: `Process ${input.runId} (PID ${record.rootPid}) was already stopped.`,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\tdetails: { record },\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\n\t\t\t\t\tconst stopScript = STOP_PROCESS_PS.replace(\"{PID}\", String(record.rootPid));\n\t\t\t\t\tconst stopResult = await resolvedOps.execPowerShell(stopScript, cwd, {\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t\ttimeout: 10,\n\t\t\t\t\t});\n\n\t\t\t\t\tif (stopResult.stdout.includes(\"STOP_OK\")) {\n\t\t\t\t\t\trecord.status = \"stopped\";\n\t\t\t\t\t\twriteRecord(record);\n\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\tcontent: [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\t\t\ttext:\n\t\t\t\t\t\t\t\t\t\t`Process ${input.runId} (PID ${record.rootPid}) stopped successfully.\\n` +\n\t\t\t\t\t\t\t\t\t\t`Stdout log: ${record.stdoutPath}\\n` +\n\t\t\t\t\t\t\t\t\t\t`Stderr log: ${record.stderrPath}`,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\tdetails: { record },\n\t\t\t\t\t\t};\n\t\t\t\t\t}\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\t\t\ttext: `Failed to stop process ${input.runId} (PID ${record.rootPid}): ${stopResult.stdout}`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t],\n\t\t\t\t\t\tdetails: { record },\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tdefault:\n\t\t\t\t\tthrow new Error(`Unknown action: ${input.action}`);\n\t\t\t}\n\t\t},\n\t};\n}\n\n/**\n * Create default operations using the local PowerShell backend.\n * This is a synchronous factory - no inline imports.\n */\nfunction createOpsFromLocal(): ProcessManagerOperations {\n\tconst psOps = createLocalPowerShellOperations();\n\treturn {\n\t\texecPowerShell: (command, execCwd, opts) => {\n\t\t\treturn new Promise((resolve, reject) => {\n\t\t\t\tlet stdout = \"\";\n\t\t\t\tpsOps\n\t\t\t\t\t.exec(command, execCwd, {\n\t\t\t\t\t\t...opts,\n\t\t\t\t\t\tonData: (data) => {\n\t\t\t\t\t\t\tstdout += data.toString(\"utf-8\");\n\t\t\t\t\t\t},\n\t\t\t\t\t\tenv: undefined,\n\t\t\t\t\t})\n\t\t\t\t\t.then((r) => resolve({ ...r, stdout, stderr: \"\" }))\n\t\t\t\t\t.catch(reject);\n\t\t\t});\n\t\t},\n\t};\n}\n\n/** Default process manager tool */\nexport const processManagerTool = createProcessManagerTool(process.cwd());\n"]}