diff --git a/rctctl/CMakeLists.txt b/rctctl/CMakeLists.txt
index 6e38eae9d414..a45af46db4df 100644
--- a/rctctl/CMakeLists.txt
+++ b/rctctl/CMakeLists.txt
@@ -1,3 +1,14 @@
+cmake_minimum_required(VERSION 3.14)
+project(rctctl)
+
+set(CMAKE_CXX_STANDARD 20)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+
+# When built standalone, set ROOT_DIR to parent directory
+if (NOT DEFINED ROOT_DIR)
+ get_filename_component(ROOT_DIR "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
+endif()
+
set(RCTCTL_SOURCES
"${CMAKE_CURRENT_LIST_DIR}/src/main.cpp"
"${CMAKE_CURRENT_LIST_DIR}/src/cli/cli.cpp"
@@ -43,6 +54,7 @@ endif()
list(APPEND RCTCTL_JSON_HINTS
"$ENV{NLOHMANN_JSON_INCLUDE_DIR}"
"${ROOT_DIR}/lib/macos/include"
+ "${ROOT_DIR}/lib/x64/include"
"/usr/include"
"/usr/local/include"
)
diff --git a/src/openrct2-ui/libopenrct2ui.vcxproj b/src/openrct2-ui/libopenrct2ui.vcxproj
index d207fcb5b261..b7a1a930374a 100644
--- a/src/openrct2-ui/libopenrct2ui.vcxproj
+++ b/src/openrct2-ui/libopenrct2ui.vcxproj
@@ -103,6 +103,7 @@
+
@@ -162,6 +163,7 @@
+
diff --git a/src/openrct2-ui/windows/AIAgentTerminal.cpp b/src/openrct2-ui/windows/AIAgentTerminal.cpp
index b57adba8b8cf..b7dd7b741725 100644
--- a/src/openrct2-ui/windows/AIAgentTerminal.cpp
+++ b/src/openrct2-ui/windows/AIAgentTerminal.cpp
@@ -23,6 +23,23 @@
#include
#include
+#if defined(_WIN32)
+ #ifndef WIN32_LEAN_AND_MEAN
+ #define WIN32_LEAN_AND_MEAN
+ #endif
+ #ifndef NOMINMAX
+ #define NOMINMAX
+ #endif
+ #include
+ // Undefine Windows macros that conflict with OpenRCT2 code
+ #ifdef CreateWindow
+ #undef CreateWindow
+ #endif
+ #ifdef DrawText
+ #undef DrawText
+ #endif
+#endif
+
#include
#include
#include
@@ -697,6 +714,10 @@ namespace OpenRCT2::Ui::Windows
bool _launchAttempted = false;
bool _textCaptureActive = false;
bool _lastProcessRunning = false;
+#if defined(_WIN32)
+ void* _externalProcessHandle = nullptr; // HANDLE for external Claude process
+ bool _externalLaunchActive = false;
+#endif
bool _needsFullRedraw = true;
uint64_t _lastOutputTimestamp = 0;
int32_t _cellWidth = 8;
@@ -799,8 +820,8 @@ namespace OpenRCT2::Ui::Windows
void DrawTerminalToBuffer(RenderTarget& rt, int32_t canvasWidth, int32_t canvasHeight, const std::vector& lines) const;
void DrawTerminalDirty(RenderTarget& rt, int32_t canvasWidth, int32_t canvasHeight,
const std::vector& cells, const std::vector& lines);
- void EnsureOffscreenBuffer(int32_t width, int32_t height);
- void BlitOffscreenToScreen(RenderTarget& screenRT, const ScreenCoordsXY& destPos, int32_t width, int32_t height);
+ void EnsureOffscreenBuffer(int32_t bufWidth, int32_t bufHeight);
+ void BlitOffscreenToScreen(RenderTarget& screenRT, const ScreenCoordsXY& destPos, int32_t blitWidth, int32_t blitHeight);
void DrawCells(RenderTarget& rt, const ScreenCoordsXY& origin, int32_t canvasWidth, int32_t canvasHeight) const;
void DrawRowCells(RenderTarget& rt, const ScreenCoordsXY& rowOrigin, std::span row) const;
void DrawCellAt(RenderTarget& rt, const ScreenCoordsXY& rowOrigin, int32_t col, const TerminalCell& cell) const;
@@ -880,7 +901,11 @@ namespace OpenRCT2::Ui::Windows
}
// Initialize workspace path for session logging
+#if defined(_WIN32)
+ const char* home = std::getenv("USERPROFILE");
+#else
const char* home = std::getenv("HOME");
+#endif
if (home && *home)
{
_workspacePath = std::filesystem::path(home) / ".openrct2-agent";
@@ -955,8 +980,24 @@ namespace OpenRCT2::Ui::Windows
sAIAgentTerminalInstance = nullptr;
}
- // Generate HTML session log before closing
- GenerateSessionLog();
+#if defined(_WIN32)
+ // Terminate external Claude process if running
+ if (_externalLaunchActive && _externalProcessHandle != nullptr)
+ {
+ TerminateProcess(static_cast(_externalProcessHandle), 0);
+ CloseHandle(static_cast(_externalProcessHandle));
+ _externalProcessHandle = nullptr;
+ _externalLaunchActive = false;
+ }
+#endif
+
+ // Generate HTML session log before closing (skip if external launch - no terminal session)
+#if defined(_WIN32)
+ if (!_externalLaunchActive)
+#endif
+ {
+ GenerateSessionLog();
+ }
// Release viewport lock so manual panning works after terminal closes
SetViewportLock(false);
@@ -1006,9 +1047,9 @@ namespace OpenRCT2::Ui::Windows
const int16_t targetWidth = collapsed
? GetCollapsedWidth()
: std::clamp(_restoreWidth, kAgentWindowSize.width, kAgentWindowMaxSize.width);
- const int16_t minHeight = kAgentWindowSize.height;
- const int16_t maxHeight = kAgentWindowMaxSize.height;
- const int16_t targetHeight = std::clamp(height, minHeight, maxHeight);
+ const int16_t minH = kAgentWindowSize.height;
+ const int16_t maxH = kAgentWindowMaxSize.height;
+ const int16_t targetHeight = std::clamp(height, minH, maxH);
const int16_t dw = static_cast(targetWidth - width);
const int16_t dh = static_cast(targetHeight - height);
@@ -1071,6 +1112,139 @@ namespace OpenRCT2::Ui::Windows
return;
}
+ LOG_INFO("AIAgentTerminal: Launching with command: %s",
+ _launchPlan.options.command.empty() ? "(empty)" : _launchPlan.options.command[0].c_str());
+
+#if defined(_WIN32)
+ // On Windows, launch Claude in a separate console window for proper terminal support
+ if (_launchPlan.launchExternal && !_launchPlan.options.command.empty())
+ {
+ // Build command line string
+ std::string cmdLine;
+ for (size_t i = 0; i < _launchPlan.options.command.size(); i++)
+ {
+ if (i > 0) cmdLine += " ";
+ // Quote arguments that contain spaces
+ const auto& arg = _launchPlan.options.command[i];
+ if (arg.find(' ') != std::string::npos)
+ {
+ cmdLine += "\"" + arg + "\"";
+ }
+ else
+ {
+ cmdLine += arg;
+ }
+ }
+
+ // Convert to wide string for CreateProcessW
+ std::wstring wCmdLine(cmdLine.begin(), cmdLine.end());
+ std::wstring wWorkDir;
+ if (!_launchPlan.options.workingDirectory.empty())
+ {
+ wWorkDir = std::wstring(_launchPlan.options.workingDirectory.begin(),
+ _launchPlan.options.workingDirectory.end());
+ }
+
+ // Build environment block - inherit current environment and add custom vars
+ std::wstring envBlock;
+ wchar_t* currentEnv = GetEnvironmentStringsW();
+ if (currentEnv != nullptr)
+ {
+ // Copy current environment (each var is null-terminated, block ends with double null)
+ const wchar_t* ptr = currentEnv;
+ while (*ptr != L'\0')
+ {
+ size_t len = wcslen(ptr);
+ // Skip any existing vars that we're overriding
+ std::wstring varW(ptr, len);
+ bool skip = false;
+ for (const auto& customVar : _launchPlan.options.environment)
+ {
+ size_t eqPos = customVar.find('=');
+ if (eqPos != std::string::npos)
+ {
+ std::string varName = customVar.substr(0, eqPos + 1);
+ std::wstring varNameW(varName.begin(), varName.end());
+ if (varW.substr(0, varNameW.size()) == varNameW)
+ {
+ skip = true;
+ break;
+ }
+ }
+ }
+ if (!skip)
+ {
+ envBlock.append(ptr, len);
+ envBlock += L'\0';
+ }
+ ptr += len + 1;
+ }
+ FreeEnvironmentStringsW(currentEnv);
+ }
+ // Add custom environment variables
+ for (const auto& env : _launchPlan.options.environment)
+ {
+ std::wstring wEnv(env.begin(), env.end());
+ envBlock += wEnv;
+ envBlock += L'\0';
+ }
+ envBlock += L'\0'; // Double null terminator
+
+ STARTUPINFOW si = {};
+ si.cb = sizeof(si);
+ si.dwFlags = STARTF_USESHOWWINDOW;
+ si.wShowWindow = SW_SHOW;
+ PROCESS_INFORMATION pi = {};
+
+ BOOL success = CreateProcessW(
+ nullptr,
+ wCmdLine.data(),
+ nullptr,
+ nullptr,
+ FALSE,
+ CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT,
+ envBlock.data(),
+ wWorkDir.empty() ? nullptr : wWorkDir.c_str(),
+ &si,
+ &pi
+ );
+
+ if (success)
+ {
+ // Store process handle for monitoring
+ _externalProcessHandle = pi.hProcess;
+ CloseHandle(pi.hThread);
+
+ // Create a Job Object to automatically terminate Claude when the game exits
+ HANDLE hJob = CreateJobObjectW(nullptr, nullptr);
+ if (hJob != nullptr)
+ {
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobInfo = {};
+ jobInfo.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
+ SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &jobInfo, sizeof(jobInfo));
+ AssignProcessToJobObject(hJob, pi.hProcess);
+ // Don't close hJob - keep it open so the job stays active
+ // It will be cleaned up when our process exits
+ }
+
+ LOG_INFO("AIAgentTerminal: Claude launched in external console window");
+ _statusMessage = "Claude running in external console window";
+ _errorMessage.clear();
+ _externalLaunchActive = true;
+ }
+ else
+ {
+ DWORD err = GetLastError();
+ _errorMessage = "Failed to launch Claude: error " + std::to_string(err);
+ LOG_WARNING("AIAgentTerminal: External launch failed: %s", _errorMessage.c_str());
+ }
+
+ _needsFullRedraw = true;
+ invalidateWidget(WIDX_TERMINAL_CANVAS);
+ return;
+ }
+#endif
+
std::string error;
_shellProcess = LaunchShellProcess(_launchPlan.options, error);
if (!_shellProcess)
@@ -1081,6 +1255,7 @@ namespace OpenRCT2::Ui::Windows
return;
}
+ LOG_INFO("AIAgentTerminal: Shell process created successfully");
_statusMessage = "Running: " + _launchPlan.description;
_errorMessage.clear();
_needsFullRedraw = true;
@@ -1573,6 +1748,10 @@ namespace OpenRCT2::Ui::Windows
if (totalRead > 0)
{
+ if (!_hasSeenOutput)
+ {
+ LOG_INFO("AIAgentTerminal: First output received (%zu bytes)", totalRead);
+ }
EnsureSession();
UpdateSynchronizedUpdateState({ _ptyDrainBuffer.data(), totalRead });
_terminalSession->FeedOutput({ _ptyDrainBuffer.data(), totalRead });
@@ -1873,6 +2052,8 @@ namespace OpenRCT2::Ui::Windows
_snapshot = std::move(_pendingSnapshot);
_hasSnapshot = true;
_hasPendingSnapshot = false;
+ LOG_INFO("AIAgentTerminal: Snapshot updated, rows=%d cols=%d cells=%zu",
+ _snapshot.rows, _snapshot.cols, _snapshot.cells.size());
// Auto-scroll to tail if:
// 1. Scroll lock is enabled (force stay at bottom), OR
@@ -2129,33 +2310,42 @@ namespace OpenRCT2::Ui::Windows
return { cols, rows, widthPx, heightPx };
}
- void AIAgentTerminalWindow::EnsureOffscreenBuffer(int32_t width, int32_t height)
+ void AIAgentTerminalWindow::EnsureOffscreenBuffer(int32_t bufWidth, int32_t bufHeight)
{
- if (_offscreenWidth == width && _offscreenHeight == height && _offscreenBuffer)
+ if (_offscreenWidth == bufWidth && _offscreenHeight == bufHeight && _offscreenBuffer)
return;
// Allocate new buffer
- const size_t bufferSize = static_cast(width) * height;
+ const size_t bufferSize = static_cast(bufWidth) * bufHeight;
_offscreenBuffer = std::make_unique(bufferSize);
- _offscreenWidth = width;
- _offscreenHeight = height;
+ _offscreenWidth = bufWidth;
+ _offscreenHeight = bufHeight;
// Clear to background color
std::fill_n(_offscreenBuffer.get(), bufferSize, ColourMapA[COLOUR_BLACK].mid_dark);
}
void AIAgentTerminalWindow::BlitOffscreenToScreen(
- RenderTarget& screenRT, const ScreenCoordsXY& destPos, int32_t width, int32_t height)
+ RenderTarget& screenRT, const ScreenCoordsXY& destPos, int32_t blitWidth, int32_t blitHeight)
{
- if (!_offscreenBuffer || width <= 0 || height <= 0)
+ if (!_offscreenBuffer || blitWidth <= 0 || blitHeight <= 0)
+ {
+ static bool sWarnedOnce = false;
+ if (!sWarnedOnce)
+ {
+ LOG_WARNING("AIAgentTerminal: BlitOffscreenToScreen skipped - buffer=%p, w=%d, h=%d",
+ static_cast(_offscreenBuffer.get()), blitWidth, blitHeight);
+ sWarnedOnce = true;
+ }
return;
+ }
// Convert screen coordinates to buffer-relative coordinates
// (same transformation that FillRect uses internally)
int32_t srcX = 0, srcY = 0;
int32_t dstX = destPos.x - screenRT.x;
int32_t dstY = destPos.y - screenRT.y;
- int32_t copyWidth = width, copyHeight = height;
+ int32_t copyWidth = blitWidth, copyHeight = blitHeight;
// Clip left edge
if (dstX < 0)
@@ -2201,6 +2391,15 @@ namespace OpenRCT2::Ui::Windows
void AIAgentTerminalWindow::DrawTerminalDoubleBuffered(RenderTarget& screenRT, const Widget& widget)
{
+ static int sDrawCount = 0;
+ sDrawCount++;
+ if (sDrawCount <= 5 || (sDrawCount % 100 == 0))
+ {
+ LOG_INFO("AIAgentTerminal: Draw #%d, _hasSnapshot=%d, _terminalFontReady=%d, offscreen=%p",
+ sDrawCount, _hasSnapshot ? 1 : 0, _terminalFontReady ? 1 : 0,
+ static_cast(_offscreenBuffer.get()));
+ }
+
// Use full widget size (including padding) to avoid unfilled gaps.
// Widget coordinates are inclusive, so add 1 for actual pixel dimensions.
const int32_t widgetWidth = widget.right - widget.left + 1;
@@ -2247,26 +2446,95 @@ namespace OpenRCT2::Ui::Windows
forceFullRedraw = (_lastOverlayVisible != overlayVisible) || (_lastOverlayHeightPx != overlayHeightPx);
}
- if (forceFullRedraw)
- {
- // Draw terminal content to offscreen buffer (includes padding fill)
- DrawTerminalToBuffer(offscreenRT, widgetWidth, widgetHeight, lines);
- _renderedCells = std::move(currentCells);
- _renderedCols = _hasSnapshot ? _snapshot.cols : 0;
- _renderedRows = _hasSnapshot ? _visibleRows : 0;
- _needsFullRedraw = false;
- }
- else
+ // Draw directly to screen (bypasses offscreen buffer which has issues on Windows)
+ ScreenCoordsXY widgetScreenPos = windowPos + ScreenCoordsXY{ widget.left, widget.top };
+
+ // Draw background
+ ScreenCoordsXY bgTopLeft = widgetScreenPos;
+ ScreenCoordsXY bgBottomRight = widgetScreenPos + ScreenCoordsXY{ widgetWidth - 1, widgetHeight - 1 };
+ Rect::fill(screenRT, { bgTopLeft, bgBottomRight }, ColourMapA[COLOUR_BLACK].mid_dark);
+
+ // Draw cells if we have a snapshot - use native DrawText for proper UTF-8 support
+ if (_hasSnapshot && !_snapshot.cells.empty())
{
- DrawTerminalDirty(offscreenRT, widgetWidth, widgetHeight, currentCells, lines);
+ const int32_t cols = _snapshot.cols;
+ const int32_t rows = std::min(_visibleRows, _snapshot.rows);
+ ScreenCoordsXY contentOrigin = widgetScreenPos + ScreenCoordsXY{ kTerminalPadding, kTerminalPadding };
+
+ // Build and draw each row as a single string using native text rendering
+ for (int32_t row = 0; row < rows; row++)
+ {
+ // Build UTF-8 string for this row
+ std::string rowText;
+ rowText.reserve(static_cast(cols) * 4); // Reserve for worst-case UTF-8
+
+ for (int32_t col = 0; col < cols; col++)
+ {
+ const size_t idx = static_cast(row) * cols + col;
+ if (idx >= _snapshot.cells.size())
+ break;
+
+ const auto& cell = _snapshot.cells[idx];
+
+ // Skip continuation cells (part of wide characters)
+ if (cell.continuation)
+ continue;
+
+ // Convert codepoint to UTF-8, substituting box drawing chars with ASCII
+ char32_t cp = cell.codepoint;
+ if (cp > 0 && cp != U' ')
+ {
+ // Substitute box drawing characters (U+2500-U+257F) with ASCII
+ if (cp >= 0x2500 && cp <= 0x257F)
+ {
+ // Horizontal lines
+ if (cp == 0x2500 || cp == 0x2501 || cp == 0x2504 || cp == 0x2505 ||
+ cp == 0x2508 || cp == 0x2509 || cp == 0x254C || cp == 0x254D)
+ cp = '-';
+ // Vertical lines
+ else if (cp == 0x2502 || cp == 0x2503 || cp == 0x2506 || cp == 0x2507 ||
+ cp == 0x250A || cp == 0x250B || cp == 0x254E || cp == 0x254F)
+ cp = '|';
+ // Corners and intersections become +
+ else
+ cp = '+';
+ }
+ // Block elements (U+2580-U+259F) - use # or space
+ else if (cp >= 0x2580 && cp <= 0x259F)
+ {
+ cp = '#';
+ }
+
+ char buffer[8] = {};
+ char* end = UTF8WriteCodepoint(buffer, static_cast(cp));
+ rowText.append(buffer, end - buffer);
+ }
+ else
+ {
+ rowText.push_back(' ');
+ }
+ }
+
+ // Trim trailing spaces for cleaner rendering
+ while (!rowText.empty() && rowText.back() == ' ')
+ rowText.pop_back();
+
+ // Draw the row using native text rendering
+ if (!rowText.empty())
+ {
+ ScreenCoordsXY rowPos = contentOrigin + ScreenCoordsXY{ 0, row * _cellHeight };
+ DrawText(screenRT, rowPos, { COLOUR_WHITE, FontStyle::medium }, rowText.c_str(), true);
+ }
+ }
}
+ // Draw status overlay
+ ScreenCoordsXY overlayOrigin = widgetScreenPos + ScreenCoordsXY{ kTerminalPadding, kTerminalPadding };
+ DrawStatusOverlay(screenRT, overlayOrigin, contentWidth, contentHeight, lines);
+
_lastOverlayVisible = overlayVisible;
_lastOverlayHeightPx = overlayHeightPx;
-
- // Blit the complete widget frame to screen in one operation
- ScreenCoordsXY destPos = windowPos + ScreenCoordsXY{ widget.left, widget.top };
- BlitOffscreenToScreen(screenRT, destPos, widgetWidth, widgetHeight);
+ _needsFullRedraw = false;
}
void AIAgentTerminalWindow::DrawTerminalToBuffer(
@@ -2879,7 +3147,11 @@ namespace OpenRCT2::Ui::Windows
_autoplayPromptIndex = 0;
// Try to load from workspace auto_prompts.txt
+#if defined(_WIN32)
+ const char* home = std::getenv("USERPROFILE");
+#else
const char* home = std::getenv("HOME");
+#endif
if (!home || !*home)
{
return;
@@ -3108,12 +3380,21 @@ namespace OpenRCT2::Ui::Windows
void AIAgentTerminalWindow::InitializeSessionMonitor()
{
// Get the workspace path that Claude is running in
+#if defined(_WIN32)
+ const char* home = std::getenv("USERPROFILE");
+ if (!home || !*home)
+ {
+ LOG_WARNING("Auto-play: Cannot initialize session monitor - USERPROFILE not set");
+ return;
+ }
+#else
const char* home = std::getenv("HOME");
if (!home || !*home)
{
LOG_WARNING("Auto-play: Cannot initialize session monitor - HOME not set");
return;
}
+#endif
auto workspacePath = std::filesystem::path(home) / ".openrct2-agent";
// Create session monitor to watch Claude's JSONL session files
@@ -3212,9 +3493,131 @@ namespace OpenRCT2::Ui::Windows
WindowBase* AIAgentTerminalOpen()
{
+#if defined(_WIN32)
+ // On Windows, launch Claude in a separate console window without creating an in-game terminal
+ auto plan = BuildAIAgentLaunchPlan(80, 24);
+ if (!plan.available || !plan.launchExternal || plan.options.command.empty())
+ {
+ // Fall back to in-game window if external launch not available
+ auto* windowMgr = GetWindowManager();
+ return windowMgr->FocusOrCreate(
+ WindowClass::aiAgentTerminal, kAgentWindowSize,
+ { WindowFlag::resizable, WindowFlag::autoPosition, WindowFlag::higherContrastOnPress, WindowFlag::noPush });
+ }
+
+ // Build command line string
+ std::string cmdLine;
+ for (size_t i = 0; i < plan.options.command.size(); i++)
+ {
+ if (i > 0) cmdLine += " ";
+ const auto& arg = plan.options.command[i];
+ if (arg.find(' ') != std::string::npos)
+ {
+ cmdLine += "\"" + arg + "\"";
+ }
+ else
+ {
+ cmdLine += arg;
+ }
+ }
+
+ std::wstring wCmdLine(cmdLine.begin(), cmdLine.end());
+ std::wstring wWorkDir;
+ if (!plan.options.workingDirectory.empty())
+ {
+ wWorkDir = std::wstring(plan.options.workingDirectory.begin(),
+ plan.options.workingDirectory.end());
+ }
+
+ // Build environment block
+ std::wstring envBlock;
+ wchar_t* currentEnv = GetEnvironmentStringsW();
+ if (currentEnv != nullptr)
+ {
+ const wchar_t* ptr = currentEnv;
+ while (*ptr != L'\0')
+ {
+ size_t len = wcslen(ptr);
+ std::wstring varW(ptr, len);
+ bool skip = false;
+ for (const auto& customVar : plan.options.environment)
+ {
+ size_t eqPos = customVar.find('=');
+ if (eqPos != std::string::npos)
+ {
+ std::string varName = customVar.substr(0, eqPos + 1);
+ std::wstring varNameW(varName.begin(), varName.end());
+ if (varW.substr(0, varNameW.size()) == varNameW)
+ {
+ skip = true;
+ break;
+ }
+ }
+ }
+ if (!skip)
+ {
+ envBlock.append(ptr, len);
+ envBlock += L'\0';
+ }
+ ptr += len + 1;
+ }
+ FreeEnvironmentStringsW(currentEnv);
+ }
+ for (const auto& env : plan.options.environment)
+ {
+ std::wstring wEnv(env.begin(), env.end());
+ envBlock += wEnv;
+ envBlock += L'\0';
+ }
+ envBlock += L'\0';
+
+ STARTUPINFOW si = {};
+ si.cb = sizeof(si);
+ si.dwFlags = STARTF_USESHOWWINDOW;
+ si.wShowWindow = SW_SHOW;
+ PROCESS_INFORMATION pi = {};
+
+ BOOL success = CreateProcessW(
+ nullptr,
+ wCmdLine.data(),
+ nullptr,
+ nullptr,
+ FALSE,
+ CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT,
+ envBlock.data(),
+ wWorkDir.empty() ? nullptr : wWorkDir.c_str(),
+ &si,
+ &pi
+ );
+
+ if (success)
+ {
+ CloseHandle(pi.hThread);
+
+ // Create Job Object to auto-terminate Claude when game exits
+ HANDLE hJob = CreateJobObjectW(nullptr, nullptr);
+ if (hJob != nullptr)
+ {
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobInfo = {};
+ jobInfo.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
+ SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &jobInfo, sizeof(jobInfo));
+ AssignProcessToJobObject(hJob, pi.hProcess);
+ }
+
+ LOG_INFO("AIAgentTerminal: Claude launched in external console window (no in-game window)");
+ CloseHandle(pi.hProcess);
+ }
+ else
+ {
+ LOG_WARNING("AIAgentTerminal: Failed to launch Claude externally: %lu", GetLastError());
+ }
+
+ return nullptr; // No in-game window
+#else
auto* windowMgr = GetWindowManager();
return windowMgr->FocusOrCreate(
WindowClass::aiAgentTerminal, kAgentWindowSize,
{ WindowFlag::resizable, WindowFlag::autoPosition, WindowFlag::higherContrastOnPress, WindowFlag::noPush });
+#endif
}
} // namespace OpenRCT2::Ui::Windows
diff --git a/src/openrct2/Diagnostic.cpp b/src/openrct2/Diagnostic.cpp
index 713b34b6fd2d..67dd4afa0cbe 100644
--- a/src/openrct2/Diagnostic.cpp
+++ b/src/openrct2/Diagnostic.cpp
@@ -15,6 +15,7 @@
#include
#include
+#include
#ifdef __ANDROID__
#include
diff --git a/src/openrct2/drawing/Font.h b/src/openrct2/drawing/Font.h
index fda084eee8b9..5b960a53da57 100644
--- a/src/openrct2/drawing/Font.h
+++ b/src/openrct2/drawing/Font.h
@@ -14,6 +14,11 @@
#include
+// Windows headers define 'small' as a macro for 'char', which conflicts with our enum
+#ifdef small
+ #undef small
+#endif
+
constexpr uint16_t kSpriteFontGlyphCount = 224;
enum class FontStyle : uint8_t
diff --git a/src/openrct2/libopenrct2.vcxproj b/src/openrct2/libopenrct2.vcxproj
index cdc55f28bd65..283ce9846623 100644
--- a/src/openrct2/libopenrct2.vcxproj
+++ b/src/openrct2/libopenrct2.vcxproj
@@ -619,6 +619,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1152,6 +1165,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/openrct2/terminal/AIAgentLaunch.cpp b/src/openrct2/terminal/AIAgentLaunch.cpp
index f8c3dde4cee3..07609281f576 100644
--- a/src/openrct2/terminal/AIAgentLaunch.cpp
+++ b/src/openrct2/terminal/AIAgentLaunch.cpp
@@ -31,6 +31,11 @@
#if defined(__APPLE__) || defined(__linux__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__)
#include
+#elif defined(_WIN32)
+ #include
+ #define access _access
+ #define X_OK 0
+ #define F_OK 0
#endif
namespace OpenRCT2::Terminal
@@ -88,6 +93,65 @@ namespace OpenRCT2::Terminal
return std::nullopt;
}
+#elif defined(_WIN32)
+ std::optional FindExecutable(const std::string& name)
+ {
+ if (name.empty())
+ {
+ return std::nullopt;
+ }
+
+ // Common executable extensions on Windows
+ const std::vector extensions = { "", ".exe", ".cmd", ".bat", ".com" };
+
+ // Check if it's an absolute path (contains \ or drive letter like C:)
+ if (name.find('\\') != std::string::npos || (name.length() >= 2 && name[1] == ':'))
+ {
+ for (const auto& ext : extensions)
+ {
+ std::string candidate = name + ext;
+ if (std::filesystem::exists(candidate))
+ {
+ return candidate;
+ }
+ }
+ return std::nullopt;
+ }
+
+ const char* pathEnv = std::getenv("PATH");
+ if (pathEnv == nullptr)
+ {
+ return std::nullopt;
+ }
+
+ std::string_view remaining(pathEnv);
+ size_t start = 0;
+ while (start <= remaining.size())
+ {
+ // Windows uses ; as PATH separator
+ const size_t end = remaining.find(';', start);
+ auto segment = remaining.substr(start, end == std::string_view::npos ? remaining.size() - start : end - start);
+ if (!segment.empty())
+ {
+ for (const auto& ext : extensions)
+ {
+ std::filesystem::path candidate = std::filesystem::path(segment) / (name + ext);
+ if (std::filesystem::exists(candidate))
+ {
+ return candidate.string();
+ }
+ }
+ }
+ if (end == std::string_view::npos)
+ {
+ break;
+ }
+ start = end + 1;
+ }
+
+ return std::nullopt;
+ }
+#endif
bool IsStubWorkspaceReadme(const std::filesystem::path& readmePath)
{
@@ -173,11 +237,19 @@ namespace OpenRCT2::Terminal
WorkspaceResult EnsureWorkspace()
{
+#if defined(_WIN32)
+ const char* home = std::getenv("USERPROFILE");
+ if (!home || !*home)
+ {
+ return { {}, false, "USERPROFILE environment variable not set" };
+ }
+#else
const char* home = std::getenv("HOME");
if (!home || !*home)
{
return { {}, false, "HOME environment variable not set" };
}
+#endif
auto workspace = std::filesystem::path(home) / kAgentWorkspaceDir;
std::error_code ec;
std::filesystem::create_directories(workspace, ec);
@@ -253,6 +325,11 @@ namespace OpenRCT2::Terminal
{
const auto base = repoRoot.value_or(std::filesystem::current_path());
std::vector candidates = {
+ // Windows Visual Studio build locations
+ base / "bin" / "rctctl",
+ base / "rctctl" / "build" / "Release" / "rctctl",
+ base / "rctctl" / "build" / "Debug" / "rctctl",
+ // CMake build locations (Unix/macOS)
base / "build" / "rctctl" / "rctctl",
base / "build" / "rctctl" / "Release" / "rctctl",
base / "build" / "rctctl" / "Debug" / "rctctl",
@@ -371,7 +448,11 @@ namespace OpenRCT2::Terminal
auto now = std::chrono::system_clock::now();
auto timeT = std::chrono::system_clock::to_time_t(now);
std::tm tm{};
+#if defined(_WIN32)
+ localtime_s(&tm, &timeT);
+#else
localtime_r(&timeT, &tm);
+#endif
std::ostringstream filename;
filename << "agent-session-" << std::put_time(&tm, "%Y%m%d-%H%M%S");
@@ -448,13 +529,18 @@ namespace OpenRCT2::Terminal
{
std::ostringstream buffer;
bool first = true;
+#if defined(_WIN32)
+ constexpr char kPathSeparator = ';';
+#else
+ constexpr char kPathSeparator = ':';
+#endif
for (size_t i = 0; i < segments.size(); ++i)
{
if (!segments[i].empty())
{
if (!first)
{
- buffer << ":";
+ buffer << kPathSeparator;
}
first = false;
buffer << segments[i];
@@ -467,7 +553,6 @@ namespace OpenRCT2::Terminal
}
}
-#endif
} // namespace
AIAgentLaunchPlan BuildAIAgentLaunchPlan(int cols, int rows)
@@ -476,7 +561,7 @@ namespace OpenRCT2::Terminal
plan.options.cols = cols;
plan.options.rows = rows;
-#if defined(__APPLE__) || defined(__linux__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__)
+#if defined(__APPLE__) || defined(__linux__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__) || defined(_WIN32)
// Step 1: Detect repo root - REQUIRED for proper agent setup
auto repoRoot = DetectRepoRoot();
if (!repoRoot)
@@ -532,6 +617,11 @@ namespace OpenRCT2::Terminal
if (const char* customCommand = std::getenv("AGENT_TERMINAL_COMMAND"))
{
+#if defined(_WIN32)
+ // On Windows, use cmd.exe to run custom commands
+ // Session logging via script is not available, but in-game SessionLogGenerator handles it
+ plan.options.command = { "cmd.exe", "/c", customCommand };
+#else
if (sessionLogFile)
{
// Wrap with script to capture terminal output: script -q /bin/sh -lc
@@ -541,6 +631,7 @@ namespace OpenRCT2::Terminal
{
plan.options.command = { "/bin/sh", "-lc", customCommand };
}
+#endif
plan.description = customCommand;
plan.usesAgent = true;
plan.available = true;
@@ -550,20 +641,30 @@ namespace OpenRCT2::Terminal
// Check for Claude Code CLI (searching for "claude" executable is intentional - it's the actual product binary name)
if (auto agentBin = FindExecutable("claude"))
{
- // Settings to pass to Claude Code CLI (disable spinner tips for cleaner UI)
- constexpr const char* kClaudeSettings = R"({"spinnerTipsEnabled":false})";
-
// Launch Claude directly - session logs are generated in-game via SessionLogGenerator
// on terminal close and /clear command (more reliable than external wrapper scripts)
+#if defined(_WIN32)
+ // On Windows, launch Claude in a separate console window for proper terminal support
+ // The in-game window will show status while Claude runs in its own native console
+ plan.options.command = {
+ agentBin.value(), "--dangerously-skip-permissions"
+ };
+ plan.launchExternal = true;
+#else
+ // Settings to pass to Claude Code CLI (disable spinner tips for cleaner UI)
+ constexpr const char* kClaudeSettings = R"({"spinnerTipsEnabled":false})";
plan.options.command = {
agentBin.value(), "--dangerously-skip-permissions", "--settings", kClaudeSettings
};
+#endif
plan.description = agentBin.value();
plan.usesAgent = true;
plan.available = true;
return plan;
}
+#if !defined(_WIN32)
+ // Bootstrap script fallback (Unix only - bash scripts don't work on Windows)
auto resolveBootstrapScript = [&]() -> std::optional {
constexpr std::string_view kBootstrap = "agent_bootstrap.sh";
std::vector candidates;
@@ -598,6 +699,7 @@ namespace OpenRCT2::Terminal
plan.available = true;
return plan;
}
+#endif
plan.error = "Claude Code CLI not found. Install it with: npm install -g @anthropic-ai/claude-code";
plan.available = false;
diff --git a/src/openrct2/terminal/AIAgentLaunch.h b/src/openrct2/terminal/AIAgentLaunch.h
index ed34152ab842..2c467f680cac 100644
--- a/src/openrct2/terminal/AIAgentLaunch.h
+++ b/src/openrct2/terminal/AIAgentLaunch.h
@@ -22,6 +22,7 @@ namespace OpenRCT2::Terminal
std::string error;
bool usesAgent = false;
bool available = false;
+ bool launchExternal = false; // Windows: launch in separate console window
};
AIAgentLaunchPlan BuildAIAgentLaunchPlan(int cols, int rows);
diff --git a/src/openrct2/terminal/SessionFileMonitor.cpp b/src/openrct2/terminal/SessionFileMonitor.cpp
index 6f4c4ae7cee8..82bf7858553a 100644
--- a/src/openrct2/terminal/SessionFileMonitor.cpp
+++ b/src/openrct2/terminal/SessionFileMonitor.cpp
@@ -544,7 +544,11 @@ namespace OpenRCT2::Terminal
std::filesystem::path SessionFileMonitor::GetClaudeProjectsDir()
{
// Claude stores projects in ~/.claude/projects/
+#if defined(_WIN32)
+ const char* homeDir = std::getenv("USERPROFILE");
+#else
const char* homeDir = std::getenv("HOME");
+#endif
if (!homeDir)
{
return {};
@@ -560,21 +564,37 @@ namespace OpenRCT2::Terminal
// 3. Replacing all / with -
// 4. Replacing all spaces with -
// Example: /Users/foo/Library/Application Support/bar -> -Users-foo-Library-Application-Support-bar
+ // Windows: C:\Users\foo\bar -> -C-Users-foo-bar
std::string pathStr = workspacePath.string();
// Ensure we have an absolute path
+#if defined(_WIN32)
+ // Windows: check for drive letter (e.g., C:\)
+ if (pathStr.size() < 2 || pathStr[1] != ':')
+ {
+ std::error_code ec;
+ pathStr = std::filesystem::absolute(workspacePath, ec).string();
+ }
+ // Remove drive letter colon (C: -> C) and add leading dash
+ if (pathStr.size() >= 2 && pathStr[1] == ':')
+ {
+ pathStr = "-" + pathStr.substr(0, 1) + pathStr.substr(2);
+ }
+ // Replace backslashes with -
+ std::replace(pathStr.begin(), pathStr.end(), '\\', '-');
+#else
if (!pathStr.empty() && pathStr[0] != '/')
{
std::error_code ec;
pathStr = std::filesystem::absolute(workspacePath, ec).string();
}
-
// Replace leading / with -
if (!pathStr.empty() && pathStr[0] == '/')
{
pathStr[0] = '-';
}
+#endif
// Replace remaining / with -
std::replace(pathStr.begin(), pathStr.end(), '/', '-');
diff --git a/src/openrct2/terminal/SessionLogGenerator.cpp b/src/openrct2/terminal/SessionLogGenerator.cpp
index 9ea999b33a21..28f25344b933 100644
--- a/src/openrct2/terminal/SessionLogGenerator.cpp
+++ b/src/openrct2/terminal/SessionLogGenerator.cpp
@@ -20,6 +20,12 @@
#include "../Diagnostic.h"
#include "../platform/Platform.h"
+// Windows uses _popen/_pclose instead of popen/pclose
+#ifdef _WIN32
+ #define popen _popen
+ #define pclose _pclose
+#endif
+
namespace OpenRCT2::Terminal
{
namespace
@@ -91,13 +97,22 @@ namespace OpenRCT2::Terminal
return false;
}
- // Find python3
+ // Find python
+#if defined(_WIN32)
+ std::string python = "python";
+ if (std::system("where python >nul 2>&1") != 0)
+ {
+ LOG_WARNING("SessionLogGenerator: python not found");
+ return false;
+ }
+#else
std::string python = "python3";
if (std::system("command -v python3 >/dev/null 2>&1") != 0)
{
LOG_WARNING("SessionLogGenerator: python3 not found");
return false;
}
+#endif
// Run the markdown converter with the input path (file or directory)
std::string command = python + " \"" + scriptPath->string() + "\" \"" +
@@ -113,7 +128,7 @@ namespace OpenRCT2::Terminal
std::array buffer;
std::string cmdOutput;
- while (fgets(buffer.data(), buffer.size(), pipe) != nullptr)
+ while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr)
{
cmdOutput += buffer.data();
}
@@ -177,12 +192,21 @@ namespace OpenRCT2::Terminal
return false;
}
+#if defined(_WIN32)
+ std::string python = "python";
+ if (std::system("where python >nul 2>&1") != 0)
+ {
+ LOG_WARNING("SessionLogGenerator: python not found");
+ return false;
+ }
+#else
std::string python = "python3";
if (std::system("command -v python3 >/dev/null 2>&1") != 0)
{
LOG_WARNING("SessionLogGenerator: python3 not found");
return false;
}
+#endif
std::string command = python + " \"" + scriptPath->string() + "\" \"" +
markdownPath.string() + "\" -o \"" + outputPath.string() + "\" 2>&1";
@@ -197,7 +221,7 @@ namespace OpenRCT2::Terminal
std::array buffer;
std::string cmdOutput;
- while (fgets(buffer.data(), buffer.size(), pipe) != nullptr)
+ while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr)
{
cmdOutput += buffer.data();
}
@@ -383,7 +407,11 @@ namespace OpenRCT2::Terminal
// Fallback: user's home directory
if (logsDir.empty())
{
+#if defined(_WIN32)
+ const char* home = std::getenv("USERPROFILE");
+#else
const char* home = std::getenv("HOME");
+#endif
if (home)
{
logsDir = std::filesystem::path(home) / "OpenRCT2-agent-logs";
@@ -405,7 +433,11 @@ namespace OpenRCT2::Terminal
const std::filesystem::path& workspacePath)
{
// Claude stores projects in ~/.claude/projects/-{workspace-path-with-dashes}
+#if defined(_WIN32)
+ const char* home = std::getenv("USERPROFILE");
+#else
const char* home = std::getenv("HOME");
+#endif
if (!home)
{
return std::nullopt;
@@ -418,19 +450,27 @@ namespace OpenRCT2::Terminal
}
// Convert workspace path to project dir name
- // e.g., /Users/foo/.openrct2-agent
- // becomes -Users-foo-.openrct2-agent
+ // e.g., /Users/foo/.openrct2-agent -> -Users-foo--openrct2-agent
+ // e.g., C:\Users\foo\.openrct2-agent -> -C-Users-foo--openrct2-agent
std::string workspaceStr = workspacePath.string();
- if (workspaceStr.front() == '/')
+#if defined(_WIN32)
+ // Remove drive letter colon (C: -> C)
+ if (workspaceStr.length() >= 2 && workspaceStr[1] == ':')
+ {
+ workspaceStr = workspaceStr[0] + workspaceStr.substr(2);
+ }
+#else
+ if (!workspaceStr.empty() && workspaceStr.front() == '/')
{
workspaceStr = workspaceStr.substr(1);
}
+#endif
std::string projectDirName = "-";
for (char c : workspaceStr)
{
- if (c == '/' || c == ' ' || c == '.')
+ if (c == '/' || c == '\\' || c == ' ' || c == '.')
{
- // Claude converts slashes, spaces, and dots to dashes in project dir names
+ // Claude converts slashes, backslashes, spaces, and dots to dashes in project dir names
projectDirName += '-';
}
else
diff --git a/src/openrct2/terminal/ShellProcess.cpp b/src/openrct2/terminal/ShellProcess.cpp
index 5e18bf3f00c8..d9ed83f6da32 100644
--- a/src/openrct2/terminal/ShellProcess.cpp
+++ b/src/openrct2/terminal/ShellProcess.cpp
@@ -21,7 +21,15 @@
#include
#include
-#if defined(__APPLE__)
+#if defined(_WIN32)
+ #ifndef WIN32_LEAN_AND_MEAN
+ #define WIN32_LEAN_AND_MEAN
+ #endif
+ #ifndef NOMINMAX
+ #define NOMINMAX
+ #endif
+ #include
+#elif defined(__APPLE__)
#include
#include
#include
@@ -300,6 +308,354 @@ namespace OpenRCT2::Terminal
return std::make_unique(masterFd, pid, JoinCommand(options.command));
}
#endif
+
+#if defined(_WIN32)
+ std::string JoinCommandWindows(const std::vector& command)
+ {
+ std::ostringstream ss;
+ for (size_t i = 0; i < command.size(); i++)
+ {
+ if (i != 0)
+ {
+ ss << ' ';
+ }
+ // Quote arguments that contain spaces
+ const auto& arg = command[i];
+ if (arg.find(' ') != std::string::npos || arg.find('\t') != std::string::npos)
+ {
+ ss << '"' << arg << '"';
+ }
+ else
+ {
+ ss << arg;
+ }
+ }
+ return ss.str();
+ }
+
+ class WindowsShellProcess final : public ShellProcess
+ {
+ public:
+ WindowsShellProcess(
+ HPCON hPC, HANDLE hProcess, HANDLE hThread, HANDLE hPipeIn, HANDLE hPipeOut, std::string description)
+ : _hPC(hPC)
+ , _hProcess(hProcess)
+ , _hThread(hThread)
+ , _hPipeIn(hPipeIn)
+ , _hPipeOut(hPipeOut)
+ , _description(std::move(description))
+ {
+ }
+
+ ~WindowsShellProcess() override
+ {
+ Cleanup();
+ }
+
+ [[nodiscard]] bool IsRunning() const override
+ {
+ if (_exited)
+ return false;
+
+ DWORD exitCode = 0;
+ if (GetExitCodeProcess(_hProcess, &exitCode))
+ {
+ if (exitCode != STILL_ACTIVE)
+ {
+ _exited = true;
+ _exitStatus = static_cast(exitCode);
+ return false;
+ }
+ }
+ return true;
+ }
+
+ ssize_t Read(uint8_t* buffer, size_t length) override
+ {
+ if (_hPipeIn == INVALID_HANDLE_VALUE)
+ return -1;
+
+ // Check if there's data available (non-blocking)
+ DWORD available = 0;
+ if (!PeekNamedPipe(_hPipeIn, nullptr, 0, nullptr, &available, nullptr))
+ {
+ CheckProcess();
+ return _exited ? -1 : 0;
+ }
+
+ if (available == 0)
+ {
+ CheckProcess();
+ return 0;
+ }
+
+ DWORD toRead = static_cast(std::min(length, available));
+ DWORD bytesRead = 0;
+ if (!ReadFile(_hPipeIn, buffer, toRead, &bytesRead, nullptr))
+ {
+ CheckProcess();
+ return _exited ? -1 : 0;
+ }
+
+ return static_cast(bytesRead);
+ }
+
+ bool Write(std::span data) override
+ {
+ if (_hPipeOut == INVALID_HANDLE_VALUE || data.empty())
+ return false;
+
+ const uint8_t* ptr = data.data();
+ size_t remaining = data.size();
+ while (remaining > 0)
+ {
+ DWORD written = 0;
+ DWORD toWrite = static_cast(std::min(remaining, 0xFFFFFFFF));
+ if (!WriteFile(_hPipeOut, ptr, toWrite, &written, nullptr))
+ {
+ return false;
+ }
+ ptr += written;
+ remaining -= written;
+ }
+ return true;
+ }
+
+ void Resize(int cols, int rows) override
+ {
+ if (_hPC == nullptr)
+ return;
+
+ COORD size;
+ size.X = static_cast(std::clamp(cols, 2, 500));
+ size.Y = static_cast(std::clamp(rows, 2, 500));
+ ResizePseudoConsole(_hPC, size);
+ }
+
+ [[nodiscard]] int ExitStatus() const override
+ {
+ return _exitStatus;
+ }
+
+ [[nodiscard]] std::string_view CommandDescription() const override
+ {
+ return _description;
+ }
+
+ private:
+ HPCON _hPC = nullptr;
+ HANDLE _hProcess = INVALID_HANDLE_VALUE;
+ HANDLE _hThread = INVALID_HANDLE_VALUE;
+ HANDLE _hPipeIn = INVALID_HANDLE_VALUE;
+ HANDLE _hPipeOut = INVALID_HANDLE_VALUE;
+ std::string _description;
+ mutable bool _exited = false;
+ mutable int _exitStatus = 0;
+
+ void CheckProcess()
+ {
+ if (_exited)
+ return;
+
+ DWORD exitCode = 0;
+ if (GetExitCodeProcess(_hProcess, &exitCode) && exitCode != STILL_ACTIVE)
+ {
+ _exited = true;
+ _exitStatus = static_cast(exitCode);
+ }
+ }
+
+ void Cleanup()
+ {
+ if (_hProcess != INVALID_HANDLE_VALUE && !_exited)
+ {
+ // Try graceful termination first
+ TerminateProcess(_hProcess, 1);
+ WaitForSingleObject(_hProcess, 500);
+ }
+
+ if (_hPC != nullptr)
+ {
+ ClosePseudoConsole(_hPC);
+ _hPC = nullptr;
+ }
+ if (_hPipeIn != INVALID_HANDLE_VALUE)
+ {
+ CloseHandle(_hPipeIn);
+ _hPipeIn = INVALID_HANDLE_VALUE;
+ }
+ if (_hPipeOut != INVALID_HANDLE_VALUE)
+ {
+ CloseHandle(_hPipeOut);
+ _hPipeOut = INVALID_HANDLE_VALUE;
+ }
+ if (_hThread != INVALID_HANDLE_VALUE)
+ {
+ CloseHandle(_hThread);
+ _hThread = INVALID_HANDLE_VALUE;
+ }
+ if (_hProcess != INVALID_HANDLE_VALUE)
+ {
+ CloseHandle(_hProcess);
+ _hProcess = INVALID_HANDLE_VALUE;
+ }
+ }
+ };
+
+ std::unique_ptr LaunchWindowsProcess(const ShellLaunchOptions& options, std::string& errorOut)
+ {
+ if (options.command.empty())
+ {
+ errorOut = "No command specified for terminal session.";
+ return nullptr;
+ }
+
+ HANDLE hPipeInRead = INVALID_HANDLE_VALUE;
+ HANDLE hPipeInWrite = INVALID_HANDLE_VALUE;
+ HANDLE hPipeOutRead = INVALID_HANDLE_VALUE;
+ HANDLE hPipeOutWrite = INVALID_HANDLE_VALUE;
+ HPCON hPC = nullptr;
+
+ auto cleanup = [&]() {
+ if (hPipeInRead != INVALID_HANDLE_VALUE)
+ CloseHandle(hPipeInRead);
+ if (hPipeInWrite != INVALID_HANDLE_VALUE)
+ CloseHandle(hPipeInWrite);
+ if (hPipeOutRead != INVALID_HANDLE_VALUE)
+ CloseHandle(hPipeOutRead);
+ if (hPipeOutWrite != INVALID_HANDLE_VALUE)
+ CloseHandle(hPipeOutWrite);
+ if (hPC != nullptr)
+ ClosePseudoConsole(hPC);
+ };
+
+ // Create pipes for stdin and stdout
+ SECURITY_ATTRIBUTES sa = { sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE };
+ if (!CreatePipe(&hPipeInRead, &hPipeInWrite, &sa, 0))
+ {
+ errorOut = "Failed to create input pipe";
+ cleanup();
+ return nullptr;
+ }
+ if (!CreatePipe(&hPipeOutRead, &hPipeOutWrite, &sa, 0))
+ {
+ errorOut = "Failed to create output pipe";
+ cleanup();
+ return nullptr;
+ }
+
+ // Create the pseudo console
+ COORD consoleSize;
+ consoleSize.X = static_cast(std::clamp(options.cols, 2, 500));
+ consoleSize.Y = static_cast(std::clamp(options.rows, 2, 500));
+
+ HRESULT hr = CreatePseudoConsole(consoleSize, hPipeOutRead, hPipeInWrite, 0, &hPC);
+ if (FAILED(hr))
+ {
+ errorOut = "Failed to create pseudo console (requires Windows 10 1809+)";
+ cleanup();
+ return nullptr;
+ }
+
+ // Close the handles that are now owned by the pseudo console
+ CloseHandle(hPipeOutRead);
+ hPipeOutRead = INVALID_HANDLE_VALUE;
+ CloseHandle(hPipeInWrite);
+ hPipeInWrite = INVALID_HANDLE_VALUE;
+
+ // Initialize the startup info with the pseudo console
+ STARTUPINFOEXW startupInfo = {};
+ startupInfo.StartupInfo.cb = sizeof(STARTUPINFOEXW);
+
+ SIZE_T attrListSize = 0;
+ InitializeProcThreadAttributeList(nullptr, 1, 0, &attrListSize);
+
+ std::vector attrListBuffer(attrListSize);
+ startupInfo.lpAttributeList = reinterpret_cast(attrListBuffer.data());
+
+ if (!InitializeProcThreadAttributeList(startupInfo.lpAttributeList, 1, 0, &attrListSize))
+ {
+ errorOut = "Failed to initialize attribute list";
+ cleanup();
+ return nullptr;
+ }
+
+ if (!UpdateProcThreadAttribute(
+ startupInfo.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, hPC, sizeof(HPCON), nullptr,
+ nullptr))
+ {
+ errorOut = "Failed to set pseudo console attribute";
+ DeleteProcThreadAttributeList(startupInfo.lpAttributeList);
+ cleanup();
+ return nullptr;
+ }
+
+ // Build the command line
+ std::string cmdLine = JoinCommandWindows(options.command);
+ std::wstring wCmdLine(cmdLine.begin(), cmdLine.end());
+
+ // Build environment block - inherit current environment and add custom vars
+ std::wstring envBlock;
+ if (!options.environment.empty())
+ {
+ // Get current environment and copy it
+ wchar_t* currentEnv = GetEnvironmentStringsW();
+ if (currentEnv != nullptr)
+ {
+ // Environment block is double-null terminated, each var is null-terminated
+ const wchar_t* ptr = currentEnv;
+ while (*ptr != L'\0')
+ {
+ size_t len = wcslen(ptr);
+ envBlock.append(ptr, len);
+ envBlock += L'\0';
+ ptr += len + 1;
+ }
+ FreeEnvironmentStringsW(currentEnv);
+ }
+
+ // Add custom environment variables
+ for (const auto& env : options.environment)
+ {
+ std::wstring wEnv(env.begin(), env.end());
+ envBlock += wEnv;
+ envBlock += L'\0';
+ }
+ envBlock += L'\0';
+ }
+
+ // Convert working directory
+ std::wstring wWorkingDir;
+ if (!options.workingDirectory.empty())
+ {
+ wWorkingDir = std::wstring(options.workingDirectory.begin(), options.workingDirectory.end());
+ }
+
+ PROCESS_INFORMATION procInfo = {};
+ BOOL success = CreateProcessW(
+ nullptr, wCmdLine.data(),
+ nullptr, // Process security attributes
+ nullptr, // Thread security attributes
+ FALSE, // Inherit handles
+ EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT,
+ options.environment.empty() ? nullptr : envBlock.data(),
+ wWorkingDir.empty() ? nullptr : wWorkingDir.c_str(), &startupInfo.StartupInfo, &procInfo);
+
+ DeleteProcThreadAttributeList(startupInfo.lpAttributeList);
+
+ if (!success)
+ {
+ DWORD error = GetLastError();
+ errorOut = "Failed to create process, error code: " + std::to_string(error);
+ cleanup();
+ return nullptr;
+ }
+
+ return std::make_unique(
+ hPC, procInfo.hProcess, procInfo.hThread, hPipeInRead, hPipeOutWrite,
+ JoinCommandWindows(options.command));
+ }
+#endif
} // namespace
bool ShellProcess::Write(std::string_view text)
@@ -311,8 +667,10 @@ namespace OpenRCT2::Terminal
{
#if defined(__APPLE__) || defined(__linux__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__)
return LaunchPosixProcess(options, errorOut);
+#elif defined(_WIN32)
+ return LaunchWindowsProcess(options, errorOut);
#else
- errorOut = "Agent terminal is only supported on POSIX builds right now.";
+ errorOut = "Agent terminal is not supported on this platform.";
return nullptr;
#endif
}
diff --git a/src/openrct2/terminal/ShellProcess.h b/src/openrct2/terminal/ShellProcess.h
index 90c0ea494601..faadb27819e8 100644
--- a/src/openrct2/terminal/ShellProcess.h
+++ b/src/openrct2/terminal/ShellProcess.h
@@ -10,12 +10,18 @@
#pragma once
#include
+#include
#include
#include
#include
#include
#include
+#if defined(_WIN32) && !defined(ssize_t)
+ #include
+ using ssize_t = SSIZE_T;
+#endif
+
namespace OpenRCT2::Terminal
{
struct ShellLaunchOptions