Skip to content

Add cmux shot for screenshotting a cmux window - #15394

Open
teamleaderleo wants to merge 19 commits into
manaflow-ai:mainfrom
teamleaderleo:feat/window-screenshot
Open

teamleaderleo wants to merge 19 commits into
manaflow-ai:mainfrom
teamleaderleo:feat/window-screenshot

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #15277: that PR's commits are the first three here, and only c0179bd134b and c55f4b1256f are new. Review those two, and merge this after #15277.

An agent that can film a window with cmux record still has no way to grab one frame of it. A clip is the wrong artifact for "what does this sheet look like right now": slower to produce, heavier to attach, and a reviewer cannot read a label off it. The screenshot command that exists today is #if DEBUG, so it is missing from every build we dogfood, which is where an agent actually runs.

cmux shot captures one of cmux's own windows to a png or a jpeg over window.screenshot, on the same ScreenCaptureKit path the recorder uses:

$ cmux shot --label settings-sheet
1512x982 214883 /var/folders/.../cmux-screenshots/settings-sheet-20260928-141233.png

$ cmux shot --region 0,0,420,900 --jpg --max-width 800 --out ~/sidebar.jpg
800x1714 61204 /Users/…/sidebar.jpg

Only cmux's own windows are captured, so no Screen Recording permission is involved and this works on a release build and inside CI.

Shared with the recorder

The two commands share every decision they could disagree about, at five layers: parameter decoding (WindowCaptureValueDecoding), crop and scale planning (WindowRecordingFrameGeometry.plan), frame capture (OwnWindowFrameCapture, lifted out of WindowRecordingSession), output-file handling (WindowCaptureOutputFile) and window resolution (TerminalController.captureWindowID). So cmux record --region 0,0,420,900 and cmux shot --region 0,0,420,900 frame the same rectangle of the same window, and a detail spotted in a clip can be shot with the same four numbers.

Two differences are deliberate. A still has no encoder demanding even dimensions, so it plans with widthQuantum: 1 and the caller gets the pixels they asked for. And a shot with nothing to crop, scale or caption skips the compose pass rather than paying a copy and the window's color space for no change.

The image is encoded beside the output path and moved into place, so an existing file there is replaced only once there is a complete image to replace it with, and --out pointed at a directory comes back as invalid_params instead of deleting it.

Relay

window.screenshot is registered as .socketWorker(mainThreadCallable: false) and is denied on the cmux ssh relay, with a test pinning both. It reads pixels of windows the remote session does not own, and there is no scoping that would make that safe, so it stays local-socket only.

Testing

cmuxTests/WindowScreenshotWriteTests.swift (new) covers the half of this that needs no window on screen: a png and a jpeg are written at the image size and identified as those types by ImageIO, jpeg --quality actually changes the file size, the output directory is created, an existing file is replaced, a directory is refused with nothing left behind, the working file is a hidden sibling of the output, and every screenshotErrorCode case is pinned so a caller cannot be sent down the wrong path by a new failure defaulting to internal_error.

Packages/macOS/CmuxFoundation/Tests/.../WindowScreenshotRequestTests.swift (new, 14 tests) pins the request limits, the format aliases and the --jpg shorthand, plus geometry and naming tests for the shared code.

python3 scripts/verify-local.py --affected origin/main: 7/7 selected checks passed (xcstrings, localization, project, wire-app-sources, test-wiring, package-groups, feature-flags). Native compilation and app tests are not in that scope and run in CI; a focused UI test is dispatched for the app-host compile, and the capture path itself needs a window server, so the dogfood evidence comes from a fleet build.

Changelog

Added: cmux shot captures a cmux window, or a region of one, to a png or a jpeg.

🤖 Generated with Claude Code


Summary by cubic

Adds cmux record and cmux shot, which capture cmux's own windows to an mp4/gif and to a single png/jpeg respectively, so an agent can attach a clip or a still of what it did. Both use ScreenCaptureKit's own-process capture, so no Screen Recording permission is involved and they work in Release builds and inside CI.

