Skip to content

debug socket: offscreen ground-truth surface screenshot (debug.surface.screenshot) - #9708

Closed
lawrencecchen wants to merge 5 commits into
mainfrom
feat-offscreen-window-screenshot
Closed

lawrencecchen wants to merge 5 commits into
mainfrom
feat-offscreen-window-screenshot

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Adds debug.surface.screenshot, a focus-free, occlusion-proof, native-resolution ground-truth capture of one embedded Ghostty terminal surface over the debug socket, for the ghostty-web pixel-parity harness. The existing debug.window.screenshot captures at 1x via CGWindowListCreateImage (.nominalResolution) and goes stale when the window is occluded, forcing the harness to raise the tagged window over the user's work.

Chosen design and why

Option (a), render-on-demand with direct read-back, implemented through the renderer's own IOSurface swap chain rather than an offscreen texture, because the fork's renderer already presents by assigning IOSurfaces to the layer (ghostty/src/renderer/metal/IOSurfaceLayer.zig): reading the presented IOSurface IS the compositor's input, byte for byte, so no second render path can drift from what the window would show. Option (b) (occlusion-override + ScreenCaptureKit) was rejected: SCScreenshotManager needs Screen Recording TCC consent per tagged bundle id (every dev tag is a fresh TCC identity, so every parity build would prompt the user), and it captures display-profile-converted output rather than the renderer's own pixels.

Mechanism, end to end:

  1. The socket worker submits one forced tokened render via the new fork API ghostty_surface_request_render_with_token (renderer: thread-safe tokened forced draw for offscreen capture ghostty#181). It fills a single-slot pending presentation on the renderer thread and rings the existing draw_now async, so it is thread-safe while the renderer OS thread is live, unlike ghostty_surface_render_now_with_token (iOS external-drain only). The forced draw rebuilds frame data from current terminal state (updateFrame) and deliberately skips the occlusion flags.visible gate.
  2. Metal delivers the presentation on the main thread in the same dispatched block that assigns that exact frame's IOSurface to the layer (IOSurfaceLayer.setSurfaceCallback), so the waiter's read of layer.contents is sequenced strictly after the assignment. No sleeps, no polling: the sync primitive is the renderer's own frame-completion callback (requirement 3).
  3. The waiter deep-copies the IOSurface (locked read-only) into a CGImage tagged Display P3 — the renderer's Metal target color space (Target.zig creates its render targets with displayP3; the leased-frame API enum documents the same). The PNG is written through ImageIO so the P3 ICC profile is embedded. The harness already converts P3 desktop captures to sRGB; it can keep doing so, now keyed off the response's color_space: "display-p3".
  4. Capture is native backing scale (2x on Retina) by default; scale resamples in P3 (scale: 1 gives logical-point resolution). The response carries width_px/height_px, points_width/points_height, native_scale, plus capture-time evidence (window_occlusion_visible, app_active, window_frame) so callers can prove the window was occluded and the app inactive at the moment of capture.

The path never activates the app, never changes key window, never reorders windows, and needs no Screen Recording permission (it never touches the window server).

Invocation

from cmux import cmux                      # tests_v2/cmux.py
c = cmux("/tmp/cmux-debug-<tag>.sock"); c.connect()
res = c.surface_screenshot(panel_id, path="/tmp/out.png")   # native 2x, Display P3
res = c.surface_screenshot(panel_id, scale=1)               # logical resolution

Raw wire form: {"id":1,"method":"debug.surface.screenshot","params":{"surface_id":"<uuid|index>","scale":2,"path":"/tmp/out.png"}}.

Proof

tests_v2/test_offscreen_surface_screenshot.py --socket /tmp/cmux-debug-oscap.sock (standalone; launches nothing but a tiny swift overlay window used as the occluder, killed on exit):

  • writes red/green/blue background bands plus a text sentinel, sentinel-waits via read_text
  • covers the tagged window with a floating overlay and waits until the RPC itself reports window_occlusion_visible == false
  • asserts the occluded, non-frontmost capture is native 2x, decodes the PNG with a pure-stdlib decoder, verifies all three bands
  • asserts two consecutive captures are pixel-identical (determinism) and scale=1 returns logical-size output

Run against the tagged oscap build of this PR head (window 1000x700 fully covered by the overlay, app inactive throughout; all evidence values come from the RPC response captured at the same instant as the pixels):

== verifying occluded capture
  [ok] window fully occluded at capture time
  [ok] app not active at capture time
  [ok] capture is native 2x (scale=2)
  [ok] pixel dims are 2x logical dims (1520x1288 px vs 760x644 pt)
  [ok] color space tagged display-p3
  [ok] decoded PNG matches reported dims
  [ok] red band present (125440 px)
  [ok] green band present (125440 px)
  [ok] blue band present (125440 px)
== verifying determinism
  [ok] repeat capture is pixel-identical
== verifying --scale 1
  [ok] scale=1 halves pixel dims (760x644 px)
PASS: occluded, non-frontmost, native-2x, Display P3, deterministic

Ordering and limitations

  • Merge renderer: thread-safe tokened forced draw for offscreen capture ghostty#181 first; this PR bumps the ghostty submodule pointer to that branch head.
  • Capture reads the presented swap-chain IOSurface immediately, on the main thread, in the acknowledgment block; a subsequent frame could in principle recycle that slot mid-copy, but no newer frame can be in flight for a quiescent terminal (the harness captures only after sentinel-quiesce), and the copy is a full-speed memcpy of a ~2MB surface.
  • One capture in flight per surface (the single-slot pending presentation refuses a concurrent second tokened draw with a clean unavailable error, rather than sharing a frame between tokens).
  • Targets the selected workspace like debug.panel_snapshot; the harness already selects its workspace before capturing.
  • If the renderer is reclaimed (hidden pane) or the surface is mid-resize (size-guard discard), the callback never fires and the RPC returns timeout after 10s; visible panes in occluded windows keep realized renderers, which is the parity case.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Adds debug.surface.screenshot to capture a terminal surface from its presented IOSurface at native resolution, unaffected by focus or occlusion. Outputs a Display P3 PNG and returns capture metadata for verification.

  • New Features

    • New RPC debug.surface.screenshot forces a tokened render and deep-copies the presented IOSurface; works while occluded or on another Space; no Screen Recording or window activation/reordering.
    • Output and params: Display P3 PNG at native scale (2x on Retina) with optional scale, label, path; response includes pixel/point dims, native_scale, color_space, window_occlusion_visible, app_active, and window_frame.
    • Synchronization: per-surface presented-frame token waiter; one capture in flight; 10s timeout; runs on the socket worker to avoid main-actor deadlock. Adds tests_v2/test_offscreen_surface_screenshot.py and tests_v2/cmux.py.surface_screenshot.
  • Dependencies

Written for commit 6b43d63. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added the DEBUG-only debug.surface.screenshot command for capturing terminal surfaces as PNGs, including offscreen or inactive windows.
    • Supports optional scaling, labels, output paths, timeouts, and Display P3 resampling.
    • Returns capture metadata such as dimensions, backing scale, color space, and window visibility.
    • Added the cmux.surface_screenshot Python helper.
  • Bug Fixes

    • Improved render synchronization so screenshots correspond to the presented frame without blocking the main thread.
  • Tests

    • Added end-to-end coverage for occluded and inactive surface screenshots.

Note

Medium Risk
New debug-only control-socket path with main-thread/render synchronization and IOSurface readback; depends on fork Ghostty APIs and could deadlock if mis-routed to the main actor (policy explicitly avoids this).

Overview
Adds DEBUG-only debug.surface.screenshot so harnesses can capture a terminal surface from the renderer’s presented IOSurface instead of the window server.

The socket worker triggers a tokened forced render (ghostty_surface_request_render_with_token), waits for the main-thread render-presented callback, then deep-copies the layer’s IOSurface into a Display P3 PNG at native backing scale (optional scale resampling). Capture does not focus, activate, or reorder windows and avoids Screen Recording; the response includes occlusion/active metadata for proof.

TerminalSurface gains presented-frame waiter bookkeeping and installs the render-presented callback at runtime creation. GhosttyTerminalView adds debugCopyPresentedFrameImage(). Execution policy routes the method on the socket worker (not main actor) to avoid deadlock. Python client and an occluded-window integration test are included.

Reviewed by Cursor Bugbot for commit 3b1cf0f. Bugbot is set up for automated code reviews on this repo. Configure here.

…e.screenshot)

Add a focus-free, occlusion-proof, native-resolution capture path for
the embedded Ghostty terminal, for the ghostty-web pixel-parity harness.

Mechanism: the socket worker submits one forced tokened render through
the new ghostty fork API ghostty_surface_request_render_with_token
(renderer-thread mailbox, safe from any thread, deliberately ignores the
occlusion gate). Libghostty acknowledges the token on the main thread in
the same block that assigns the frame's IOSurface to the layer; the
waiter then deep-copies that IOSurface, the exact bytes the compositor
would composite for the terminal grid area, at native backing scale
(2x on Retina), tagged Display P3 (the renderer's Metal target color
space). No Screen Recording permission, no window-server involvement,
never activates the app or reorders windows; works fully occluded, on
another Space, or never ordered front.

- ghostty submodule: manaflow-ai/ghostty#181 (tokened renderer-thread
  forced draw; must merge before this PR)
- CmuxTerminal: install the render-presented callback per runtime
  surface; token waiter registry on TerminalSurface
- debug.surface.screenshot params: surface_id, scale (default native),
  label/path; response includes pixel+point dims, native_scale,
  color_space, window_occlusion_visible, app_active, window_frame so
  callers can prove capture conditions
- runs on the socket worker (blocks on a main-thread-delivered
  acknowledgment; main-actor execution would deadlock)
- tests_v2/test_offscreen_surface_screenshot.py: standalone proof
  script (pattern write, overlay full occlusion, non-frontmost, 2x,
  Display P3, pixel-identical repeat captures, scale=1)
@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds a DEBUG-only debug.surface.screenshot socket method. It waits for a presented terminal frame, copies the IOSurface into a Display P3 image, writes PNG output, returns capture metadata, and adds Python and end-to-end test coverage.

Changes

Surface screenshot capture

Layer / File(s) Summary
Presented-frame acknowledgment
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/*
Runtime surfaces register presented-frame callbacks. Tokened waiters support registration, acknowledgment, cancellation, expiry, and failed-render cleanup.
Presented IOSurface image copy
Sources/GhosttyTerminalView.swift, ghostty, scripts/ghosttykit-checksums.txt
The terminal view copies presented IOSurface pixels into a Display P3 CGImage at native backing resolution. The Ghostty submodule and checksum entry are updated.
Debug screenshot API and dispatch
Sources/TerminalController+SurfaceScreenshot.swift, Sources/TerminalController.swift, Sources/TerminalController+DebugMethodNames.swift, Packages/macOS/CmuxControlSocket/..., cmux.xcodeproj/project.pbxproj
The new method validates parameters, captures a tokened frame, optionally resamples it, writes PNG output, and returns metadata. DEBUG and Release dispatch paths, worker execution policy, tests, and Xcode project wiring are updated.
Client wrapper and offscreen validation
tests_v2/cmux.py, tests_v2/test_offscreen_surface_screenshot.py
The Python client exposes screenshot options. The end-to-end test validates occluded captures, image metadata, pixel content, repeated output, logical scaling, and cleanup.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DebugClient
  participant TerminalController
  participant TerminalSurface
  participant GhosttyTerminalView
  participant PNGOutput
  DebugClient->>TerminalController: Send debug.surface.screenshot
  TerminalController->>TerminalSurface: Request presented frame token
  TerminalSurface->>GhosttyTerminalView: Trigger tokened render
  GhosttyTerminalView-->>TerminalSurface: Report presented frame
  TerminalController->>GhosttyTerminalView: Copy presented IOSurface
  TerminalController->>PNGOutput: Write PNG and capture metadata
  PNGOutput-->>DebugClient: Return screenshot response
Loading

Possibly related PRs

  • manaflow-ai/cmux#9066: Updates related GhosttyTerminalView IOSurface copying and TerminalController screenshot handling.

Suggested reviewers: austinywang


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (4 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error The new production RPC calls socketAwaitCallback(timeout: 10.0), whose implementation uses DispatchSemaphore.wait and NSLock; it also adds a v2MainSync hop for this path. Use an asynchronous socket response with a MainActor callback/continuation and cancellation-aware timeout instead of blocking socketAwaitCallback and synchronous main hopping.
Cmux User-Facing Error Privacy ❌ Error The new debug socket API exposes internal details in error bodies: TabManager, IOSurface, and Tokened render ... realized renderer (lines 147, 170, 187). Use generic cmux terms such as “Capture unavailable” or “Capture failed”; keep renderer, IOSurface, and token details in logs, with safe retry guidance.
Cmux Full Internationalization ❌ Error New socket API errors in Sources/TerminalController+SurfaceScreenshot.swift return hardcoded English text without String(localized:) or catalog entries. Localize each new screenshot error message and add matching translated entries for every locale in Resources/Localizable.xcstrings.
Cmux No Test Or Debug Seam In Production Source ❌ Error The diff adds DebugPresentedFrameImage and debugCopyPresentedFrameImage() to production Sources/GhosttyTerminalView.swift; the only caller is the DEBUG-only screenshot RPC. Move these members to a dedicated debug source file or folder, or use a test-target seam via @testable import; follow #6452.
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (20 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed The new RPC is nonisolated and routes UI access through @MainActor v2StartDebugSurfaceScreenshot; waiter APIs are @MainActor, with no new unsafe Sendable reference type or service protocol.
Cmux Browser Automation Off-Main ✅ Passed The PR adds only debug.surface.screenshot; it adds no browser.* command or browser-router change. Existing browser commands remain worker-routed with v2MainSync hops and policy coverage.
Cmux Expensive Synchronous Load ✅ Passed The PR adds no agent-history, transcript, JSONL, directory-scan, or RestorableAgentSessionIndex load. The DEBUG screenshot handler runs on the socket worker and hops to MainActor only for UI capture.
Cmux Cache Substitution Correctness ✅ Passed The diff adds a debug screenshot path, not a persistence/history/undo cache substitution; it forces a render and reads IOSurface after an event-driven presentation callback.
Cmux No Hacky Sleeps ✅ Passed The PR adds no TypeScript, JavaScript, shell, or non-Swift runtime script changes; its only new sleeps are in the Python integration test scaffolding, which the rule allows.
Cmux Algorithmic Complexity ✅ Passed New code uses linear workspace/panel lookup and token dictionary access; waiter pruning is bounded per surface by one in-flight capture and a 60-second expiry, with no nested scalable scans.
Cmux Swift Concurrency ✅ Passed Feature diff adds no DispatchQueue, Combine, or fire-and-forget Task; its two escaping closures bridge the required Ghostty presentation callback and existing synchronous socket callback awaiter.
Cmux Swift @Concurrent ✅ Passed The Swift diff adds no async or @concurrent functions. The synchronous screenshot handler runs on the socket worker, while renderer callbacks and UI/model access remain explicitly @MainActor-bound.
Cmux Swift Package Boundaries ✅ Passed The app-target additions are DEBUG AppKit/Ghostty and TerminalController lifecycle glue; tokened presented-frame state and API are placed in the CmuxTerminal SwiftPM target.
Cmux Swiftpm Lockfiles ✅ Passed The PR changes no Package.swift, cmux-owned .gitignore, or Package.resolved files; its Xcode diff only adds a Swift source file, and Ghostty remains a vendored submodule update.
Cmux Swift Logging ✅ Passed The PR Swift diff adds no print, debugPrint, dump, NSLog, Logger, or ad hoc diagnostic logging; the existing GhosttyTerminalView NSLog is unchanged.
Cmux Swiftui State Layout ✅ Passed The PR adds no SwiftUI state or layout patterns. It adds backend/AppKit bridge code and plain waiter storage; existing ObservableObject state is touched only incidentally.
Cmux Architecture Rethink ✅ Passed The diff adds no sleeps, polling, locks, delayed dispatch, or notification observers; it uses a documented Ghostty callback bridge with waiter state owned by TerminalSurface's main actor.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed The Swift diff adds no standalone NSWindow, NSPanel, NSWindowController, Window, or WindowGroup. It only reads an existing view.window, and the auxiliary-window lint passes.
Cmux Source Artifacts ✅ Passed The diff contains only intentional Swift/Python source, tests, Xcode configuration, the existing Ghostty submodule pointer, and its checksum; no scratch, output, cache, or generated artifact paths...
Cmux No Ambient Global State ✅ Passed New screenshot behavior is implemented as methods on TerminalController, TerminalSurface, and GhosttySurfaceScrollView; the only file-scope callback is a private C-interop bridge.
Title check ✅ Passed The title clearly identifies the new debug socket API and its offscreen surface screenshot purpose.
Description check ✅ Passed The description thoroughly explains the change, design, testing, limitations, and dependency, although the template checklist and demo video are not included.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-offscreen-window-screenshot

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift`:
- Line 36: Add an explicit assertion in fixedWorkerSetRunsOnTheSocketWorker for
debug.surface.screenshot confirming mainThreadCallable is false, alongside the
existing runsOnSocketWorker assertion. Ensure the test fails if this method is
added to mainThreadCallableSocketWorkerMethods.

In `@Sources/GhosttyTerminalView.swift`:
- Around line 11326-11331: Update the IOSurface pixel-reading flow around
IOSurfaceLock to check its return status before registering the IOSurfaceUnlock
defer; return nil when locking fails. Also validate that IOSurfaceGetBaseAddress
returns a non-nil address before constructing Data, returning nil otherwise.

In `@Sources/TerminalController`+SurfaceScreenshot.swift:
- Around line 29-41: Document the `@unchecked` Sendable rationale for
V2SurfaceScreenshotFrame and V2SurfaceScreenshotOutcome, stating that their
payload is immutable after construction and handed off exactly once between the
main-thread callback and worker thread.
- Around line 100-111: Sanitize the untrusted label in the default output path
branch before interpolating it into the filename. Update the label-derived base
value near outputURL so path separators and traversal components such as “..”
cannot escape the cmux-screenshots directory, while preserving the explicitPath
behavior as a caller-selected destination.

In `@tests_v2/cmux.py`:
- Around line 1086-1098: Validate panel at the start of surface_screenshot
before calling _resolve_surface_id: reject None and empty-string values, while
preserving valid string and integer panel handling. Ensure invalid input exits
through the existing validation/error convention rather than passing a null
surface_id or resolving the focused surface.

In `@tests_v2/test_offscreen_surface_screenshot.py`:
- Around line 173-175: Update the default-socket guard in the offscreen
screenshot test to compare resolved paths on both sides, resolving the literal
default socket path as well as args.socket. Preserve the existing refusal
message and return code when the paths identify the same socket.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2b2abf9c-4bae-450f-845d-b602e015a0e5

📥 Commits

Reviewing files that changed from the base of the PR and between 4c3325d and f97235b.

📒 Files selected for processing (14)
  • Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift
  • Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift
  • Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift
  • Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+PresentedFrameCapture.swift
  • Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift
  • Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/TerminalController+DebugMethodNames.swift
  • Sources/TerminalController+SurfaceScreenshot.swift
  • Sources/TerminalController.swift
  • cmux.xcodeproj/project.pbxproj
  • ghostty
  • tests_v2/cmux.py
  • tests_v2/test_offscreen_surface_screenshot.py

"workspace.remote.pty_bridge", "workspace.env", "sidebar.custom.reload",
"sidebar.custom.open",
"debug.sidebar.simulate_drag", "debug.mobile.transport.disconnect",
"debug.surface.screenshot",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin mainThreadCallable: false for debug.surface.screenshot.

fixedWorkerSetRunsOnTheSocketWorker only asserts runsOnSocketWorker. The policy comment states that a main-thread run deadlocks for the full capture timeout. Add an explicit assertion so a later edit cannot add the method to mainThreadCallableSocketWorkerMethods without failing a test.

💚 Proposed assertion
     `@Test` func onlyPureProbesAreMainThreadCallable() {
         `#expect`(ControlCommandExecutionPolicy(forMethod: "system.ping") == .socketWorker(mainThreadCallable: true))
         `#expect`(ControlCommandExecutionPolicy(forMethod: "system.capabilities") == .socketWorker(mainThreadCallable: true))
         `#expect`(ControlCommandExecutionPolicy(forMethod: "system.top") == .socketWorker(mainThreadCallable: false))
+        // A main-thread run would block on the renderer's main-thread
+        // presented-frame acknowledgment for the whole capture timeout.
+        `#expect`(ControlCommandExecutionPolicy(forMethod: "debug.surface.screenshot") == .socketWorker(mainThreadCallable: false))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift`
at line 36, Add an explicit assertion in fixedWorkerSetRunsOnTheSocketWorker for
debug.surface.screenshot confirming mainThreadCallable is false, alongside the
existing runsOnSocketWorker assertion. Ensure the test fails if this method is
added to mainThreadCallableSocketWorkerMethods.

Comment on lines +11326 to +11331
IOSurfaceLock(surfaceRef, [.readOnly], nil)
defer { IOSurfaceUnlock(surfaceRef, [.readOnly], nil) }

let base = IOSurfaceGetBaseAddress(surfaceRef)
let size = bytesPerRow * height
let data = Data(bytes: base, count: size)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle IOSurfaceLock failure before reading pixels.

IOSurfaceLock can fail. The current path then reads the base address and unlocks an IOSurface that was not successfully locked. Return nil unless the lock succeeds and the base address exists.

Proposed fix
-        IOSurfaceLock(surfaceRef, [.readOnly], nil)
+        guard IOSurfaceLock(surfaceRef, [.readOnly], nil) == KERN_SUCCESS,
+              let base = IOSurfaceGetBaseAddress(surfaceRef) else {
+            return nil
+        }
         defer { IOSurfaceUnlock(surfaceRef, [.readOnly], nil) }
 
-        let base = IOSurfaceGetBaseAddress(surfaceRef)
         let size = bytesPerRow * height
#!/bin/bash
set -euo pipefail

sdk_root="$(xcrun --sdk macosx --show-sdk-path)"
fd -a 'IOSurface*.h' "$sdk_root" | while IFS= read -r header; do
  rg -n -C 3 'IOSurfaceLock|IOSurfaceGetBaseAddress|IOSurfaceUnlock' "$header"
done
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/GhosttyTerminalView.swift` around lines 11326 - 11331, Update the
IOSurface pixel-reading flow around IOSurfaceLock to check its return status
before registering the IOSurfaceUnlock defer; return nil when locking fails.
Also validate that IOSurfaceGetBaseAddress returns a non-nil address before
constructing Data, returning nil otherwise.

Comment on lines +29 to +41
private struct V2SurfaceScreenshotFrame: @unchecked Sendable {
let surfaceId: UUID
let image: CGImage
let backingScale: CGFloat
let windowOcclusionVisible: Bool
let appActive: Bool
let windowFrame: CGRect?
}

private enum V2SurfaceScreenshotOutcome: @unchecked Sendable {
case frame(V2SurfaceScreenshotFrame)
case failure(code: String, message: String)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the @unchecked Sendable rationale.

The coding guidelines require a clear safety explanation for @unchecked Sendable. V2SurfaceScreenshotFrame and V2SurfaceScreenshotOutcome carry CGImage and CGRect across the main-thread callback into the worker thread. Add one sentence that states the payload is immutable after construction and is handed off exactly once.

As per coding guidelines: "Do not mark shared mutable reference types as Sendable unless they use actor isolation, MainActor isolation, a lock with a documented rationale, or @unchecked Sendable with a clear safety explanation."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/TerminalController`+SurfaceScreenshot.swift around lines 29 - 41,
Document the `@unchecked` Sendable rationale for V2SurfaceScreenshotFrame and
V2SurfaceScreenshotOutcome, stating that their payload is immutable after
construction and handed off exactly once between the main-thread callback and
worker thread.

Source: Coding guidelines

Comment on lines +100 to +111
let outputURL: URL
if !explicitPath.isEmpty {
outputURL = URL(fileURLWithPath: (explicitPath as NSString).expandingTildeInPath)
} else {
let outputDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-screenshots")
try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
let timestampMs = Int(Date().timeIntervalSince1970 * 1000)
let shortId = String(UUID().uuidString.prefix(8))
let base = label.isEmpty ? "surface" : label
outputURL = outputDir.appendingPathComponent("\(base)-\(timestampMs)-\(shortId).png")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Sanitize label before you use it in the output filename.

label comes straight from the socket request and is interpolated into the file name. A label such as ../../evil or a/b escapes cmux-screenshots and writes the PNG to an arbitrary path. explicitPath has the same property, but that parameter is explicitly a caller-chosen destination; label is not. Restrict label to a safe character set, or reject any label that contains a path separator or ...

🛡️ Proposed fix
-            let base = label.isEmpty ? "surface" : label
+            let safeLabel = label.filter { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
+            let base = safeLabel.isEmpty ? "surface" : safeLabel
             outputURL = outputDir.appendingPathComponent("\(base)-\(timestampMs)-\(shortId).png")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let outputURL: URL
if !explicitPath.isEmpty {
outputURL = URL(fileURLWithPath: (explicitPath as NSString).expandingTildeInPath)
} else {
let outputDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-screenshots")
try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
let timestampMs = Int(Date().timeIntervalSince1970 * 1000)
let shortId = String(UUID().uuidString.prefix(8))
let base = label.isEmpty ? "surface" : label
outputURL = outputDir.appendingPathComponent("\(base)-\(timestampMs)-\(shortId).png")
}
let outputURL: URL
if !explicitPath.isEmpty {
outputURL = URL(fileURLWithPath: (explicitPath as NSString).expandingTildeInPath)
} else {
let outputDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-screenshots")
try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
let timestampMs = Int(Date().timeIntervalSince1970 * 1000)
let shortId = String(UUID().uuidString.prefix(8))
let safeLabel = label.filter { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
let base = safeLabel.isEmpty ? "surface" : safeLabel
outputURL = outputDir.appendingPathComponent("\(base)-\(timestampMs)-\(shortId).png")
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/TerminalController`+SurfaceScreenshot.swift around lines 100 - 111,
Sanitize the untrusted label in the default output path branch before
interpolating it into the filename. Update the label-derived base value near
outputURL so path separators and traversal components such as “..” cannot escape
the cmux-screenshots directory, while preserving the explicitPath behavior as a
caller-selected destination.

Comment thread tests_v2/cmux.py
Comment on lines +1086 to +1098
panel: Union[str, int],
scale: Optional[float] = None,
label: str = "",
path: str = "",
timeout_s: float = 20.0,
) -> dict:
"""Ground-truth capture of one terminal surface via the renderer's own
presented IOSurface (debug.surface.screenshot). Works while the window
is occluded, on another Space, or never ordered front; never focuses or
reorders anything. Native backing scale (2x on Retina) by default;
pixels are Display P3 (the PNG is tagged accordingly)."""
sid = self._resolve_surface_id(panel)
params: Dict[str, Any] = {"surface_id": sid}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject an invalid panel before resolving the surface.

_resolve_surface_id(None) selects the focused surface. An empty string returns None. As written, surface_screenshot(None) can capture the wrong surface, and an empty panel sends "surface_id": null.

Reject missing or empty panels before calling _resolve_surface_id.

Proposed fix
     ) -> dict:
         """Ground-truth capture of one terminal surface via the renderer's own
         presented IOSurface (debug.surface.screenshot). Works while the window
         is occluded, on another Space, or never ordered front; never focuses or
         reorders anything. Native backing scale (2x on Retina) by default;
         pixels are Display P3 (the PNG is tagged accordingly)."""
+        if panel is None:
+            raise cmuxError("surface_screenshot requires a surface")
         sid = self._resolve_surface_id(panel)
+        if not sid:
+            raise cmuxError(f"Invalid surface: {panel!r}")
         params: Dict[str, Any] = {"surface_id": sid}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests_v2/cmux.py` around lines 1086 - 1098, Validate panel at the start of
surface_screenshot before calling _resolve_surface_id: reject None and
empty-string values, while preserving valid string and integer panel handling.
Ensure invalid input exits through the existing validation/error convention
rather than passing a null surface_id or resolving the focused surface.

Comment on lines +173 to +175
if os.path.realpath(args.socket) == "/tmp/cmux-debug.sock":
print("FAIL: refusing to run against the user's default socket", file=sys.stderr)
return 2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

The default-socket guard never fires on macOS.

os.path.realpath resolves symlinks. On macOS /tmp is a symlink to /private/tmp, so os.path.realpath("/tmp/cmux-debug.sock") returns /private/tmp/cmux-debug.sock. That value never equals the literal /tmp/cmux-debug.sock, so the script proceeds against the user's own instance. The script then sends a sleep 300 and a ctrl-c into a live pane. Resolve both sides before you compare.

🐛 Proposed fix
-    if os.path.realpath(args.socket) == "/tmp/cmux-debug.sock":
+    if os.path.realpath(args.socket) == os.path.realpath("/tmp/cmux-debug.sock"):
         print("FAIL: refusing to run against the user's default socket", file=sys.stderr)
         return 2
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if os.path.realpath(args.socket) == "/tmp/cmux-debug.sock":
print("FAIL: refusing to run against the user's default socket", file=sys.stderr)
return 2
if os.path.realpath(args.socket) == os.path.realpath("/tmp/cmux-debug.sock"):
print("FAIL: refusing to run against the user's default socket", file=sys.stderr)
return 2
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests_v2/test_offscreen_surface_screenshot.py` around lines 173 - 175, Update
the default-socket guard in the offscreen screenshot test to compare resolved
paths on both sides, resolving the literal default socket path as well as
args.socket. Preserve the existing refusal message and return code when the
paths identify the same socket.

…-screenshot

# Conflicts:
#	Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift
#	Sources/TerminalController.swift
#	ghostty

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3b1cf0f. Configure here.

Comment thread Sources/GhosttyTerminalView.swift
Comment thread Sources/TerminalController+SurfaceScreenshot.swift
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 11327-11330: Update debugCopyPresentedFrameImage() to read
contents directly from modelLayer and remove the presentation-layer fallback,
ensuring the returned image uses the acknowledged IOSurface associated with the
render request token.

In `@Sources/TerminalController`+SurfaceScreenshot.swift:
- Around line 150-157: The surface lookup in the terminal panel flow should not
require the selected tab for UUID targets. Update the logic around
resolveSurfaceId and terminalInputTarget(forPanelID:) to use
AppDelegate.shared?.locateSurface(surfaceId:) for UUID-based surface resolution,
while retaining selected-tab index aliases only where their scoped behavior is
intentional.

In `@tests_v2/test_offscreen_surface_screenshot.py`:
- Around line 254-255: Bound the readiness wait around overlay.stdout in the
offscreen surface screenshot test instead of calling blocking readline()
directly. Wait until a readiness line is available using a deadline, then fail
with a clear timeout error if Swift compilation or helper startup stalls;
preserve the existing readiness-line processing when output arrives.
- Around line 211-217: Update the shell script in the screenshot test to replace
the fixed `sleep 300` in the command built by the test with `read -r _`. Ensure
the existing Ctrl-C cleanup releases the waiting read so capture behavior
remains deterministic without a fixed-duration delay.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e604aa1b-bb55-4a48-addd-cd56e0377bd7

📥 Commits

Reviewing files that changed from the base of the PR and between 6089fa0 and 3b1cf0f.

📒 Files selected for processing (14)
  • Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift
  • Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift
  • Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift
  • Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+PresentedFrameCapture.swift
  • Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift
  • Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/TerminalController+DebugMethodNames.swift
  • Sources/TerminalController+SurfaceScreenshot.swift
  • Sources/TerminalController.swift
  • cmux.xcodeproj/project.pbxproj
  • ghostty
  • tests_v2/cmux.py
  • tests_v2/test_offscreen_surface_screenshot.py

Comment on lines +11327 to +11330
func debugCopyPresentedFrameImage() -> DebugPresentedFrameImage? {
guard let modelLayer = surfaceView.layer else { return nil }
let layer = modelLayer.presentation() ?? modelLayer
guard let contents = layer.contents else { return nil }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Read the acknowledged model-layer IOSurface.

Line 11329 prefers modelLayer.presentation(). That layer can still reference the preceding frame when the callback runs. The callback contract only guarantees that modelLayer.contents has the acknowledged IOSurface. Capture modelLayer.contents directly so the response matches the tokened render request.

Proposed fix
-        let layer = modelLayer.presentation() ?? modelLayer
-        guard let contents = layer.contents else { return nil }
+        guard let contents = modelLayer.contents else { return nil }
...
-            backingScale: max(1.0, layer.contentsScale)
+            backingScale: max(1.0, modelLayer.contentsScale)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func debugCopyPresentedFrameImage() -> DebugPresentedFrameImage? {
guard let modelLayer = surfaceView.layer else { return nil }
let layer = modelLayer.presentation() ?? modelLayer
guard let contents = layer.contents else { return nil }
func debugCopyPresentedFrameImage() -> DebugPresentedFrameImage? {
guard let modelLayer = surfaceView.layer else { return nil }
guard let contents = modelLayer.contents else { return nil }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/GhosttyTerminalView.swift` around lines 11327 - 11330, Update
debugCopyPresentedFrameImage() to read contents directly from modelLayer and
remove the presentation-layer fallback, ensuring the returned image uses the
acknowledged IOSurface associated with the render request token.

Comment on lines +150 to +157
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
finish(.failure(code: "not_found", message: "No tab selected"))
return
}
guard let panelId = resolveSurfaceId(from: surfaceArg, tab: tab),
let terminalPanel = tab.terminalInputTarget(forPanelID: panelId)?.panel else {
finish(.failure(code: "not_found", message: "Terminal surface not found"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Resolve surface_id without selected-tab bias.

This code only searches the selected tab. A valid UUID for a surface in another tab or window returns not_found, so the API cannot capture the requested occluded or inactive surface.

Resolve UUID targets through the shared structured surface locator before obtaining the terminal panel. Keep index aliases only when their selected-tab scope is intentional and documented.

Based on learnings: panel-only routes must fall back to AppDelegate.shared?.locateSurface(surfaceId:) to avoid active-window bias.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/TerminalController`+SurfaceScreenshot.swift around lines 150 - 157,
The surface lookup in the terminal panel flow should not require the selected
tab for UUID targets. Update the logic around resolveSurfaceId and
terminalInputTarget(forPanelID:) to use
AppDelegate.shared?.locateSurface(surfaceId:) for UUID-based surface resolution,
while retaining selected-tab index aliases only where their scoped behavior is
intentional.

Source: Learnings

Comment on lines +211 to +217
script = (
"clear; "
"for i in 1 2 3 4; do printf '\\e[48;2;255;0;0m%80s\\e[0m\\\\n' ''; done; "
"for i in 1 2 3 4; do printf '\\e[48;2;0;255;0m%80s\\e[0m\\\\n' ''; done; "
"for i in 1 2 3 4; do printf '\\e[48;2;0;0;255m%80s\\e[0m\\\\n' ''; done; "
f"printf '{SENTINEL}\\\\n'; printf '\\e[?25l'; sleep 300"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Replace the fixed shell sleep with an event wait.

Line 216 uses sleep 300 to hold the shell. Use read -r _ and release it with the existing Ctrl-C cleanup. This keeps capture determinism without a fixed wall-clock wait.

Proposed fix
-            f"printf '{SENTINEL}\\\\n'; printf '\\e[?25l'; sleep 300"
+            f"printf '{SENTINEL}\\\\n'; printf '\\e[?25l'; read -r _"

As per coding guidelines, “Do not use fixed sleeps” in correctness tests.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
script = (
"clear; "
"for i in 1 2 3 4; do printf '\\e[48;2;255;0;0m%80s\\e[0m\\\\n' ''; done; "
"for i in 1 2 3 4; do printf '\\e[48;2;0;255;0m%80s\\e[0m\\\\n' ''; done; "
"for i in 1 2 3 4; do printf '\\e[48;2;0;0;255m%80s\\e[0m\\\\n' ''; done; "
f"printf '{SENTINEL}\\\\n'; printf '\\e[?25l'; sleep 300"
)
script = (
"clear; "
"for i in 1 2 3 4; do printf '\\e[48;2;255;0;0m%80s\\e[0m\\\\n' ''; done; "
"for i in 1 2 3 4; do printf '\\e[48;2;0;255;0m%80s\\e[0m\\\\n' ''; done; "
"for i in 1 2 3 4; do printf '\\e[48;2;0;0;255m%80s\\e[0m\\\\n' ''; done; "
f"printf '{SENTINEL}\\\\n'; printf '\\e[?25l'; read -r _"
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests_v2/test_offscreen_surface_screenshot.py` around lines 211 - 217, Update
the shell script in the screenshot test to replace the fixed `sleep 300` in the
command built by the test with `read -r _`. Ensure the existing Ctrl-C cleanup
releases the waiting read so capture behavior remains deterministic without a
fixed-duration delay.

Source: Coding guidelines

Comment on lines +254 to +255
assert overlay.stdout is not None
ready_line = overlay.stdout.readline().strip()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bound the overlay readiness wait.

readline() can block forever if Swift compilation or helper startup stalls. Wait for stdout readiness with a deadline, then fail the test with a clear error.

Proposed fix
+import select
...
-        ready_line = overlay.stdout.readline().strip()
+        ready, _, _ = select.select([overlay.stdout], [], [], 20)
+        if not ready:
+            print("FAIL: overlay helper did not become ready", file=sys.stderr)
+            return 2
+        ready_line = overlay.stdout.readline().strip()

As per coding guidelines, completion signals must use deadline-bounded waits.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assert overlay.stdout is not None
ready_line = overlay.stdout.readline().strip()
assert overlay.stdout is not None
ready, _, _ = select.select([overlay.stdout], [], [], 20)
if not ready:
print("FAIL: overlay helper did not become ready", file=sys.stderr)
return 2
ready_line = overlay.stdout.readline().strip()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests_v2/test_offscreen_surface_screenshot.py` around lines 254 - 255, Bound
the readiness wait around overlay.stdout in the offscreen surface screenshot
test instead of calling blocking readline() directly. Wait until a readiness
line is available using a deadline, then fail with a clear timeout error if
Swift compilation or helper startup stalls; preserve the existing readiness-line
processing when output arrives.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants