From a44ee66e66795448b9f0ea2bcf3031031ed101bd Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 12 Aug 2026 09:58:12 -0700 Subject: [PATCH 001/349] docs: investigate Windows port feasibility Document the Winghostty fork strategy and add reproducible Windows portability spikes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- investigation/.gitignore | 13 + investigation/decisions.md | 106 ++++ investigation/ghostty-windows-embedding.md | 76 +++ investigation/licensing.md | 17 + investigation/open-questions.md | 33 ++ investigation/spikes/README.md | 28 + .../spikes/ghostty-custom-window/README.md | 44 ++ .../spikes/ghostty-custom-window/spike.c | 224 ++++++++ .../spikes/swift-full/Package.resolved | 24 + investigation/spikes/swift-full/Package.swift | 22 + investigation/spikes/swift-full/prepare.ps1 | 14 + .../spikes/swift-named-pipe/Package.swift | 9 + .../GraphcodeSwiftNamedPipeSpike/main.swift | 299 ++++++++++ .../spikes/swift-paths/Package.swift | 9 + .../GraphcodeWindowsPathSpike/main.swift | 26 + .../spikes/swift-portable/Package.resolved | 24 + .../spikes/swift-portable/Package.swift | 30 + .../PortableDomainTests.swift | 28 + .../spikes/swift-portable/prepare.ps1 | 14 + .../spikes/swift-process/Package.swift | 9 + .../GraphcodeSwiftProcessSpike/main.swift | 118 ++++ investigation/spikes/zmx-conpty/README.md | 72 +++ .../spikes/zmx-conpty/conpty_spike.c | 379 ++++++++++++ investigation/swift-windows-portability.md | 132 +++++ investigation/ui-parity-matrix.md | 42 ++ investigation/windows-port-feasibility.md | 200 +++++++ .../windows-process-and-shell-semantics.md | 55 ++ investigation/winghostty-fork-plan.md | 537 ++++++++++++++++++ investigation/zmx-windows-feasibility.md | 83 +++ 29 files changed, 2667 insertions(+) create mode 100644 investigation/.gitignore create mode 100644 investigation/decisions.md create mode 100644 investigation/ghostty-windows-embedding.md create mode 100644 investigation/licensing.md create mode 100644 investigation/open-questions.md create mode 100644 investigation/spikes/README.md create mode 100644 investigation/spikes/ghostty-custom-window/README.md create mode 100644 investigation/spikes/ghostty-custom-window/spike.c create mode 100644 investigation/spikes/swift-full/Package.resolved create mode 100644 investigation/spikes/swift-full/Package.swift create mode 100644 investigation/spikes/swift-full/prepare.ps1 create mode 100644 investigation/spikes/swift-named-pipe/Package.swift create mode 100644 investigation/spikes/swift-named-pipe/Sources/GraphcodeSwiftNamedPipeSpike/main.swift create mode 100644 investigation/spikes/swift-paths/Package.swift create mode 100644 investigation/spikes/swift-paths/Sources/GraphcodeWindowsPathSpike/main.swift create mode 100644 investigation/spikes/swift-portable/Package.resolved create mode 100644 investigation/spikes/swift-portable/Package.swift create mode 100644 investigation/spikes/swift-portable/Tests/GraphcodePortableDomainTests/PortableDomainTests.swift create mode 100644 investigation/spikes/swift-portable/prepare.ps1 create mode 100644 investigation/spikes/swift-process/Package.swift create mode 100644 investigation/spikes/swift-process/Sources/GraphcodeSwiftProcessSpike/main.swift create mode 100644 investigation/spikes/zmx-conpty/README.md create mode 100644 investigation/spikes/zmx-conpty/conpty_spike.c create mode 100644 investigation/swift-windows-portability.md create mode 100644 investigation/ui-parity-matrix.md create mode 100644 investigation/windows-port-feasibility.md create mode 100644 investigation/windows-process-and-shell-semantics.md create mode 100644 investigation/winghostty-fork-plan.md create mode 100644 investigation/zmx-windows-feasibility.md diff --git a/investigation/.gitignore b/investigation/.gitignore new file mode 100644 index 00000000..a7aa688b --- /dev/null +++ b/investigation/.gitignore @@ -0,0 +1,13 @@ +spikes/**/.build/ +spikes/**/*.exe +spikes/**/*.lib +spikes/**/*.exp +spikes/**/*.obj +spikes/**/build.log +spikes/**/run.log +spikes/**/test.log +spikes/**/manifest.log +spikes/**/daemon.log +spikes/**/ready.txt +spikes/swift-full/Sources/ +spikes/swift-portable/Sources/ diff --git a/investigation/decisions.md b/investigation/decisions.md new file mode 100644 index 00000000..005c569f --- /dev/null +++ b/investigation/decisions.md @@ -0,0 +1,106 @@ +# Architecture decisions + +## ADR-001: Keep orchestration and domain logic in Swift + +### Status +Accepted for the port investigation. + +### Evidence +Swift 6.3.3 built `IdentifiedCollections` and a 31-file GraphCode domain target on Windows. +JSON/settings tests passed. Source audit classifies 39 of 62 GraphcodeKit files as portable +unchanged and 17 as shared after abstraction. + +### Decision +Keep GraphcodeKit and graphcoded orchestration in Swift. Add explicit platform services; +do not duplicate graph/session/backend policy in Zig or C++. + +### Consequences +The Windows package must redistribute the Swift runtime. SwiftPM becomes a supported +shared-core build path alongside Tuist. + +## ADR-002: Use a process boundary for the Windows shell + +### Status +Accepted. + +### Decision +The native Windows shell talks to Swift `graphcoded` through the daemon protocol. Do not +start with a Swift DLL/C ABI embedded in Zig. + +### Evidence +The daemon already owns state and survives UI restarts. A Swift Named Pipe spike supports +the required local communication patterns. + +### Consequences +UI crashes do not take orchestration down. Protocol correlation/versioning should be +hardened before multiple rich clients are shipped. + +## ADR-003: Use Named Pipes for Windows daemon IPC + +### Status +Accepted, pending security hardening. + +### Evidence +The Swift/WinSDK spike passed request/response, events, simultaneous clients, reconnect, +unavailable-daemon, connection-availability timeout, and oversized-frame rejection. + +### Decision +Retain JSON and four-byte length framing over a Windows Named Pipe transport. + +### Consequences +Replace raw descriptors in `GraphStore` and `ProjectRegistry` with a connection abstraction. +Use overlapped I/O, connected-operation deadlines/cancellation, bounded frames, and an +explicit current-user ACL in production. Add request correlation/versioning before rich +multi-client use. + +## ADR-004: Port zmx cross-platform rather than create zmx-win + +### Status +Conditional; requires a source-integrated protocol prototype. + +### Evidence +A Windows primitive spike combined ConPTY, Named Pipes, Job Objects, short client +connections, background output, a raw-buffer snapshot, and cleanup. It did not implement +zmx's actual protocol or long-lived attach behavior. + +### Decision +First rebase GraphCode's mouse patch and build a prototype inside current zmx preserving +its wire ABI/CLI, long-lived attach, VT reconstruction, resize, and attach leadership. +Then add platform modules. Create a separate fork only if upstream rejects the required +boundaries or semantics. + +### Consequences +The work is a real backend port, not a small compile fix, and approval remains conditional. +Task mode, signals, shell semantics, and event-loop plumbing need separate Windows +implementations. + +## ADR-005: Do not declare Ghostty surface embedding solved by a VT-only spike + +### Status +Accepted. + +### Context +`libghostty-vt` can drive terminal state and public row/cell APIs in a GraphCode-owned +Win32 window. That is useful but is not the complete Ghostty renderer/application runtime. +Upstream's full embedder API is macOS/iOS-only and exposes no HWND surface API. + +### Decision +Treat a full two-surface Ghostty renderer/input/IME/clipboard/accessibility spike as the +UI go/no-go gate. Do not commit the product to a Winghostty fork or to a custom GDI +terminal renderer based only on VT success. + +### Consequences +Headless Windows work can proceed while the UI architecture remains provisional. + +## ADR-006: Defer remote SSH parity from native Windows v1 + +### Status +Accepted. + +### Evidence +Remote support embeds POSIX shell, Unix-domain sockets, chmod/shebang behavior, `/usr/bin/ssh`, +and reverse Unix socket forwarding throughout several files. + +### Decision +Ship local projects/sessions first. Preserve remote URI/domain models and design a later +Windows OpenSSH transport without blocking the local port. diff --git a/investigation/ghostty-windows-embedding.md b/investigation/ghostty-windows-embedding.md new file mode 100644 index 00000000..0087f8f1 --- /dev/null +++ b/investigation/ghostty-windows-embedding.md @@ -0,0 +1,76 @@ +# Ghostty Windows embedding assessment + +Revisions examined: + +- Ghostty `fad7f854e8f976968bf4d61d408de9699cf87666` +- Winghostty `dccedf73600e0ef59c938aa8997f378f27d08f31` + +## Public API boundary + +`libghostty-vt` is usable on Windows and exposes terminal state, VT writes, resize, +callbacks, render snapshots, row/cell iterators, styles/colors/graphemes, key/mouse/focus +encoders, selection, snapshots, Kitty graphics support, and paste validation. + +It intentionally does not provide HWND creation, ConPTY/process launch, Win32 events, +clipboard ownership, font shaping/rasterization, glyph atlases, GPU contexts, compositor, +or presentation. + +Upstream `ghostty.h` is documented as an internal macOS embedder API. Its platform payloads +are NSView/UIView, and upstream has no Win32 application runtime or HWND surface API. + +## Spike + +`investigation/spikes/ghostty-custom-window` proves: + +- one GraphCode-owned top-level HWND +- two child terminal HWNDs +- independent `GhosttyTerminal` and `GhosttyRenderState` values +- public row/cell iteration +- independent GDI painting + +Smoke result: + +```text +SMOKE PASS: created=2 painted A=1 B=1 independent-terminal-state=PASS +``` + +This is a VT/state and window-topology proof, not a production Ghostty renderer proof. + +## Winghostty dependency map + +Winghostty's internal runtime demonstrates the desired topology: + +- `src/apprt/win32.zig`: `App`, `Host`, `Surface`, HWND lifecycle, input, DPI, focus, + clipboard, tabs/splits, accessibility, repaint scheduling +- `src/Surface.zig`, `src/App.zig`: terminal/application core +- `src/renderer.zig`, `src/renderer/OpenGL.zig`, `src/renderer/opengl/*`: WGL/OpenGL + terminal rendering +- `src/pty.zig`: Windows pipes and ConPTY +- `src/Command.zig`: `CreateProcessW`, pseudoconsole attribute, process lifetime + +Each Winghostty surface owns an HWND, HDC, HGLRC, core surface, and host association. +Rendering uses `wglMakeCurrent` and `SwapBuffers`. Multiple complete surfaces are therefore +technically possible. + +There is no exported `create_surface(parent_hwnd)` boundary. Extracting it crosses a wide +import graph including compositor, shell, clipboard, UIA, settings, tabs, recovery, +drag/drop, IPC, and theme code. + +## Build evidence + +- Current Ghostty `zig build -Demit-lib-vt=true` succeeds with Zig 0.16.0. +- Shared/static C spike builds and runs. +- Winghostty requires Zig 0.15.2 but its build runner failed on an absolute child cwd. +- Zig 0.16.0 is incompatible with that checkout's declared version and build APIs. +- `libghostty-vt` tests hung for more than five minutes and were stopped; result is + inconclusive. + +## Decision + +- **Go** for `libghostty-vt` with a GraphCode-owned renderer. +- **No-go today** for embedding a complete upstream Ghostty surface via public APIs. +- **Conditional go** for extracting/maintaining Winghostty's internal Win32/OpenGL runtime. + +The production UI cannot be estimated as a thin wrapper. It requires either a substantial +Winghostty runtime extraction/fork or a production terminal renderer/input stack owned by +GraphCode. diff --git a/investigation/licensing.md b/investigation/licensing.md new file mode 100644 index 00000000..ec24ff3a --- /dev/null +++ b/investigation/licensing.md @@ -0,0 +1,17 @@ +# Licensing notes + +| Component | License | Windows-port consequence | +|---|---|---| +| `GraphcodeKit/` | MIT | May be reused, modified, and redistributed with notice. | +| `graphcode-cli/` | MIT | Same. | +| `graphcoded/`, app, remaining repository | FSL-1.1-MIT | Internal, non-commercial research, education, and permitted professional services are allowed. A competing commercial product/service is restricted until each version's two-year MIT future-license date. Preserve the license on redistribution. | +| Ghostty / `libghostty-vt` | MIT | Reuse is permitted with copyright/license notice. | +| zmx and GraphCode's zmx fork | MIT | Reuse/port is permitted with upstream notice. Record fork SHA and upstream base. | +| Winghostty | Ghostty-derived MIT code; verify per-file headers | Concepts and code may be adapted with notices, but copied files need provenance and header review. | + +GraphCode's current zmx fork is 26 upstream commits behind and carries a GraphCode-specific +mouse-input patch. Windows work should rebase that patch before building a new backend. + +This is an engineering inventory, not legal advice. Before public Windows distribution, +generate a third-party notice/SBOM from the exact pinned commits and review every copied +Winghostty file rather than relying only on repository-level READMEs. diff --git a/investigation/open-questions.md b/investigation/open-questions.md new file mode 100644 index 00000000..76221f14 --- /dev/null +++ b/investigation/open-questions.md @@ -0,0 +1,33 @@ +# Open questions and go/no-go gates + +## Must answer before production UI work + +1. Can a GraphCode-owned Win32 window host the complete Ghostty renderer/input stack, not only `libghostty-vt` state rendered by a custom GDI client? +2. Can two complete surfaces share a compositor without focus, DPI, IME, accessibility, or teardown leaks? +3. Which Winghostty modules can be extracted on a maintained Zig/Ghostty revision? +4. What upstream relationship will prevent GraphCode from carrying a permanent Ghostty application-runtime fork? + +## Must answer before daemon release + +1. Exact current-user Named Pipe ACL and SID-derived naming. +2. Overlapped-I/O deadlines/cancellation for connect, header read, body read, and writes. +3. Maximum frame size, backpressure, partial-frame, and non-reading-peer behavior. +4. Protocol request correlation/versioning; the current “next matching broadcast” behavior is fragile with multiple active clients. +5. Event subscription, ordering, and reconnect/replay semantics for multiple clients. +6. Windows startup choice: Startup shortcut, scheduled task, packaged app startup task, or explicit app-managed child. +7. Swift runtime redistribution and installer footprint. + +## Must answer before zmx release + +1. Rebase/upstream the GraphCode mouse-input patch. +2. Define multiple attach leadership behavior on Windows. +3. Verify VT snapshot restoration against real ConPTY streams and coding-agent TUIs. +4. Test resize, Ctrl+C/Ctrl+Break, Unicode, bracketed paste, stale-session cleanup, daemon/client crashes, and reboot recovery. +5. Decide whether task-mode POSIX shell features are supported natively, through PowerShell, or deferred. + +## Deferred from Windows v1 + +- Remote SSH socket forwarding and the embedded Unix-socket Python CLI shim. +- WSL-specific project/path integration. +- ARM64. +- Full updater/installer automation. diff --git a/investigation/spikes/README.md b/investigation/spikes/README.md new file mode 100644 index 00000000..7ee6e20d --- /dev/null +++ b/investigation/spikes/README.md @@ -0,0 +1,28 @@ +# Windows feasibility spikes + +All Swift spikes were run with Swift 6.3.3 on Windows 11. + +The official toolkit used in this investigation installed under: + +```text +%LOCALAPPDATA%\Programs\Swift\ +``` + +Before running SwiftPM, put the selected toolchain and runtime `usr\bin` directories on +`PATH` and set `SDKROOT` to the matching Windows SDK inside the Swift installation. + +| Directory | Purpose | Last result | +|---|---|---| +| `swift-full` | Compile all GraphcodeKit sources through SwiftPM | Dependencies build; fails at `PTYProcessSession.swift: import Darwin` | +| `swift-portable` | Compile/test 31 portable domain files | Pass, 2 tests | +| `swift-paths` | Execute current path algorithms on Windows paths | Pass; reproduces 3 blockers | +| `swift-named-pipe` | Swift/WinSDK daemon transport behaviors | Pass, 7 behaviors; connected-I/O deadlines remain untested | +| `swift-process` | Foundation `Process`, argv, cwd, environment, scripts | Pass | +| `zmx-conpty` | ConPTY + Named Pipe + Job Object primitive survival/snapshot | Pass; not actual zmx protocol parity | +| `ghostty-custom-window` | GraphCode-owned HWND with two libghostty-vt-backed views | Pass for VT/state rendering; not a full Ghostty renderer proof | + +The `swift-full` and `swift-portable` setup scripts create directory junctions into the +repository so source is not duplicated. + +Generated `.build` directories, executables, libraries, and runtime logs are not required +source artifacts and should not be committed. diff --git a/investigation/spikes/ghostty-custom-window/README.md b/investigation/spikes/ghostty-custom-window/README.md new file mode 100644 index 00000000..b3181f60 --- /dev/null +++ b/investigation/spikes/ghostty-custom-window/README.md @@ -0,0 +1,44 @@ +# Ghostty/libghostty-vt Win32 embedding spike + +This is a minimal, external-host proof for the current public Ghostty VT API. It does **not** embed Ghostty's complete GUI/runtime surface. + +## What it proves + +- Creates a GraphCode-like Win32 top-level host window. +- Creates two `WS_CHILD` terminal windows owned by that host. +- Keeps two independent `GhosttyTerminal` and `GhosttyRenderState` instances. +- Feeds independent VT text to each terminal. +- Uses the public render-state row/cell iterators to paint styled ASCII cells with GDI. +- Runs a smoke check requiring both child HWNDs to be created and painted. + +## Source and upstream inputs + +- Source: `spike.c` +- Upstream Ghostty revision used: `fad7f854e8f976968bf4d61d408de9699cf87666` +- The upstream build was `zig build -Demit-lib-vt=true` with Zig 0.16.0. + +## Re-run without storing bulky outputs here + +Set `$ghosttyBuild` to a local Ghostty build produced with +`zig build -Demit-lib-vt=true`. Build from this directory and write generated outputs to a +temporary directory: + +```powershell +$src = Join-Path (Get-Location) 'spike.c' +$out = Join-Path $env:TEMP 'graphcode-ghostty-window-spike' +$inc = Join-Path $ghosttyBuild 'include' +$lib = Join-Path $ghosttyBuild 'lib\ghostty-vt.lib' +New-Item -ItemType Directory -Force $out | Out-Null +clang-cl /nologo /W4 /I $inc /c $src /Fo:(Join-Path $out 'spike-from-graphcode.obj') +clang-cl /nologo (Join-Path $out 'spike-from-graphcode.obj') $lib user32.lib gdi32.lib advapi32.lib shell32.lib /Fe:(Join-Path $out 'spike-from-graphcode.exe') +Copy-Item (Join-Path $ghosttyBuild 'bin\ghostty-vt.dll') $out -Force +& (Join-Path $out 'spike-from-graphcode.exe') +``` + +Expected result includes: + +```text +SMOKE PASS: created=2 painted A=1 B=1 independent-terminal-state=PASS +``` + +Build outputs are intentionally excluded from this GraphCode investigation directory. diff --git a/investigation/spikes/ghostty-custom-window/spike.c b/investigation/spikes/ghostty-custom-window/spike.c new file mode 100644 index 00000000..83287780 --- /dev/null +++ b/investigation/spikes/ghostty-custom-window/spike.c @@ -0,0 +1,224 @@ +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include + +#include + +typedef struct Surface { + HWND hwnd; + GhosttyTerminal terminal; + GhosttyRenderState render; + GhosttyRenderStateRowIterator rows; + GhosttyRenderStateRowCells cells; + int paint_count; + const char* title; +} Surface; + +static Surface g_surfaces[2]; +static HWND g_host; +static const wchar_t* HOST_CLASS = L"GraphCodeEmbeddingSpikeHost"; +static const wchar_t* SURFACE_CLASS = L"GraphCodeEmbeddingSpikeSurface"; + +static COLORREF to_color(GhosttyColorRgb c) { + return RGB(c.r, c.g, c.b); +} + +static GhosttyColorRgb style_color(GhosttyStyleColor color, + const GhosttyRenderStateColors* colors, + GhosttyColorRgb fallback) { + if (color.tag == GHOSTTY_STYLE_COLOR_RGB) return color.value.rgb; + if (color.tag == GHOSTTY_STYLE_COLOR_PALETTE) return colors->palette[color.value.palette]; + return fallback; +} + +static void paint_surface(Surface* surface, HDC dc) { + GhosttyRenderStateColors colors = GHOSTTY_INIT_SIZED(GhosttyRenderStateColors); + if (ghostty_render_state_colors_get(surface->render, &colors) != GHOSTTY_SUCCESS) return; + + uint16_t cols = 0, rows = 0; + if (ghostty_render_state_get(surface->render, GHOSTTY_RENDER_STATE_DATA_COLS, &cols) != GHOSTTY_SUCCESS || + ghostty_render_state_get(surface->render, GHOSTTY_RENDER_STATE_DATA_ROWS, &rows) != GHOSTTY_SUCCESS) return; + + HFONT font = CreateFontW(16, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, + DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, + CLEARTYPE_QUALITY, FIXED_PITCH | FF_MODERN, L"Consolas"); + HGDIOBJ old_font = SelectObject(dc, font); + SetBkMode(dc, OPAQUE); + + if (ghostty_render_state_get(surface->render, + GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, + &surface->rows) != GHOSTTY_SUCCESS) return; + + int cell_w = 10, cell_h = 20; + int row_index = 0; + while (row_index < rows && ghostty_render_state_row_iterator_next(surface->rows)) { + if (ghostty_render_state_row_get(surface->rows, GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, &surface->cells) != GHOSTTY_SUCCESS) break; + int col_index = 0; + while (col_index < cols && ghostty_render_state_row_cells_next(surface->cells)) { + GhosttyStyle style = GHOSTTY_INIT_SIZED(GhosttyStyle); + if (ghostty_render_state_row_cells_get(surface->cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE, &style) != GHOSTTY_SUCCESS) break; + + GhosttyColorRgb fg = style_color(style.fg_color, &colors, colors.foreground); + GhosttyColorRgb bg = style_color(style.bg_color, &colors, colors.background); + SetTextColor(dc, to_color(fg)); + SetBkColor(dc, to_color(bg)); + + uint32_t grapheme_len = 0; + ghostty_render_state_row_cells_get(surface->cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN, &grapheme_len); + char ch = ' '; + if (grapheme_len > 0) { + uint32_t codepoints[16] = {0}; + ghostty_render_state_row_cells_get(surface->cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, codepoints); + if (codepoints[0] >= 32 && codepoints[0] < 127) ch = (char)codepoints[0]; + } + RECT cell = { col_index * cell_w, row_index * cell_h, + (col_index + 1) * cell_w, (row_index + 1) * cell_h }; + ExtTextOutA(dc, cell.left + 1, cell.top + 1, ETO_OPAQUE, &cell, &ch, 1, NULL); + col_index++; + } + row_index++; + } + + SelectObject(dc, old_font); + DeleteObject(font); + surface->paint_count++; +} + +static LRESULT CALLBACK host_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + switch (msg) { + case WM_ERASEBKGND: + return 1; + case WM_PAINT: { + PAINTSTRUCT ps; + HDC dc = BeginPaint(hwnd, &ps); + RECT r; + GetClientRect(hwnd, &r); + HBRUSH brush = CreateSolidBrush(RGB(30, 30, 35)); + FillRect(dc, &r, brush); + DeleteObject(brush); + EndPaint(hwnd, &ps); + return 0; + } + case WM_DESTROY: + PostQuitMessage(0); + return 0; + } + return DefWindowProcW(hwnd, msg, wp, lp); +} + +static LRESULT CALLBACK surface_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + Surface* surface = (Surface*)GetWindowLongPtrW(hwnd, GWLP_USERDATA); + if (msg == WM_NCCREATE) { + CREATESTRUCTW* create = (CREATESTRUCTW*)lp; + surface = (Surface*)create->lpCreateParams; + SetWindowLongPtrW(hwnd, GWLP_USERDATA, (LONG_PTR)surface); + surface->hwnd = hwnd; + } + switch (msg) { + case WM_ERASEBKGND: + return 1; + case WM_PAINT: { + PAINTSTRUCT ps; + HDC dc = BeginPaint(hwnd, &ps); + if (surface) paint_surface(surface, dc); + EndPaint(hwnd, &ps); + return 0; + } + } + return DefWindowProcW(hwnd, msg, wp, lp); +} + +static int fail(const char* message) { + fprintf(stderr, "FAIL: %s (win32=%lu)\n", message, (unsigned long)GetLastError()); + return 1; +} + +static int init_surface(Surface* surface, const char* title, const char* content) { + GhosttyResult result = ghostty_terminal_new(NULL, &surface->terminal, 40, 8); + if (result != GHOSTTY_SUCCESS) return 0; + result = ghostty_render_state_new(NULL, &surface->render); + if (result != GHOSTTY_SUCCESS) return 0; + result = ghostty_render_state_row_iterator_new(NULL, &surface->rows); + if (result != GHOSTTY_SUCCESS) return 0; + result = ghostty_render_state_row_cells_new(NULL, &surface->cells); + if (result != GHOSTTY_SUCCESS) return 0; + ghostty_terminal_vt_write(surface->terminal, (const uint8_t*)content, strlen(content)); + result = ghostty_render_state_update(surface->render, surface->terminal); + if (result != GHOSTTY_SUCCESS) return 0; + surface->title = title; + return 1; +} + +static void free_surface(Surface* surface) { + if (surface->cells) ghostty_render_state_row_cells_free(surface->cells); + if (surface->rows) ghostty_render_state_row_iterator_free(surface->rows); + if (surface->render) ghostty_render_state_free(surface->render); + if (surface->terminal) ghostty_terminal_free(surface->terminal); + memset(surface, 0, sizeof(*surface)); +} + +int main(void) { + setvbuf(stdout, NULL, _IONBF, 0); + fprintf(stderr, "stage: start\n"); fflush(stderr); + HINSTANCE instance = GetModuleHandleW(NULL); + WNDCLASSW host_class = {0}; + host_class.hInstance = instance; + host_class.lpfnWndProc = host_proc; + host_class.lpszClassName = HOST_CLASS; + host_class.hCursor = LoadCursorW(NULL, MAKEINTRESOURCEW(32512)); + if (!RegisterClassW(&host_class) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) return fail("register host class"); + + WNDCLASSW surface_class = {0}; + surface_class.hInstance = instance; + surface_class.lpfnWndProc = surface_proc; + surface_class.lpszClassName = SURFACE_CLASS; + surface_class.hCursor = LoadCursorW(NULL, MAKEINTRESOURCEW(32513)); + if (!RegisterClassW(&surface_class) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) return fail("register surface class"); + + fprintf(stderr, "stage: before surface A\n"); fflush(stderr); + if (!init_surface(&g_surfaces[0], "surface-a", "Surface A: \033[1;32mindependent\033[0m VT state\r\n")) return fail("initialize surface A"); + fprintf(stderr, "stage: after surface A\n"); fflush(stderr); + if (!init_surface(&g_surfaces[1], "surface-b", "Surface B: \033[1;34mindependent\033[0m VT state\r\n")) return fail("initialize surface B"); + + fprintf(stderr, "stage: after surface B\n"); fflush(stderr); + g_host = CreateWindowExW(0, HOST_CLASS, L"GraphCode-owned embedding host", + WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, + 900, 260, NULL, NULL, instance, NULL); + if (!g_host) return fail("create top-level host HWND"); + + g_surfaces[0].hwnd = CreateWindowExW(WS_EX_CLIENTEDGE, SURFACE_CLASS, L"A", + WS_CHILD | WS_VISIBLE, 12, 12, 420, 180, g_host, NULL, instance, &g_surfaces[0]); + g_surfaces[1].hwnd = CreateWindowExW(WS_EX_CLIENTEDGE, SURFACE_CLASS, L"B", + WS_CHILD | WS_VISIBLE, 444, 12, 420, 180, g_host, NULL, instance, &g_surfaces[1]); + if (!g_surfaces[0].hwnd || !g_surfaces[1].hwnd) return fail("create two child terminal HWNDs"); + + fprintf(stderr, "stage: after windows\n"); fflush(stderr); + printf("HOST hwnd=%p CHILD_A hwnd=%p CHILD_B hwnd=%p\n", + (void*)g_host, (void*)g_surfaces[0].hwnd, (void*)g_surfaces[1].hwnd); + ShowWindow(g_host, SW_SHOWNOACTIVATE); + UpdateWindow(g_host); + UpdateWindow(g_surfaces[0].hwnd); + UpdateWindow(g_surfaces[1].hwnd); + + if (g_surfaces[0].paint_count < 1 || g_surfaces[1].paint_count < 1) { + fprintf(stderr, "FAIL: child paint smoke test A=%d B=%d\n", + g_surfaces[0].paint_count, g_surfaces[1].paint_count); + DestroyWindow(g_host); + free_surface(&g_surfaces[0]); + free_surface(&g_surfaces[1]); + return 1; + } + + printf("SMOKE PASS: created=2 painted A=%d B=%d independent-terminal-state=PASS\n", + g_surfaces[0].paint_count, g_surfaces[1].paint_count); + + DestroyWindow(g_host); + free_surface(&g_surfaces[0]); + free_surface(&g_surfaces[1]); + return 0; +} diff --git a/investigation/spikes/swift-full/Package.resolved b/investigation/spikes/swift-full/Package.resolved new file mode 100644 index 00000000..02a8ccb0 --- /dev/null +++ b/investigation/spikes/swift-full/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "a819c5704c91f1dbb8eb3827835fa41130bb7b01c5bef1fb02b57f1e74b205d3", + "pins" : [ + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + } + ], + "version" : 3 +} \ No newline at end of file diff --git a/investigation/spikes/swift-full/Package.swift b/investigation/spikes/swift-full/Package.swift new file mode 100644 index 00000000..86747013 --- /dev/null +++ b/investigation/spikes/swift-full/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "GraphcodeKitWindowsFullSpike", + products: [ + .library(name: "GraphcodeKit", targets: ["GraphcodeKit"]) + ], + dependencies: [ + .package( + url: "https://github.com/pointfreeco/swift-identified-collections", + exact: "1.1.1") + ], + targets: [ + .target( + name: "GraphcodeKit", + dependencies: [ + .product(name: "IdentifiedCollections", package: "swift-identified-collections") + ], + path: "Sources/GraphcodeKit") + ]) diff --git a/investigation/spikes/swift-full/prepare.ps1 b/investigation/spikes/swift-full/prepare.ps1 new file mode 100644 index 00000000..def11d9f --- /dev/null +++ b/investigation/spikes/swift-full/prepare.ps1 @@ -0,0 +1,14 @@ +$ErrorActionPreference = "Stop" + +$spike = Split-Path -Parent $MyInvocation.MyCommand.Path +$repo = Resolve-Path (Join-Path $spike "..\..\..") +$sources = Join-Path $spike "Sources" +$link = Join-Path $sources "GraphcodeKit" +$target = Join-Path $repo "GraphcodeKit\Sources" + +New-Item -ItemType Directory -Force -Path $sources | Out-Null +if (Test-Path $link) { + Remove-Item $link +} +New-Item -ItemType Junction -Path $link -Target $target | Out-Null +Write-Host "Linked $link -> $target" diff --git a/investigation/spikes/swift-named-pipe/Package.swift b/investigation/spikes/swift-named-pipe/Package.swift new file mode 100644 index 00000000..ae66c174 --- /dev/null +++ b/investigation/spikes/swift-named-pipe/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "GraphcodeSwiftNamedPipeSpike", + targets: [ + .executableTarget(name: "GraphcodeSwiftNamedPipeSpike") + ]) diff --git a/investigation/spikes/swift-named-pipe/Sources/GraphcodeSwiftNamedPipeSpike/main.swift b/investigation/spikes/swift-named-pipe/Sources/GraphcodeSwiftNamedPipeSpike/main.swift new file mode 100644 index 00000000..e3781b16 --- /dev/null +++ b/investigation/spikes/swift-named-pipe/Sources/GraphcodeSwiftNamedPipeSpike/main.swift @@ -0,0 +1,299 @@ +import Foundation +import WinSDK + +enum PipeError: Error { + case win32(String, DWORD) + case invalidFrame + case frameTooLarge(UInt32) +} +let maxFrameBytes: UInt32 = 1_048_576 +func withWideString( + _ value: String, + _ body: (UnsafePointer) throws -> Result +) rethrows -> Result { + var buffer = Array(value.utf16) + buffer.append(0) + return try buffer.withUnsafeBufferPointer { pointer in + try body(pointer.baseAddress!) + } +} +func checkHandle(_ handle: HANDLE?, operation: String) throws -> HANDLE { + guard let handle, handle != INVALID_HANDLE_VALUE else { + throw PipeError.win32(operation, GetLastError()) + } + return handle +} +func writeAll(_ bytes: [UInt8], to handle: HANDLE) throws { + var offset = 0 + while offset < bytes.count { + var written: DWORD = 0 + let succeeded = bytes.withUnsafeBytes { rawBuffer in + WriteFile( + handle, + rawBuffer.baseAddress!.advanced(by: offset), + DWORD(bytes.count - offset), + &written, + nil) + } + guard succeeded != false else { + throw PipeError.win32("WriteFile", GetLastError()) + } + offset += Int(written) + } +} +func readExactly(_ count: Int, from handle: HANDLE) throws -> [UInt8] { + var bytes = [UInt8](repeating: 0, count: count) + var offset = 0 + while offset < count { + var bytesRead: DWORD = 0 + let succeeded = bytes.withUnsafeMutableBytes { rawBuffer in + ReadFile( + handle, + rawBuffer.baseAddress!.advanced(by: offset), + DWORD(count - offset), + &bytesRead, + nil) + } + guard succeeded != false else { + throw PipeError.win32("ReadFile", GetLastError()) + } + guard bytesRead > 0 else { throw PipeError.invalidFrame } + offset += Int(bytesRead) + } + return bytes +} +func writeFrame(_ text: String, to handle: HANDLE) throws { + let payload = Array(text.utf8) + let count = UInt32(payload.count) + let header: [UInt8] = [ + UInt8((count >> 24) & 0xff), + UInt8((count >> 16) & 0xff), + UInt8((count >> 8) & 0xff), + UInt8(count & 0xff), + ] + try writeAll(header + payload, to: handle) +} +func validatedFrameLength(_ header: [UInt8]) throws -> Int { + guard header.count == 4 else { throw PipeError.invalidFrame } + let count = + (UInt32(header[0]) << 24) + | (UInt32(header[1]) << 16) + | (UInt32(header[2]) << 8) + | UInt32(header[3]) + guard count <= maxFrameBytes else { throw PipeError.frameTooLarge(count) } + return Int(count) +} +func readFrame(from handle: HANDLE) throws -> String { + let count = try validatedFrameLength(readExactly(4, from: handle)) + let payload = try readExactly(count, from: handle) + guard let text = String(bytes: payload, encoding: .utf8) else { + throw PipeError.invalidFrame + } + return text +} +func createServerPipe( + named name: String, + maxInstances: DWORD = DWORD(bitPattern: PIPE_UNLIMITED_INSTANCES) +) + throws -> HANDLE +{ + try withWideString(name) { wideName in + try checkHandle( + CreateNamedPipeW( + wideName, + DWORD(PIPE_ACCESS_DUPLEX), + DWORD(PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT), + maxInstances, + 64 * 1024, + 64 * 1024, + 1_000, + nil), + operation: "CreateNamedPipeW") + } +} +func connectClient(to name: String) throws -> HANDLE { + try withWideString(name) { wideName in + guard WaitNamedPipeW(wideName, 2_000) != false else { + throw PipeError.win32("WaitNamedPipeW", GetLastError()) + } + return try checkHandle( + CreateFileW( + wideName, + DWORD(GENERIC_READ) | DWORD(bitPattern: GENERIC_WRITE), + 0, + nil, + DWORD(OPEN_EXISTING), + 0, + nil), + operation: "CreateFileW") + } +} +func serveOne( + pipeName: String, + ready: DispatchSemaphore, + result: @escaping @Sendable (Result) -> Void +) { + DispatchQueue.global().async { + do { + let pipe = try createServerPipe(named: pipeName) + defer { CloseHandle(pipe) } + ready.signal() + let connected = ConnectNamedPipe(pipe, nil) + guard connected != false || GetLastError() == ERROR_PIPE_CONNECTED else { + throw PipeError.win32("ConnectNamedPipe", GetLastError()) + } + let request = try readFrame(from: pipe) + try writeFrame("response:\(request)", to: pipe) + try writeFrame("event:graphChanged", to: pipe) + FlushFileBuffers(pipe) + DisconnectNamedPipe(pipe) + result(.success(request)) + } catch { + result(.failure(error)) + } + } +} +let pipeName = #"\\.\pipe\graphcode-spike-\#(GetCurrentProcessId())"# +let ready = DispatchSemaphore(value: 0) +let serversDone = DispatchGroup() +let clientsDone = DispatchGroup() +final class FailureStore: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String] = [] + + func append(_ text: String) { + lock.lock() + storage.append(text) + lock.unlock() + } + + var values: [String] { + lock.lock() + defer { lock.unlock() } + return storage + } +} +final class SendableHandle: @unchecked Sendable { + let value: HANDLE + + init(_ value: HANDLE) { + self.value = value + } +} +let failures = FailureStore() +for _ in 0..<2 { + serversDone.enter() + serveOne(pipeName: pipeName, ready: ready) { result in + if case .failure(let error) = result { + failures.append("server: \(error)") + } + serversDone.leave() + } +} +ready.wait() +ready.wait() +for request in ["client-one", "client-two"] { + clientsDone.enter() + DispatchQueue.global().async { + defer { clientsDone.leave() } + do { + let client = try connectClient(to: pipeName) + defer { CloseHandle(client) } + try writeFrame(request, to: client) + let response = try readFrame(from: client) + let event = try readFrame(from: client) + guard response == "response:\(request)", event == "event:graphChanged" else { + throw PipeError.invalidFrame + } + } catch { + failures.append("client \(request): \(error)") + } + } +} +clientsDone.wait() +serversDone.wait() +let reconnectReady = DispatchSemaphore(value: 0) +let reconnectDone = DispatchSemaphore(value: 0) +serveOne(pipeName: pipeName, ready: reconnectReady) { result in + if case .failure(let error) = result { + failures.append("reconnect server: \(error)") + } + reconnectDone.signal() +} +reconnectReady.wait() +do { + let client = try connectClient(to: pipeName) + try writeFrame("reconnected", to: client) + guard try readFrame(from: client) == "response:reconnected", + try readFrame(from: client) == "event:graphChanged" + else { + throw PipeError.invalidFrame + } + CloseHandle(client) +} catch { + failures.append("reconnect client: \(error)") +} +reconnectDone.wait() +let missingName = pipeName + "-missing" +let missingError = withWideString(missingName) { wideName -> DWORD in + let handle = CreateFileW( + wideName, + DWORD(GENERIC_READ) | DWORD(bitPattern: GENERIC_WRITE), + 0, + nil, + DWORD(OPEN_EXISTING), + 0, + nil) + if handle != INVALID_HANDLE_VALUE { + CloseHandle(handle) + return DWORD(bitPattern: ERROR_SUCCESS) + } + return GetLastError() +} +if missingError != DWORD(bitPattern: ERROR_FILE_NOT_FOUND) { + failures.append("daemon unavailable error was \(missingError)") +} +let busyName = pipeName + "-busy" +do { + let busyPipe = try createServerPipe(named: busyName, maxInstances: 1) + let busyPipeBox = SendableHandle(busyPipe) + let connected = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + _ = ConnectNamedPipe(busyPipeBox.value, nil) + connected.signal() + } + let firstClient = try connectClient(to: busyName) + connected.wait() + let timeoutError = withWideString(busyName) { wideName -> DWORD in + if WaitNamedPipeW(wideName, 100) != false { + return DWORD(bitPattern: ERROR_SUCCESS) + } + return GetLastError() + } + if timeoutError != DWORD(bitPattern: ERROR_SEM_TIMEOUT) { + failures.append("busy-pipe timeout error was \(timeoutError)") + } + CloseHandle(firstClient) + DisconnectNamedPipe(busyPipe) + CloseHandle(busyPipe) +} catch { + failures.append("timeout setup: \(error)") +} +do { + _ = try validatedFrameLength([0x7f, 0xff, 0xff, 0xff]) + failures.append("oversized frame was accepted") +} catch PipeError.frameTooLarge { +} catch { + failures.append("oversized frame returned unexpected error: \(error)") +} +if !failures.values.isEmpty { + for failure in failures.values { FileHandle.standardError.write(Data("\(failure)\n".utf8)) } + exit(1) +} +print("swift-named-pipe request-response: ok") +print("swift-named-pipe event-stream: ok") +print("swift-named-pipe multiple-clients: ok") +print("swift-named-pipe reconnect: ok") +print("swift-named-pipe daemon-unavailable: ok") +print("swift-named-pipe connection-availability-timeout: ok") +print("swift-named-pipe oversized-frame-rejection: ok") diff --git a/investigation/spikes/swift-paths/Package.swift b/investigation/spikes/swift-paths/Package.swift new file mode 100644 index 00000000..07e9ed45 --- /dev/null +++ b/investigation/spikes/swift-paths/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "GraphcodeWindowsPathSpike", + targets: [ + .executableTarget(name: "GraphcodeWindowsPathSpike") + ]) diff --git a/investigation/spikes/swift-paths/Sources/GraphcodeWindowsPathSpike/main.swift b/investigation/spikes/swift-paths/Sources/GraphcodeWindowsPathSpike/main.swift new file mode 100644 index 00000000..e639ec5d --- /dev/null +++ b/investigation/spikes/swift-paths/Sources/GraphcodeWindowsPathSpike/main.swift @@ -0,0 +1,26 @@ +import Foundation + +let projectPath = #"C:\Projects\GraphCode Demo"# +let supportOverride = #"D:\GraphCodeState"# +let home = FileManager.default.homeDirectoryForCurrentUser +let registryAcceptsPath = projectPath.hasPrefix("/") +let currentSupportResolution = + supportOverride.hasPrefix("/") + ? URL(fileURLWithPath: supportOverride, isDirectory: true) + : home.appendingPathComponent(supportOverride, isDirectory: true) +let persistenceFileName = + projectPath.replacingOccurrences(of: "/", with: "_") + ".json" +print("project=\(projectPath)") +print("registryAcceptsPath=\(registryAcceptsPath)") +print("supportOverrideResolved=\(currentSupportResolution.path)") +print("persistenceFileName=\(persistenceFileName)") +guard registryAcceptsPath == false else { + fatalError("Expected the current POSIX absolute-path check to reject a Windows drive path") +} +guard currentSupportResolution.path != supportOverride else { + fatalError("Expected the current support-directory logic to misclassify a Windows drive path") +} +guard persistenceFileName.contains("\\") && persistenceFileName.contains(":") else { + fatalError("Expected the current persistence filename sanitizer to retain Windows separators") +} +print("observed-current-windows-path-blockers") diff --git a/investigation/spikes/swift-portable/Package.resolved b/investigation/spikes/swift-portable/Package.resolved new file mode 100644 index 00000000..7170ff4c --- /dev/null +++ b/investigation/spikes/swift-portable/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "6dedbfa23d4a976c618db2b9f5e4d0b528c71af4076a35cf40632d21644b4754", + "pins" : [ + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + } + ], + "version" : 3 +} \ No newline at end of file diff --git a/investigation/spikes/swift-portable/Package.swift b/investigation/spikes/swift-portable/Package.swift new file mode 100644 index 00000000..fbdf489e --- /dev/null +++ b/investigation/spikes/swift-portable/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "GraphcodePortableDomainSpike", + products: [ + .library(name: "GraphcodePortableDomain", targets: ["GraphcodePortableDomain"]) + ], + dependencies: [ + .package( + url: "https://github.com/pointfreeco/swift-identified-collections", + exact: "1.1.1") + ], + targets: [ + .target( + name: "GraphcodePortableDomain", + dependencies: [ + .product(name: "IdentifiedCollections", package: "swift-identified-collections") + ], + path: "Sources/GraphcodePortableDomain", + exclude: [ + "BackendCommand.swift", + "RemoteProjectLocation.swift", + "SessionBriefing.swift", + ]), + .testTarget( + name: "GraphcodePortableDomainTests", + dependencies: ["GraphcodePortableDomain"]), + ]) diff --git a/investigation/spikes/swift-portable/Tests/GraphcodePortableDomainTests/PortableDomainTests.swift b/investigation/spikes/swift-portable/Tests/GraphcodePortableDomainTests/PortableDomainTests.swift new file mode 100644 index 00000000..9a5bb04f --- /dev/null +++ b/investigation/spikes/swift-portable/Tests/GraphcodePortableDomainTests/PortableDomainTests.swift @@ -0,0 +1,28 @@ +import Foundation +import GraphcodePortableDomain +import XCTest + +final class PortableDomainTests: XCTestCase { + func testGraphRoundTripsWithAWindowsProjectPath() throws { + let graph = LoopGraph( + project: ProjectRef( + path: #"C:\Projects\GraphCode Demo"#, + name: "demo", + lastOpenedAt: Date(timeIntervalSince1970: 1_700_000_000))) + + let data = try JSONEncoder().encode(graph) + let decoded = try JSONDecoder().decode(LoopGraph.self, from: data) + + XCTAssertEqual(decoded.id, graph.id) + XCTAssertEqual(decoded.nodes, graph.nodes) + XCTAssertEqual(decoded.edges, graph.edges) + XCTAssertEqual(decoded.project.path, #"C:\Projects\GraphCode Demo"#) + XCTAssertEqual(decoded.project.name, "demo") + } + + func testSettingsRoundTripUnchanged() throws { + let settings = GraphcodeSettings() + let data = try JSONEncoder().encode(settings) + XCTAssertEqual(try JSONDecoder().decode(GraphcodeSettings.self, from: data), settings) + } +} diff --git a/investigation/spikes/swift-portable/prepare.ps1 b/investigation/spikes/swift-portable/prepare.ps1 new file mode 100644 index 00000000..f610700f --- /dev/null +++ b/investigation/spikes/swift-portable/prepare.ps1 @@ -0,0 +1,14 @@ +$ErrorActionPreference = "Stop" + +$spike = Split-Path -Parent $MyInvocation.MyCommand.Path +$repo = Resolve-Path (Join-Path $spike "..\..\..") +$sources = Join-Path $spike "Sources" +$link = Join-Path $sources "GraphcodePortableDomain" +$target = Join-Path $repo "GraphcodeKit\Sources\Domain" + +New-Item -ItemType Directory -Force -Path $sources | Out-Null +if (Test-Path $link) { + Remove-Item $link +} +New-Item -ItemType Junction -Path $link -Target $target | Out-Null +Write-Host "Linked $link -> $target" diff --git a/investigation/spikes/swift-process/Package.swift b/investigation/spikes/swift-process/Package.swift new file mode 100644 index 00000000..1d0f5821 --- /dev/null +++ b/investigation/spikes/swift-process/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "GraphcodeSwiftProcessSpike", + targets: [ + .executableTarget(name: "GraphcodeSwiftProcessSpike") + ]) diff --git a/investigation/spikes/swift-process/Sources/GraphcodeSwiftProcessSpike/main.swift b/investigation/spikes/swift-process/Sources/GraphcodeSwiftProcessSpike/main.swift new file mode 100644 index 00000000..4b7d3c6d --- /dev/null +++ b/investigation/spikes/swift-process/Sources/GraphcodeSwiftProcessSpike/main.swift @@ -0,0 +1,118 @@ +import Foundation + +struct ChildReport: Codable, Equatable { + var arguments: [String] + var workingDirectory: String + var environmentValue: String? +} +if CommandLine.arguments.dropFirst().first == "--child" { + let report = ChildReport( + arguments: Array(CommandLine.arguments.dropFirst(2)), + workingDirectory: FileManager.default.currentDirectoryPath, + environmentValue: ProcessInfo.processInfo.environment["GRAPHCODE_PROCESS_SPIKE"]) + FileHandle.standardOutput.write(try JSONEncoder().encode(report)) + exit(0) +} +struct ProcessResult { + var status: Int32 + var output: String +} +func run( + _ executable: String, + _ arguments: [String], + workingDirectory: URL? = nil, + environment: [String: String]? = nil +) throws -> ProcessResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.currentDirectoryURL = workingDirectory + process.environment = environment + let output = Pipe() + process.standardOutput = output + process.standardError = output + try process.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return ProcessResult( + status: process.terminationStatus, + output: String(decoding: data, as: UTF8.self)) +} +let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode process spike \(UUID().uuidString)", isDirectory: true) +try FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true) +defer { try? FileManager.default.removeItem(at: temporaryDirectory) } +let currentExecutable = URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL.path +var environment = ProcessInfo.processInfo.environment +environment["GRAPHCODE_PROCESS_SPIKE"] = "inherited" +let directArguments = ["space value", #"quote"value"#, "雪"] +let direct = try run( + currentExecutable, + ["--child"] + directArguments, + workingDirectory: temporaryDirectory, + environment: environment) +guard direct.status == 0, + let report = try? JSONDecoder().decode(ChildReport.self, from: Data(direct.output.utf8)) +else { + fatalError("Direct executable launch did not preserve argv, cwd, and environment") +} +FileHandle.standardOutput.write( + Data( + [ + "direct.arguments=\(report.arguments)", + "direct.cwd=\(report.workingDirectory)", + "direct.environment=\(report.environmentValue ?? "nil")", + "", + ].joined(separator: "\n").utf8)) +guard report.arguments == directArguments, + URL(fileURLWithPath: report.workingDirectory).standardizedFileURL + == temporaryDirectory.standardizedFileURL, + report.environmentValue == "inherited" +else { + fatalError("Direct executable launch changed argv, cwd, or environment") +} +let commandScript = temporaryDirectory.appendingPathComponent("echo args.cmd") +try "@echo off\r\necho CMD_OK:%~1\r\n".write( + to: commandScript, + atomically: true, + encoding: .utf8) +var commandScriptDirectlyLaunches = false +do { + let result = try run(commandScript.path, ["space value"]) + commandScriptDirectlyLaunches = + result.status == 0 && result.output.contains("CMD_OK:space value") +} catch {} +let commandHost = #"C:\Windows\System32\cmd.exe"# +let hostedCommand = try run( + commandHost, + ["/d", "/c", "call", commandScript.path, "space value"]) +FileHandle.standardOutput.write( + Data("cmd.status=\(hostedCommand.status) cmd.output=\(hostedCommand.output)\n".utf8)) +guard hostedCommand.status == 0, hostedCommand.output.contains("CMD_OK:space value") else { + fatalError("cmd.exe did not launch a .cmd shim with a spaced argument") +} +let powerShellScript = temporaryDirectory.appendingPathComponent("echo args.ps1") +try #"param([string]$Value) Write-Output "PS_OK:$Value""#.write( + to: powerShellScript, + atomically: true, + encoding: .utf8) +let powerShell = #"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"# +var powerShellScriptDirectlyLaunches = false +do { + let result = try run(powerShellScript.path, ["-Value", "space value"]) + powerShellScriptDirectlyLaunches = + result.status == 0 && result.output.contains("PS_OK:space value") +} catch {} +let hostedPowerShell = try run( + powerShell, + ["-NoLogo", "-NoProfile", "-File", powerShellScript.path, "-Value", "space value"]) +guard hostedPowerShell.status == 0, hostedPowerShell.output.contains("PS_OK:space value") else { + fatalError("PowerShell did not launch a .ps1 shim with a spaced argument") +} +print("swift-process direct-exe-argv-cwd-environment: ok") +print("swift-process direct-cmd-launches=\(commandScriptDirectlyLaunches)") +print("swift-process direct-ps1-launches=\(powerShellScriptDirectlyLaunches)") +print("swift-process cmd-hosted-shim: ok") +print("swift-process powershell-hosted-shim: ok") diff --git a/investigation/spikes/zmx-conpty/README.md b/investigation/spikes/zmx-conpty/README.md new file mode 100644 index 00000000..2404a56b --- /dev/null +++ b/investigation/spikes/zmx-conpty/README.md @@ -0,0 +1,72 @@ +# zmx ConPTY persistence spike + +Standalone Windows feasibility spike; it does not use or modify GraphCode production code. +It combines: + +- ConPTY for a hosted `cmd.exe /Q /K` terminal. +- A reader thread for ConPTY output. +- Named Pipe IPC: `\\.\pipe\zmx-conpty-spike`. +- A Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. +- Detach/reconnect snapshot behavior while the daemon remains alive. + +The daemon uses the current directory for its log and ready marker. + +## Build (PowerShell) + +```powershell +Set-Location \investigation\spikes\zmx-conpty +# Run from a Developer PowerShell or Developer Command Prompt. +cl /nologo /W4 /O2 conpty_spike.c /link /SUBSYSTEM:CONSOLE /OUT:conpty_spike.exe +``` + +The observed build exited `0`. + +## Run (PowerShell) + +```powershell +Set-Location \investigation\spikes\zmx-conpty +Remove-Item -Force -ErrorAction SilentlyContinue daemon.log,ready.txt +.\conpty_spike.exe start +Start-Sleep -Milliseconds 800 +.\conpty_spike.exe status +.\conpty_spike.exe send 'echo BEFORE' +Start-Sleep -Milliseconds 800 +.\conpty_spike.exe detach +.\conpty_spike.exe send 'echo DETACHED' +Start-Sleep -Milliseconds 800 +.\conpty_spike.exe attach +.\conpty_spike.exe status +Get-Content ready.txt +Get-Content daemon.log -Tail 30 +.\conpty_spike.exe stop +Start-Sleep -Milliseconds 800 +.\conpty_spike.exe status # expected: connect failed gle=2 +``` + +## Observed rerun + +```text +start: daemon_process_pid=40672 +ready=ready.txt +STATUS pid=42544 running=1 output=160 conpty=1 named_pipe=1 job=1 +OK write +OK detached +OK write +SNAPSHOT 388 +...echo BEFORE +BEFORE... +...echo DETACHED +DETACHED... +STATUS pid=42544 running=1 output=388 conpty=1 named_pipe=1 job=1 +pid=42544 +pipe=\\.\pipe\zmx-conpty-spike +conpty=ok +job=kill-on-close +OK stopping +connect failed gle=2 +``` + +`daemon.log` also recorded `CreatePseudoConsole(80x25)`, the Named Pipe, +`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, reader startup, both commands, and their +output. After stopping, the hosted child and daemon were absent and the Named +Pipe endpoint was closed. diff --git a/investigation/spikes/zmx-conpty/conpty_spike.c b/investigation/spikes/zmx-conpty/conpty_spike.c new file mode 100644 index 00000000..bb6e84db --- /dev/null +++ b/investigation/spikes/zmx-conpty/conpty_spike.c @@ -0,0 +1,379 @@ +#define _WIN32_WINNT 0x0A00 +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include +#include + +#define BASE_DIR L"." +#define PIPE_NAME L"\\\\.\\pipe\\zmx-conpty-spike" +#define READY_FILE L"ready.txt" +#define LOG_FILE L"daemon.log" +#define OUTPUT_CAP 262144 + +typedef struct Server { + HANDLE hpc; + HANDLE in_read; + HANDLE in_write; + HANDLE out_read; + HANDLE out_write; + HANDLE child; + DWORD child_pid; + HANDLE job; + HANDLE reader; + CRITICAL_SECTION lock; + char output[OUTPUT_CAP]; + size_t output_len; + volatile LONG running; + FILE *log; + int lock_initialized; +} Server; + +static void log_line(Server *s, const char *prefix, const char *data, size_t len) { + if (!s->log) return; + EnterCriticalSection(&s->lock); + fprintf(s->log, "%s", prefix); + fwrite(data, 1, len, s->log); + fputs("\n", s->log); + fflush(s->log); + LeaveCriticalSection(&s->lock); +} + +static DWORD WINAPI reader_thread(void *arg) { + Server *s = (Server *)arg; + EnterCriticalSection(&s->lock); + fprintf(s->log, "[reader] started\n"); + fflush(s->log); + LeaveCriticalSection(&s->lock); + char buf[4096]; + DWORD n = 0; + while (ReadFile(s->out_read, buf, sizeof(buf), &n, NULL) && n > 0) { + EnterCriticalSection(&s->lock); + size_t keep = n; + if (keep > OUTPUT_CAP - s->output_len) keep = OUTPUT_CAP - s->output_len; + if (keep) { + memcpy(s->output + s->output_len, buf, keep); + s->output_len += keep; + } + LeaveCriticalSection(&s->lock); + log_line(s, "[output] ", buf, n); + } + DWORD err = GetLastError(); + EnterCriticalSection(&s->lock); + fprintf(s->log, "[reader] exited gle=%lu\n", (unsigned long)err); + fflush(s->log); + LeaveCriticalSection(&s->lock); + InterlockedExchange(&s->running, 0); + return 0; +} + +static int write_all(HANDLE h, const void *data, DWORD len) { + const char *p = (const char *)data; + while (len) { + DWORD n = 0; + if (!WriteFile(h, p, len, &n, NULL) || n == 0) return 0; + p += n; + len -= n; + } + return 1; +} + +static int write_ready(Server *s) { + FILE *f = _wfopen(READY_FILE, L"w, ccs=UTF-8"); + if (!f) return 0; + fwprintf(f, L"pid=%lu\npipe=%s\nconpty=ok\njob=kill-on-close\n", (unsigned long)s->child_pid, PIPE_NAME); + fclose(f); + return 1; +} + +static void remove_ready(void) { + DeleteFileW(READY_FILE); +} + +static int create_server(Server *s) { +#define STEP_FAIL(msg) do { fprintf(stderr, "create_server: %s gle=%lu\n", msg, (unsigned long)GetLastError()); return 0; } while (0) + SECURITY_ATTRIBUTES sa; + memset(&sa, 0, sizeof(sa)); + sa.nLength = sizeof(sa); + sa.bInheritHandle = FALSE; + + s->job = CreateJobObjectW(NULL, NULL); + if (!s->job) STEP_FAIL("CreateJobObject"); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION ji; + memset(&ji, 0, sizeof(ji)); + ji.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!SetInformationJobObject(s->job, JobObjectExtendedLimitInformation, &ji, sizeof(ji))) STEP_FAIL("SetInformationJobObject"); + + if (!CreatePipe(&s->in_read, &s->in_write, &sa, 0)) STEP_FAIL("CreatePipe input"); + if (!CreatePipe(&s->out_read, &s->out_write, &sa, 0)) STEP_FAIL("CreatePipe output"); + SetHandleInformation(s->in_read, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(s->in_write, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(s->out_read, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(s->out_write, HANDLE_FLAG_INHERIT, 0); + + HRESULT hr = CreatePseudoConsole((COORD){80, 25}, s->in_read, s->out_write, 0, &s->hpc); + if (FAILED(hr)) STEP_FAIL("CreatePseudoConsole"); + + SIZE_T attr_size = 0; + InitializeProcThreadAttributeList(NULL, 1, 0, &attr_size); + LPPROC_THREAD_ATTRIBUTE_LIST attrs = (LPPROC_THREAD_ATTRIBUTE_LIST)HeapAlloc(GetProcessHeap(), 0, attr_size); + if (!attrs) STEP_FAIL("HeapAlloc attrs"); + if (!InitializeProcThreadAttributeList(attrs, 1, 0, &attr_size)) STEP_FAIL("InitializeProcThreadAttributeList"); + if (!UpdateProcThreadAttribute(attrs, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + s->hpc, sizeof(s->hpc), NULL, NULL)) STEP_FAIL("UpdateProcThreadAttribute"); + + STARTUPINFOEXW si; + PROCESS_INFORMATION pi; + memset(&si, 0, sizeof(si)); + memset(&pi, 0, sizeof(pi)); + si.StartupInfo.cb = sizeof(si); + si.lpAttributeList = attrs; + wchar_t cmdline[] = L"cmd.exe /Q /K"; + DWORD flags = EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT; + BOOL ok = CreateProcessW(NULL, cmdline, NULL, NULL, FALSE, flags, NULL, NULL, + &si.StartupInfo, &pi); + DeleteProcThreadAttributeList(attrs); + HeapFree(GetProcessHeap(), 0, attrs); + if (!ok) STEP_FAIL("CreateProcessW child"); + + s->child = pi.hProcess; + s->child_pid = pi.dwProcessId; + CloseHandle(pi.hThread); + // The handles supplied to CreatePseudoConsole must be released after the + // hosted process is created; retain only the host-side pipe ends. + CloseHandle(s->in_read); s->in_read = NULL; + CloseHandle(s->out_write); s->out_write = NULL; + if (!AssignProcessToJobObject(s->job, s->child)) STEP_FAIL("AssignProcessToJobObject"); + + s->log = _wfopen(LOG_FILE, L"w"); + if (!s->log) STEP_FAIL("open log"); + fprintf(s->log, "[daemon] child_pid=%lu\n", (unsigned long)s->child_pid); + fprintf(s->log, "[daemon] ConPTY=CreatePseudoConsole(80x25)\n"); + fprintf(s->log, "[daemon] JobObject=JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE\n"); + fprintf(s->log, "[daemon] NamedPipe=%ls\n", PIPE_NAME); + fflush(s->log); + + InitializeCriticalSection(&s->lock); + s->lock_initialized = 1; + InterlockedExchange(&s->running, 1); + s->reader = CreateThread(NULL, 0, reader_thread, s, 0, NULL); + if (!s->reader) STEP_FAIL("CreateThread reader"); + if (!write_ready(s)) STEP_FAIL("write_ready"); + return 1; +} + +static int read_line(HANDLE h, char *buf, DWORD cap) { + DWORD used = 0; + while (used + 1 < cap) { + DWORD n = 0; + if (!ReadFile(h, buf + used, 1, &n, NULL) || n == 0) return 0; + if (buf[used++] == '\n') break; + } + buf[used] = 0; + return 1; +} + +static void trim_line(char *line) { + size_t n = strlen(line); + while (n && (line[n - 1] == '\r' || line[n - 1] == '\n')) line[--n] = 0; +} + +static void respond(HANDLE pipe, const char *data, DWORD len) { + write_all(pipe, data, len); + FlushFileBuffers(pipe); +} + +static void handle_request(Server *s, HANDLE pipe, char *line) { + trim_line(line); + if (strncmp(line, "WRITE ", 6) == 0) { + const char *text = line + 6; + char command[4096]; + int n = _snprintf_s(command, sizeof(command), _TRUNCATE, "%s\r", text); + int wrote = (n > 0) && write_all(s->in_write, command, (DWORD)n); + EnterCriticalSection(&s->lock); + fprintf(s->log, "[ipc] WRITE bytes=%d ok=%d gle=%lu text=%s\n", n, wrote, (unsigned long)GetLastError(), text); + fflush(s->log); + LeaveCriticalSection(&s->lock); + if (wrote) respond(pipe, "OK write\n", 9); + else respond(pipe, "ERR write\n", 10); + return; + } + if (strcmp(line, "DETACH") == 0) { + respond(pipe, "OK detached\n", 13); + log_line(s, "[ipc] ", "DETACH", 6); + return; + } + if (strcmp(line, "SNAPSHOT") == 0) { + EnterCriticalSection(&s->lock); + char header[64]; + int hn = _snprintf_s(header, sizeof(header), _TRUNCATE, "SNAPSHOT %zu\n", s->output_len); + write_all(pipe, header, (DWORD)hn); + if (s->output_len) write_all(pipe, s->output, (DWORD)s->output_len); + LeaveCriticalSection(&s->lock); + FlushFileBuffers(pipe); + return; + } + if (strcmp(line, "STATUS") == 0) { + char status[256]; + EnterCriticalSection(&s->lock); + size_t len = s->output_len; + LeaveCriticalSection(&s->lock); + int n = _snprintf_s(status, sizeof(status), _TRUNCATE, + "STATUS pid=%lu running=%ld output=%zu conpty=1 named_pipe=1 job=1\n", + (unsigned long)s->child_pid, (long)s->running, len); + respond(pipe, status, (DWORD)n); + return; + } + if (strcmp(line, "QUIT") == 0) { + respond(pipe, "OK stopping\n", 13); + InterlockedExchange(&s->running, 0); + return; + } + respond(pipe, "ERR unknown\n", 13); +} + +static int server_loop(Server *s) { + while (s->running) { + HANDLE pipe = CreateNamedPipeW( + PIPE_NAME, PIPE_ACCESS_DUPLEX, PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + 1, 65536, 65536, 0, NULL); + if (pipe == INVALID_HANDLE_VALUE) return 0; + BOOL connected = ConnectNamedPipe(pipe, NULL); + if (!connected && GetLastError() != ERROR_PIPE_CONNECTED) { + CloseHandle(pipe); + continue; + } + char line[8192]; + if (read_line(pipe, line, sizeof(line))) handle_request(s, pipe, line); + FlushFileBuffers(pipe); + DisconnectNamedPipe(pipe); + CloseHandle(pipe); + } + return 1; +} + +static void cleanup_server(Server *s) { + InterlockedExchange(&s->running, 0); + if (s->child) { + WaitForSingleObject(s->child, 200); + DWORD code = STILL_ACTIVE; + if (GetExitCodeProcess(s->child, &code) && code == STILL_ACTIVE) TerminateProcess(s->child, 0); + WaitForSingleObject(s->child, 1000); + CloseHandle(s->child); + } + if (s->reader) { + CloseHandle(s->out_write); + WaitForSingleObject(s->reader, 1000); + CloseHandle(s->reader); + } + if (s->hpc) ClosePseudoConsole(s->hpc); + if (s->in_read) CloseHandle(s->in_read); + if (s->in_write) CloseHandle(s->in_write); + if (s->out_read) CloseHandle(s->out_read); + if (s->out_write) CloseHandle(s->out_write); + if (s->job) CloseHandle(s->job); + if (s->log) fclose(s->log); + DeleteFileW(READY_FILE); + DeleteCriticalSection(&s->lock); +} + +static int connect_pipe(HANDLE *out) { + for (int i = 0; i < 100; ++i) { + HANDLE h = CreateFileW(PIPE_NAME, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); + if (h != INVALID_HANDLE_VALUE) { *out = h; return 1; } + Sleep(50); + } + return 0; +} + +static int client_request(const char *request, int raw_snapshot) { + HANDLE pipe; + if (!connect_pipe(&pipe)) { fprintf(stderr, "connect failed gle=%lu\n", (unsigned long)GetLastError()); return 2; } + if (!write_all(pipe, request, (DWORD)strlen(request))) { CloseHandle(pipe); return 3; } + FlushFileBuffers(pipe); + char buf[8192]; + DWORD n; + int rc = 0; + while (ReadFile(pipe, buf, sizeof(buf), &n, NULL) && n) { + if (raw_snapshot) { + DWORD out_n = 0; + WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), buf, n, &out_n, NULL); + } else { + fwrite(buf, 1, n, stdout); + fflush(stdout); + } + } + CloseHandle(pipe); + return rc; +} + +static int wait_ready(void) { + for (int i = 0; i < 100; ++i) { + if (GetFileAttributesW(READY_FILE) != INVALID_FILE_ATTRIBUTES) return 1; + Sleep(50); + } + return 0; +} + +static int start_daemon(void) { + DeleteFileW(READY_FILE); + DeleteFileW(LOG_FILE); + wchar_t path[MAX_PATH]; + DWORD n = GetModuleFileNameW(NULL, path, MAX_PATH); + if (!n || n >= MAX_PATH) return 2; + wchar_t cmdline[2 * MAX_PATH]; + _snwprintf_s(cmdline, _countof(cmdline), _TRUNCATE, L"\"%s\" daemon", path); + STARTUPINFOW si; + PROCESS_INFORMATION pi; + memset(&si, 0, sizeof(si)); + memset(&pi, 0, sizeof(pi)); + si.cb = sizeof(si); + if (!CreateProcessW(path, cmdline, NULL, NULL, FALSE, CREATE_NO_WINDOW, + NULL, BASE_DIR, &si, &pi)) { + fprintf(stderr, "CreateProcess daemon failed gle=%lu\n", (unsigned long)GetLastError()); + return 3; + } + printf("daemon_process_pid=%lu\n", (unsigned long)pi.dwProcessId); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + if (!wait_ready()) { fprintf(stderr, "daemon did not become ready\n"); return 4; } + printf("ready=%ls\n", READY_FILE); + return 0; +} + +int wmain(int argc, wchar_t **argv) { + if (argc < 2) { + fwprintf(stderr, L"usage: start|daemon|send |detach|attach|snapshot|status|stop\n"); + return 1; + } + if (wcscmp(argv[1], L"daemon") == 0) { + Server s; + memset(&s, 0, sizeof(s)); + if (!create_server(&s)) { + fprintf(stderr, "create_server failed gle=%lu\n", (unsigned long)GetLastError()); + cleanup_server(&s); + return 10; + } + server_loop(&s); + cleanup_server(&s); + return 0; + } + if (wcscmp(argv[1], L"start") == 0) return start_daemon(); + if (wcscmp(argv[1], L"send") == 0 && argc >= 3) { + char text[4096]; + int n = WideCharToMultiByte(CP_UTF8, 0, argv[2], -1, text, sizeof(text), NULL, NULL); + if (!n) return 2; + char req[4200]; + _snprintf_s(req, sizeof(req), _TRUNCATE, "WRITE %s\n", text); + return client_request(req, 0); + } + if (wcscmp(argv[1], L"detach") == 0) return client_request("DETACH\n", 0); + if (wcscmp(argv[1], L"attach") == 0 || wcscmp(argv[1], L"snapshot") == 0) return client_request("SNAPSHOT\n", 1); + if (wcscmp(argv[1], L"status") == 0) return client_request("STATUS\n", 0); + if (wcscmp(argv[1], L"stop") == 0) return client_request("QUIT\n", 0); + fwprintf(stderr, L"unknown command\n"); + return 1; +} diff --git a/investigation/swift-windows-portability.md b/investigation/swift-windows-portability.md new file mode 100644 index 00000000..a3fc1ade --- /dev/null +++ b/investigation/swift-windows-portability.md @@ -0,0 +1,132 @@ +# Swift Windows portability audit + +Tested on Windows 11 with Swift 6.3.3 (`x86_64-unknown-windows-msvc`). + +## Result + +- `GraphcodeKit` contains 62 Swift files. +- 39 files (62.9%) are assessed as portable unchanged. +- 17 files (27.4%) retain shared behavior but need a platform/path/process/transport abstraction. +- 6 files (9.7%) are platform implementations and need Windows counterparts. +- No third-party Swift dependency blocker was found. `IdentifiedCollections` 1.1.1 and its `swift-collections` dependency built on Windows. +- A compiler-tested 31-file domain subset built and passed JSON/settings tests. This is 91.2% of `Domain/`; the three excluded files are cross-layer or path/shell coupled. +- The full package reached GraphCode sources and stopped at the unconditional `import Darwin` in `PTYProcessSession.swift`. + +Categories: + +- **A**: portable unchanged. +- **B**: shared behavior, portable after a small explicit abstraction or path correction. +- **C**: platform implementation; retain a Darwin version and add a Windows version. + +## File inventory + +| File | Category | Evidence / required change | +|---|---:|---| +| `CLI/GraphcodeCommand.swift` | A | Pure parsing/rendering over domain and daemon protocol values. | +| `DaemonBootstrap.swift` | C | launchd plist, `launchctl`, quarantine xattr, app-bundle helper layout. Add a per-user Windows startup/install host. | +| `Domain/AttentionRollup.swift` | A | Pure value derivation. | +| `Domain/BackendCapabilities.swift` | A | Pure capability values. | +| `Domain/BackendCommand.swift` | B | Domain layer calls `PresenceHooks.codexNotifyOverride`; separate OS-neutral backend policy from shell-specific hook arguments. | +| `Domain/CLISessionBackendKind.swift` | A | Pure enum/capabilities. | +| `Domain/CycleGuard.swift` | A | Codable value. | +| `Domain/EdgeCondition.swift` | A | Codable enum. | +| `Domain/EdgeKind.swift` | A | Codable enum. | +| `Domain/EdgeSpec.swift` | A | Codable value. | +| `Domain/GoalSpec.swift` | A | Codable value. | +| `Domain/GraphcodeSettings.swift` | A | Codable settings; Windows-specific defaults can be injected outside the type. | +| `Domain/LoopEdge.swift` | A | Codable value. | +| `Domain/LoopGraph.swift` | A | Built on Windows with `IdentifiedCollections`. | +| `Domain/LoopGraphScope.swift` | A | Pure scope/value logic. | +| `Domain/LoopNode.swift` | A | Built on Windows. | +| `Domain/LoopState.swift` | A | Codable state. | +| `Domain/LoopType.swift` | A | Codable enum and prompt composition. | +| `Domain/MetricSample.swift` | A | Pure value/trend logic. | +| `Domain/ModelTier.swift` | A | Codable enum. | +| `Domain/NodeDraft.swift` | A | Pure validation/value logic. | +| `Domain/NodeUpdate.swift` | A | Codable value. | +| `Domain/PayloadTransform.swift` | A | Codable enum. | +| `Domain/PilotState.swift` | A | Codable enum. | +| `Domain/Presence.swift` | A | Codable values. | +| `Domain/ProjectRef.swift` | A | Windows path strings round-trip unchanged. | +| `Domain/RemoteBootMarker.swift` | A | Pure marker parsing. | +| `Domain/RemoteProjectLocation.swift` | B | Remote path is intentionally POSIX, but local SSH executable/control-socket assumptions are macOS-specific. | +| `Domain/SafeArgument.swift` | A | Pure validation. | +| `Domain/SessionBriefing.swift` | B | Shared text, but fixed `~/.graphcode/bin/graphcode` and shell command examples need platform injection. | +| `Domain/ShellPredicate.swift` | A | Pure value. | +| `Domain/SSHReconnectLoop.swift` | B | Generates a `/bin/sh` retry loop; retain behavior behind a remote-shell strategy. | +| `Domain/TerminalLayout.swift` | A | Built on Windows with `IdentifiedCollections`. | +| `Domain/UsageSample.swift` | A | Codable value. | +| `Domain/WorktreeHygiene.swift` | A | Pure policy/value logic. | +| `Domain/WorktreeRef.swift` | A | Codable value. | +| `GraphStore.swift` | B | Orchestration is shared, but it stores raw `Int32` descriptors and calls `FramedMessageIO` directly. Store a send-capable connection instead. | +| `GraphcodeSettingsStore.swift` | A | Foundation JSON I/O is portable once `SupportDirectory` is corrected. | +| `IPC/DaemonProtocol.swift` | A | Codable command/event protocol is transport-independent. | +| `IPC/DaemonSocketClient.swift` | C | AF_UNIX, `sockaddr_un`, POSIX timeout and errno behavior. Add a Named Pipe client. | +| `IPC/DaemonSocketPath.swift` | B | Replace socket URL with a platform endpoint identity; retain support-dir/worktree isolation semantics. | +| `IPC/FramedMessageIO.swift` | B | Four-byte big-endian framing is reusable; raw POSIX `read`/`write` must become a byte-stream abstraction. | +| `ProjectPersistence.swift` | B | Replacing `/` only leaves `:` and `\` in Windows filenames. Use a stable hash plus optional readable suffix. | +| `ProjectRegistry.swift` | B | Rejects every drive-letter path via `hasPrefix("/")`; also owns raw descriptors. Use URL/path APIs and abstract connections. | +| `QuickChatStore.swift` | A | Foundation JSON I/O. | +| `Sessions/AgentEnvironment.swift` | B | `unsetenv` is not a portable public strategy. Build sanitized child environments and use a small Windows process-environment helper only where unavoidable. | +| `Sessions/CLISessionBackend.swift` | B | Shared facade, but defaults bind directly to `ZmxSessionLauncher` and shell/process implementations. Inject a session service. | +| `Sessions/CodexSessionLog.swift` | B | Foundation I/O is portable; `/` leaf parsing and zmx/process coupling need URL/platform helpers. | +| `Sessions/CopilotSessionLog.swift` | B | Foundation I/O is portable; `/` leaf parsing, Windows state locations, and zmx/process coupling need helpers. | +| `Sessions/MessageBus.swift` | A | Pure delivery policy and message construction. | +| `Sessions/NodeMemory.swift` | A | Foundation file I/O; default root follows corrected `SupportDirectory`. | +| `Sessions/PTYProcessSession.swift` | C | `Darwin`, `openpty`, `fcntl`, POSIX descriptors. Replace control-command use with a pipe-based process runner; interactive Windows PTY work belongs in zmx/ConPTY. | +| `Sessions/PresenceHooks.swift` | B | Shared lifecycle mapping, but generated hook bodies use `/bin/sh`, `sed`, `head`, shell quoting, and POSIX paths. Generate backend/OS-specific hook commands. | +| `Sessions/RemoteEnsureGate.swift` | A | Pure actor/lease logic. | +| `Sessions/RemoteGraphAccess.swift` | C | Embedded POSIX Python shim, AF_UNIX, chmod, shebangs, and Unix socket forwarding. Defer from native Windows v1 or add a separate Windows remote transport. | +| `Sessions/RemoteSocketForwarder.swift` | C | `/bin/sh`, `/usr/bin/ssh`, Unix remote socket forwarding, `kill -0`. | +| `Sessions/SessionIDStore.swift` | A | Foundation file I/O; default root follows corrected `SupportDirectory`. | +| `Sessions/ShellPredicateEvaluator.swift` | C | Hard-coded `/bin/zsh` and POSIX shell evaluation. Add a Windows predicate runner with an explicit shell policy. | +| `Sessions/ZmxLocator.swift` | B | Select `zmx.exe` and the Windows installation root. | +| `Sessions/ZmxSessionLauncher.swift` | B | Keep session/backend policy shared, but extract local process execution, shell invocation, quoting, path layout, hooks, and remote execution. | +| `SupportDirectory.swift` | B | Absolute Windows overrides are treated as relative (`C:\...` becomes `/C:/...`). Define platform roots and use URL path classification. | +| `TerminalLayoutStore.swift` | A | Foundation JSON I/O. | + +## Compiler and behavior evidence + +### Portable domain + +`investigation/spikes/swift-portable` compiles 31 domain files on Windows and tests: + +- graph JSON round-trip with `C:\Projects\GraphCode Demo` +- settings JSON round-trip + +Both tests pass. + +### Current path behavior + +`investigation/spikes/swift-paths` reproduces: + +```text +registryAcceptsPath=false +supportOverrideResolved=/D:/GraphCodeState +persistenceFileName=C:\Projects\GraphCode Demo.json +``` + +### Important correction to the original handoff + +`PTYProcessSession` is not only an old interactive PTY path. `ZmxSessionLauncher`, +Copilot/Codex probes, remote probes, label reads, sends, kills, and presence queries use +it as a general subprocess runner. Windows therefore needs: + +1. a pipe-based cross-platform `ProcessRunner` for non-interactive commands; and +2. ConPTY inside zmx for persistent interactive sessions. + +Trying to make all those short-lived control commands use ConPTY would preserve an +accidental macOS implementation detail and add unnecessary complexity. + +## Recommended first extraction + +1. `DaemonConnection` with `send(Data)`, lifecycle, and stable identity. +2. `ByteStream` framing independent of POSIX descriptors/HANDLEs. +3. `ProcessRunner` for direct executable argv, cwd, environment, output, timeout, and cancellation. +4. `ShellStrategy` for zsh, `cmd.exe`, PowerShell, and remote POSIX shells. +5. `PlatformPaths` for support/bin/hooks/session/log locations and safe persistence keys. +6. `SessionService` separating shared zmx/backend policy from OS command construction. + +`graphcoded` can remain Swift, but not “entirely shared except its socket main.” Its +orchestration can remain shared; transport host, startup, process/shell services, paths, +and remote forwarding require explicit platform implementations. diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md new file mode 100644 index 00000000..5dbc7aa4 --- /dev/null +++ b/investigation/ui-parity-matrix.md @@ -0,0 +1,42 @@ +# Windows UI parity matrix + +This is a behavioral contract, not a SwiftUI translation plan. + +| Feature | Current macOS surface | Required Windows behavior | Priority | +|---|---|---|---:| +| Project sidebar | `AppSidebarView`, project rows | Open/close projects, recent projects, state badges | P0 | +| Global graph | `GraphOverview*` | All project lanes under one start node | P1 | +| Project graph canvas | `ProjectCanvasView`, `Canvas*` | Pan, zoom, select, create, position nodes, render edges | P0 | +| Node cards | `LoopCardView`, presentation/state helpers | State, presence, activity, backend, attention styling | P0 | +| Create loop | `NodeDraftForm`, type/backend pickers | Create goal/turn/time/composite with validation | P0 | +| Connect loops | connector handles, edge forms | Drag/create hand-off/message/spawn edges and conditions | P1 | +| Loop workspace | `LoopWorkspace*` | Open a node into a terminal-first workspace | P0 | +| Terminal attach | `GhosttyTerminalView` | Run `zmx attach`, close/recreate without ending session | P0 | +| Terminal tabs | terminal layout domain + workspace views | Persist/reopen tabs | P1 | +| Terminal splits | pane layout/workspace views | Multiple panes, resize, focus navigation | P1 | +| Two simultaneous terminals | multiple panes/surfaces | Independent input, focus, resize, rendering | P0 architecture gate | +| Quick chat | `QuickChatsCanvasView` | Ad-hoc persistent agent session | P2 | +| Needs-you flow | attention rail/activity strip | Identify and navigate awaiting-input loops | P1 | +| Downstream rail | workspace rail | Navigate graph descendants from a loop | P2 | +| Jump palette | `JumpPalette*` | Keyboard navigation to projects/loops | P1 | +| Context menus | project/worktree/canvas menus | Native menus for node/project actions | P1 | +| Settings | `SettingsView` | Backend, permission, appearance, path settings | P1 | +| Worktree management | worktree features/dialogs | Inspect/sweep worktrees and bindings | P2 | +| Remote repository UI | welcome/remote forms | Defer until Windows remote transport is designed | Deferred | +| Updates/install | Sparkle-like macOS flow | Native installer/update strategy | P2 | +| Accessibility | SwiftUI/AppKit semantics | UIA names, roles, focus, terminal text exposure | P0 for release | +| Keyboard/IME | AppKit/Ghostty integration | Native key layout, dead keys, IME composition | P0 architecture gate | +| Clipboard/selection | Ghostty/AppKit | Copy/paste and mouse selection | P0 architecture gate | +| DPI/theme | AppKit/SwiftUI | Per-monitor DPI, resize, dark/light theme | P0 | + +## Minimum usable Windows shell + +1. Project list. +2. One project canvas with node cards and edges. +3. Create/open/stop/send actions through `graphcoded`. +4. One terminal workspace attached to zmx. +5. State/presence updates. +6. Two terminal surfaces in one top-level window before committing to the compositor. + +Tabs, arbitrary splits, remote SSH, worktree polish, updater, and full global-graph parity +should follow only after the terminal-host gate passes. diff --git a/investigation/windows-port-feasibility.md b/investigation/windows-port-feasibility.md new file mode 100644 index 00000000..74873b30 --- /dev/null +++ b/investigation/windows-port-feasibility.md @@ -0,0 +1,200 @@ +# GraphCode Windows port feasibility + +## Executive decision + +**A native Windows port is feasible, but the architecture in the handoff is only partly +proven.** + +- **Go**: shared Swift domain/orchestration, Windows `graphcoded`, CLI, Named Pipe IPC, + and Windows paths/process services. +- **Conditional go**: a cross-platform zmx backend. The OS primitives are feasible, but + the spike does not preserve zmx's real wire protocol, long-lived attach behavior, or + terminal snapshot semantics. +- **Conditional go**: the Windows UI. `libghostty-vt` works and can back multiple custom + Win32 terminal views, but that does not yet prove a complete Ghostty-rendered surface + with input, IME, clipboard, accessibility, DPI, and multi-surface composition. +- **Defer**: remote SSH parity, ARM64, and production installer/updater. + +The recommended program is therefore headless-first. Do not begin the graph editor until +the complete two-terminal Ghostty host gate passes. + +## Evidence produced + +| Spike | Result | Decision impact | +|---|---|---| +| Full GraphcodeKit SwiftPM build | Dependencies build; GraphCode compilation stops at unconditional `import Darwin` in `PTYProcessSession` | Swift dependency graph is viable; platform seams are source-level, not a Swift-on-Windows blocker | +| Portable Swift domain | 31 files compile; graph/settings JSON tests pass | Core domain remains Swift | +| Windows path behavior | Reproduced drive-path rejection, malformed support override, unsafe persistence filename | Paths require an early platform abstraction | +| Swift Named Pipes | Request/response, events, multiple clients, reconnect, unavailable daemon, connection-availability timeout, and oversized-frame rejection pass | `graphcoded` can remain Swift and use WinSDK directly; connected-I/O deadlines remain open | +| Swift `Process` | Direct exe preserves argv/Unicode/cwd/env; `.cmd` works; `.ps1` needs PowerShell host | Foundation `Process` is viable with explicit extension/shell policy | +| zmx ConPTY primitives | ConPTY + Named Pipe + Job Object; short connections, background output, raw-buffer snapshot, stop all pass | OS primitives are feasible; actual zmx protocol/attach parity remains unproven | +| Ghostty VT custom window | `libghostty-vt` builds; two independent child terminal views render in one GraphCode-owned HWND | VT/state embedding is feasible; full Ghostty surface remains a separate gate | + +Spike source and commands are under `investigation/spikes/`. + +## Material corrections to the handoff + +1. **Daemon transport is not isolated to two socket files.** `GraphStore` and + `ProjectRegistry` own raw `Int32` descriptors and write frames directly. +2. **`PTYProcessSession` is not merely an old interactive path.** It runs zmx control + commands, label reads, sends, kills, log probes, and remote commands. Split it into a + pipe-based process runner and zmx's interactive ConPTY backend. +3. **Current path handling is functionally incompatible with Windows.** This affects + project admission, support-directory overrides, and persistence filenames. +4. **Remote support is not a small SSH executable-path change.** It depends on POSIX + shells, AF_UNIX, chmod/shebangs, control sockets, and reverse Unix socket forwarding. +5. **`libghostty-vt` Windows support is not proof of a full reusable Ghostty surface.** + The handoff conflates terminal state APIs with the renderer/application runtime. +6. **The GraphCode zmx fork is stale.** Its mouse-input patch remains relevant but is 26 + upstream commits behind and conflicts when moved to current upstream. +7. **Swift toolchain setup needs to be explicit.** The official 6.3.3 toolkit, runtime + DLL path, Windows SDK root, MSVC libraries, and Git bare-repository policy all affected + the spike. CI must codify this rather than assume `swift` on PATH is sufficient. + +## Recommended architecture + +```text +GraphCode shared Swift package + domain, graph, persistence, backend/session policy + | + graphcoded (Swift) + | + protocol + length framing + / \ + Unix socket/macOS Named Pipe/Windows + | | + SwiftUI/AppKit shell native Windows shell + | | + GhosttyKit full Ghostty host gate + | | + zmx cross-platform zmx + ConPTY +``` + +Required shared interfaces: + +- `DaemonConnection` / `DaemonListener` +- `ByteStream` +- `PlatformPaths` +- `ProcessRunner` +- `ShellStrategy` +- `SessionService` +- `StartupManager` + +Do not put platform conditionals throughout `ZmxSessionLauncher`. Keep backend/resume/ +message policy shared and move command construction/execution behind those services. + +## zmx feasibility + +zmx is portable in architecture but not in implementation. Current Unix dependencies +include `forkpty`, double-fork daemonization, AF_UNIX, `poll`, signals/self-pipe, termios, +ioctl resize, process groups, UID/XDG paths, `/bin/sh`, and Unix quoting. + +The Windows spike demonstrated the enabling OS behavior: + +```text +start ConPTY child +send BEFORE +detach client +send DETACHED while detached +reattach and receive snapshot containing both +stop and clean the process tree +``` + +It did **not** implement zmx's real 8-byte-header/tagged protocol, a long-lived +bidirectional attached client, libghostty-vt reconstruction, resize, or concurrent attach +leadership. Its `attach` is a one-shot raw-buffer snapshot and `detach` records no client +state. + +Recommendation: + +1. Rebase the GraphCode mouse patch onto current upstream zmx. +2. Introduce platform modules for PTY/process, IPC, daemon lifecycle, event wait, paths, + resize/control, and task shell. +3. Preserve the existing 8-byte IPC header, 552-byte info structure, tags, CLI names, and + GraphCode-used commands (`run -d`, `attach`, `send`, `get`, `set`, `kill`). +4. Before committing to the backend port, build a source-integrated zmx prototype that + preserves the real wire ABI/CLI and proves long-lived attach, detach, reconnect with + VT reconstruction, resize, concurrent attach policy, and one real agent TUI. +5. Add black-box compatibility tests before changing GraphCode. + +Confidence is high for the Windows primitives and medium-low for full zmx protocol/task/ +signal/agent parity. + +## Ghostty/Winghostty feasibility + +Positive evidence: + +- `libghostty-vt` builds on Windows. +- Its public C API exposes terminal lifecycle, VT writes, resize, render snapshots, + row/cell iterators, styles/colors/graphemes, key/mouse/focus encoders, selection, and + paste validation. +- Public terminal row/cell state can drive two independent child views in one + GraphCode-owned top-level HWND. +- Multiple terminal states are not inherently a blocker. + +Negative evidence: + +- Upstream `ghostty.h` is explicitly an internal macOS/iOS embedder API and has no HWND + platform payload. +- Upstream Ghostty has no Win32 application runtime or public Windows + `create_surface(parent_hwnd)` equivalent. +- `libghostty-vt` provides no windowing, ConPTY, process launch, GPU context, font shaping, + glyph atlas, compositor, clipboard ownership, or event routing. + +Not yet proven: + +- reuse of Ghostty's production renderer rather than a custom GDI renderer +- complete keyboard layout and IME behavior +- mouse selection and clipboard +- accessibility/UIA +- DPI and teardown under repeated surface recreation +- compositor behavior with graph canvas plus two live terminal surfaces +- a maintainable build against current Winghostty/Ghostty revisions + +Winghostty proves the topology is possible: its internal `Host` owns the top-level HWND +and child `Surface` values own HWND/HDC/HGLRC/CoreSurface instances and WGL rendering. +However, this boundary is internal and tightly coupled across `win32.zig`, `Surface.zig`, +`App.zig`, renderer/OpenGL, compositor, clipboard, UIA, tabs/splits, shell, IPC, recovery, +and settings modules. Winghostty is valuable source evidence, not a safe dependency +decision yet. Its tested build has a Zig-version/path conversion failure, while newer Zig +is API-incompatible. + +**UI gate:** a GraphCode-owned window containing two complete Ghostty-rendered surfaces, +each running a command and independently handling focus/input/resize/clipboard/IME. Until +that passes, “Zig + Win32 using Ghostty/Winghostty” remains a preferred hypothesis. The +two feasible implementation choices are both substantial: + +1. extract/maintain Winghostty's internal Win32/OpenGL runtime; or +2. use `libghostty-vt` and build GraphCode's own production renderer/input stack. + +## Delivery plan and measured estimate + +| Phase | Exit condition | Estimate | +|---|---|---:| +| 1. Shared Swift extraction | Cross-platform package, paths/process abstractions, shared tests | 3-5 engineer-weeks | +| 2. Windows daemon + CLI | Secure Named Pipe, multi-client events, local graph commands, startup | 3-5 weeks | +| 3. zmx Windows backend | CLI compatibility, ConPTY detach/reattach, resize, Unicode, crash tests | 6-10 weeks | +| 4. Full terminal-host gate | Extracted Winghostty runtime or production custom renderer; two surfaces with input/IME/clipboard/DPI | 8-16 weeks, very high uncertainty | +| 5. Minimal native shell | Project list, graph, node actions, one workspace, state updates | 8-12 weeks | +| 6. parity/hardening | Tabs/splits, attention UX, accessibility, packaging, agents | 8-14 weeks | + +Total: roughly **36-62 engineer-weeks** before remote parity and ARM64. One experienced +engineer should expect approximately 9-16 months; a small parallel team can reduce +calendar time, but the terminal-host gate is not parallelizable away. + +## Go/no-go checkpoints + +Proceed now with phases 1-2 and a source-integrated zmx prototype. Treat the complete zmx +backend as conditional on that prototype. + +Do not approve the full product port budget until: + +1. the full two-surface Ghostty host gate passes; +2. zmx runs at least one real coding agent through detach/send/reattach; +3. Named Pipe ACLs, connected read/write deadlines, cancellation, and frame bounds are proven; +4. request correlation, version negotiation, event-subscription semantics, and interleaved + multi-client tests are complete; +5. Swift runtime packaging size and installer behavior are measured. + +If the Ghostty host gate fails, reconsider the UI host/renderer choice without discarding +the successful shared Swift, daemon, CLI, and zmx work. diff --git a/investigation/windows-process-and-shell-semantics.md b/investigation/windows-process-and-shell-semantics.md new file mode 100644 index 00000000..bf4f2111 --- /dev/null +++ b/investigation/windows-process-and-shell-semantics.md @@ -0,0 +1,55 @@ +# Windows process and shell semantics + +## Spike result + +`investigation/spikes/swift-process` uses Swift Foundation `Process` on Windows 11. + +Observed: + +```text +direct.arguments=["space value", "quote\"value", "雪"] +direct.environment=inherited +swift-process direct-exe-argv-cwd-environment: ok +swift-process direct-cmd-launches=true +swift-process direct-ps1-launches=false +swift-process cmd-hosted-shim: ok +swift-process powershell-hosted-shim: ok +``` + +Conclusions: + +- `Process` is sufficient for direct `.exe` launches with argv, Unicode, cwd, and environment. +- On this toolchain, `.cmd` launches directly and preserves a spaced argument, but GraphCode should still classify executable extensions explicitly rather than rely on undocumented dispatch behavior. +- `.ps1` does not launch directly. It needs `powershell.exe` or `pwsh.exe`. +- POSIX shell strings and quoting must not be translated mechanically to Windows. + +## Proposed launch policy + +| Input | Windows launch | +|---|---| +| `.exe`, extensionless native executable | Direct `Process`/`CreateProcessW` argv | +| `.cmd`, `.bat` | `cmd.exe /d /c call