Recording and shots

  • record films a window or region, note adds captions drawn into later frames, and one recording runs at a time and stops itself at --max-seconds.
  • shot writes one frame as a png or jpeg; both commands share decoding, geometry planning, capture, and output-file handling, so the same --region numbers frame the same rectangle.
  • The recorder's fixes cover NaN/overflow traps, gifs that stop early, appends racing the close, abandoned starts, and output paths that only ever receive a complete file.
  • A shot region anchored at the window's origin is now cropped as asked instead of writing the whole window, and a timed-out screenshot no longer writes its file.

Limits and safety

  • Both methods run on the socket worker (not main-thread callable) and are denied on the cmux ssh relay; request limits, error codes, and encoded formats are pinned by tests.

Written for commit 3ba7009. Summary will update on new commits.

Review in cubic

teamleaderleo and others added 11 commits September 28, 2026 02:12
A clip needs its parameters settled before any capture starts: the format
and its default frame rate, the scale and width caps, the crop rectangle in
window points, the file name, and how the frame size follows a window that
is resized mid-clip. None of that needs a window or a screen, so it lives in
CmuxFoundation with tests instead of inside the capture session.

WindowRecordingFrameGeometry aspect-fits each captured image into the frame
size chosen at the start, so a resize letterboxes rather than stretches, and
a crop is adopted once and then kept for the rest of the clip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An agent working in its own cmux can describe what it changed, but it cannot
show it. `cmux record start` films a cmux window, or a region of one, and
writes an mp4 or a gif that can go straight into a pull request. `cmux record
note` drops a caption into the clip as it runs, so a reader can follow what
was being done.

Capture uses ScreenCaptureKit's own-process content, so only cmux's own
windows are reachable and no Screen Recording permission is asked for.
Encoding is AVAssetWriter for mp4 and ImageIO for gif; frame times come from
when each frame was sampled, so playback matches what happened. A recording
stops itself at --max-seconds, and one runs at a time, so an agent that goes
away cannot leave a capture running.

The window.record.* socket methods run on the socket worker and are not
main-thread callable: the window being filmed has to keep drawing while the
sampler runs. They are deliberately absent from the remote relay allowlist,
which stays default-deny, because a clip is local screen content rather than
an object of the peer's workspace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A review of the recorder found seven defects the existing tests missed.
These are the reds, before the fixes:

- `--fps nan`, `--fps inf` and `--fps 1e30` reach `Int(Double)` in the
  request decoder and trap, which takes the whole app down with the
  socket. The package suite now crashes with "Double value cannot be
  converted to Int" instead of finishing.
- a gif whose recording stopped long before its frame budget cannot be
  finalized, because the budget is handed to ImageIO as the number of
  images the file will contain, so the documented `--gif` flow leaves no
  file at all.
- `record stop` after a clip reached its own `--max-seconds` limit
  reported "no recording is running" rather than the finished clip.
- an unknown recording id, and a note for a clip that already stopped,
  had no coverage at all.
- the mp4 writer's no-frames path was not checked for leaving a stub
  file behind, and the two-frames-one-tick test only asserted a nonzero
  duration rather than the timestamps it exists to pin down.
- `fit` upscales a window that shrank mid-clip, which its own name says
  it does not do.

`remember` on the registry stops being private so a test can set up a
finished clip without a window on screen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Seven defects a review found, in the order they bite a caller:

- `--fps nan` and `--fps 1e30` trapped in `Int(Double)` and took the app
  down. The decoder now refuses a value that is not finite or does not
  fit an `Int`.
- a gif was created with the recording's frame budget as its declared
  image count, so a clip stopped before that budget could not be
  finalized and left no file at all. The count is a floor rather than a
  cap, and how many frames a clip ends with is only known when it stops,
  so declare one and add as many as were captured.
- `stop` could close the file while an append was suspended inside the
  session actor, which is an uncatchable AVFoundation exception for the
  mp4 writer and concurrent state for the gif writer. Appends and the
  close now take turns.
- two `record start` calls could both pass the one-at-a-time check,
  because the check was followed by the await that opens the capture.
  The slot is claimed before that await.
- `record stop` after a clip reached its own `--max-seconds` limit said
  no recording was running; it reports the finished clip, as `status`
  already did.
- the mp4 writer cancelled a writer it had never started when a clip
  captured no frames, which raises rather than returns an error.
- the output file was deleted before the first frame was written, so a
  failed recording destroyed whatever was at `--out`. Frames go to a
  hidden sibling file and only move into place once the clip closes, a
  directory or device at that path is refused, and a partial file is
  cleaned up on every failure path.

Also: a window closed mid-clip reported an empty rectangle and was
captured as a one-pixel frame for the rest of the clip, so an empty
rectangle ends the recording and keeps what came before; `fit` no longer
magnifies a window that shrank mid-clip, which is what its name always
claimed; and `window.record.*` is listed in `system.capabilities` for
local callers, where the relay policy still filters it out.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`cmux record start --label --gif` named the clip "--gif" and recorded an
mp4, because the shared option parser takes whatever follows a flag. The
record command now hands a value that looks like a flag back to the
unexpected-arguments check, which names both, and the positional
recording id for `stop`/`status` no longer accepts one either.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`outputNotAFile` was added to `WindowRecordingSessionError` in the fix
commit for the output-path handling, but the router's switch over that
enum was never extended, so the app target stopped compiling:

    Sources/TerminalController+WindowRecording.swift:175:13:
      error: switch must be exhaustive

Nothing on the pull request caught it. The checks that run on a pull
request build the packages and the tooling, not the app target, so this
first appeared in a dispatched UI test run:

    https://github.com/manaflow-ai/cmux/actions/runs/36412249068

The missing case is `invalid_params`: `--out` naming a directory, a
device or anything else the recorder may not replace is the caller's
parameter, not a cmux failure, and an agent that reads `internal_error`
retries instead of fixing its flag.

The mapping had no test at all, which is why a missing case could sit
here. `recordingErrorCode(for:)` is now internal and every case of the
three error types it understands is pinned, including the unrecognized
fallback. A compile error cannot be committed as a red test first, since
the test would not build either; the build log above is the red
evidence, and the test is what keeps the codes from drifting.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`window.screenshot` takes the same region, the same scale and width caps and
the same rule about an output path as `window.record.start`, and a caller who
finds a rectangle in a clip should be able to shoot it with the same four
numbers. Written twice, the two would drift: one would round a region's edges
differently, or accept a relative path, or trap on `--max-width 1e30` where the
other refuses it.

So the decoding moves into `WindowCaptureValueDecoding`, which both requests
translate into their own public failures. The recorder's API and every message
it produces are unchanged; `WindowRecordingRequest.make` now decodes the format
first and maps the shared failure back into its own, which is why the format
still names itself in a path error.

`WindowRecordingFrameGeometry.plan` gains an overload taking a region, a scale
and a width quantum rather than a recording request, so a still can plan its
crop with the code a clip uses. H.264's even-width rounding becomes that
quantum instead of being read off the format.

`WindowRecordingLabel` takes a fallback, so a label that sanitizes away to
nothing is filed as a screenshot rather than under the word "recording", and
`WindowRecordingOutputNaming` gains the screenshot directory the DEBUG command
has always used plus a filename overload taking an extension.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A record start that timed out on the socket kept running and could
register a recording the caller had been told failed. The registry now
tracks each start by token; on timeout the socket handler abandons it
and waits for the session to release its writer and partial file before
answering. A start that is stopped or abandoned mid-capture no longer
opens a writer, and fail() releases a writer even after the session
reached a terminal state.

record note now strips only a leading -- terminator, and cli.usage.record
carries every catalog locale.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	cmux.xcodeproj/project.pbxproj
The package conventions lint rejects all-static public types, so the
limits and the default output naming become extensions on the request.
The mp4 test names its encoded clip length plainly so the determinism
check does not read it as a measured wall-clock time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An agent that films a window with `cmux record` still has no way to grab
one frame of it. A clip is the wrong artifact for "what does this sheet
look like now": it is slower to produce, heavier to attach, and a
reviewer cannot read a label off it.

`window.screenshot` captures one of cmux's own windows to a png or a
jpeg, through the same ScreenCaptureKit path the recorder uses. No
Screen Recording permission is involved, so it works on a release build
and inside CI, where the DEBUG-only `screenshot` command does not exist
at all.

The two commands share every decision they could disagree about:
parameter decoding, crop and scale planning, frame capture, output-file
handling and window resolution. `cmux record --region 0,0,420,900` and
`cmux shot --region 0,0,420,900` frame the same rectangle of the same
window, so a detail spotted in a clip can be shot with the same four
numbers.

The image is encoded beside the output path and moved into place, so an
existing file there is replaced only once there is a complete image to
replace it with, and `--out` pointed at a directory comes back as
`invalid_params` rather than deleting it.

Local socket only: `window.screenshot` is not on the `cmux ssh` relay
allowlist, since it reads pixels of windows the remote session does not
own.

## Changelog

Added: `cmux shot` captures a cmux window, or a region of one, to a png
or a jpeg.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 5 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: manaflow-ai/cmux/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 2351a1a8-6d9e-4770-b42f-7adc54ab6dce

📥 Commits

Reviewing files that changed from the base of the PR and between 0b2d3e0 and 3ba7009.

📒 Files selected for processing (43)
  • CLI/CMUXCLI+CommandSuggestions.swift
  • CLI/CMUXCLI+Record.swift
  • CLI/CMUXCLI+Screenshot.swift
  • CLI/CMUXCLI+TaskHelp.swift
  • CLI/CMUXCLI+WindowDispatch.swift
  • CLI/cmux.swift
  • Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift
  • Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift
  • Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowCapture/WindowCaptureOutputName.swift
  • Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowCapture/WindowCaptureValueDecoding.swift
  • Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowCapture/WindowScreenshotRequest+Output.swift
  • Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowCapture/WindowScreenshotRequest.swift
  • Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingCaptionTrack.swift
  • Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingFrameGeometry.swift
  • Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingLabel.swift
  • Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingRegion.swift
  • Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingRequest+Output.swift
  • Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingRequest.swift
  • Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowCaptureOutputNameTests.swift
  • Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowRecordingCaptionTrackTests.swift
  • Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowRecordingFrameGeometryTests.swift
  • Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowRecordingRequestTests.swift
  • Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WindowScreenshotRequestTests.swift
  • Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayCoreRPCPolicyTests.swift
  • Resources/Localizable.xcstrings
  • Sources/OwnWindowFrameCapture.swift
  • Sources/TerminalController+Capabilities.swift
  • Sources/TerminalController+WindowRecording.swift
  • Sources/TerminalController+WindowScreenshotMethod.swift
  • Sources/TerminalController.swift
  • Sources/WindowCaptureOutputFile.swift
  • Sources/WindowRecordingFrameComposer.swift
  • Sources/WindowRecordingFrameWriter.swift
  • Sources/WindowRecordingRegistry.swift
  • Sources/WindowRecordingSession.swift
  • Sources/WindowRecordingWindowSelection.swift
  • Sources/WindowStillImageWriter.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/WindowRecordingErrorCodeTests.swift
  • cmuxTests/WindowRecordingPipelineTests.swift
  • cmuxTests/WindowScreenshotWriteTests.swift
  • docs/cli-contract.md
  • scripts/localization-allowed-omissions.json

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.

@github-actions

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

Optional tuples are not Equatable, so the caption test did not compile.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The recorder branch moved its limits and output naming off all-static
public types, which the namespace lint rejects, and this branch had
generalized that same naming for stills. Resolve it once:
WindowCaptureOutputName holds the timestamped identifier and builds a
filename from a label plus an extension, and each request type carries
its own output directory and filename in an extension. The screenshot
limits move onto WindowScreenshotRequest the same way the recording ones
moved onto WindowRecordingRequest.

Also takes the recorder's `--` handling for record note through this
branch's shared parseCaptureOption, its discardWriter, and its Equatable
Pixel in the pipeline tests, which is what the last dispatch failed on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Catch-up merge by scripts/ci/catch_up_pr.py (RFC manaflow-ai#14631).
Merged by scripts/merge-main.sh: origin/main at 56eacd4.

Resolved conflicts:
- Resources/Localizable.xcstrings: xcstrings key-level union
- cmux.xcodeproj/project.pbxproj: union of added entries, then normalize-pbxproj.py

Catch-up-previous-head: 4d5f2a3
Catch-up-base: 56eacd4
@vercel

vercel Bot commented Sep 28, 2026

Copy link
Copy Markdown

Deployment failed for project cmux with the following error:

The provided GitHub repository does not contain the requested branch or commit reference. Please ensure the repository is not empty.

@cursor

cursor Bot commented Sep 28, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

teamleaderleo and others added 5 commits September 28, 2026 12:18
CGImageDestinationFinalize only succeeds when the destination received
exactly the image count it was created with, so a gif opened with a count
of one could not take a second frame. The gif writer now keeps each frame
as PNG data and builds the gif in finish. The mp4 timing test skips the
empty marker buffers the asset reader returns.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	cmux.xcodeproj/project.pbxproj
Four defects from the review:

- `--region 0,0,1e19,1e19` killed the app. `Int(_: Double)` traps past Int's
  range, and only fps, scale and max-width were bounded. A region is bounded
  now, and the geometry clamps rather than traps, so a request built in code
  cannot take the app down either.
- `--out` on a read-only volume answered `internal_error`. The writer's own
  failures had no mapping, and opening the file is the caller's parameter.
- A stop that lands while the start is still opening the clip reported "the
  recording has already finished", an event that never happened, and the start
  answered `internal_error`. Both say stopped-while-starting now, with the
  code `conflict`.
- The backpressure wait spun once its task was cancelled, which is exactly when
  a stop is waiting behind it. It uses a delay a cancelled task still waits out.

The contract also says where the frames go while a clip is open, since quitting
cmux mid-recording leaves that file behind.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`cmux shot --region 0,0,420,900` wrote the whole window. The still path
skipped composing whenever the crop offset was zero, and `cropsNothing`
only asked about the offset, so a region anchored at the window's origin
looked like "no crop" no matter how small it was.

`cropsNothing` now takes the source size and answers the question the
callers actually have, and the composer uses the same method instead of
its own three-part condition, so the still and recording paths cannot
disagree again.

A timed-out screenshot no longer writes its file: the capture task is
cancelled when the socket wait gives up, and the write is preceded by a
cancellation check, so a "timeout" reply does not land a file on top of
whatever the caller put at `out` since.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…indow-screenshot

# Conflicts:
#	Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingFrameGeometry.swift
#	Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/WindowRecording/WindowRecordingRequest.swift
#	cmux.xcodeproj/project.pbxproj
#	docs/cli-contract.md
@teamleaderleo teamleaderleo added the needs a call Finished and held for a team design or product decision (see #13742 and the gallery in #15427) label Sep 30, 2026
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Product decision pending before merge; leaving this feature open for that decision.

@azooz2003-bit azooz2003-bit reopened this Oct 6, 2026

This branch has not been deployed

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

Labels

needs a call Finished and held for a team design or product decision (see #13742 and the gallery in #15427)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants