Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
296ca24
Add the window recording request and frame model to CmuxFoundation
teamleaderleo Sep 28, 2026
63f25e6
Add cmux record for capturing a cmux window to mp4 or gif
teamleaderleo Sep 28, 2026
e86d476
Add failing tests for the window recorder's crash and early-stop paths
teamleaderleo Sep 28, 2026
3fcc9a3
Fix the window recorder's crash, race and early-stop paths
teamleaderleo Sep 28, 2026
a2fa353
Refuse a record flag that swallowed the next flag as its value
teamleaderleo Sep 28, 2026
a2195d1
Map an unusable recording output path to invalid_params
teamleaderleo Sep 28, 2026
a1252eb
Record a clip around dogfood tour steps
teamleaderleo Sep 28, 2026
76bdd07
Keep a dogfood recording from gating the steps it records
teamleaderleo Sep 28, 2026
0adeb5a
Keep the clip, route the tour guard, document the record rules
teamleaderleo Sep 28, 2026
360a6c8
Discard a timed-out record start and keep standalone -- in notes
teamleaderleo Sep 28, 2026
4b992e1
Merge remote-tracking branch 'upstream/main' into HEAD
teamleaderleo Sep 28, 2026
23764ff
Scope recording limits and output naming onto WindowRecordingRequest
teamleaderleo Sep 28, 2026
dc12d8f
Decode nested steps through a closure again
teamleaderleo Sep 28, 2026
69a90ad
Compare recorded pixels through an Equatable value
teamleaderleo Sep 28, 2026
c3e2328
Merge feat/agent-record-cli into the dogfood record branch
teamleaderleo Sep 28, 2026
6670e96
Fail a record step that would record nothing
teamleaderleo Sep 28, 2026
f032d90
Merge origin/main into feat/dogfood-record-step
teamleaderleo Sep 30, 2026
b9a0063
Validate recording regions and step values
teamleaderleo Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci-guards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,10 @@ jobs:
python3 tests/test_ci_pr_media.py
python3 tests/test_ci_guard_tests_emit_no_workflow_commands.py

- name: Validate dogfood tours
if: ${{ matrix.group == 'app-host-execution' }}
run: python3 tests/test_dogfood_scenarios.py

- name: Validate manual macOS package cache recovery
if: ${{ matrix.group == 'app-host-execution' }}
run: python3 tests/test_ci_manual_macos_package_cache.py
Expand Down
1 change: 1 addition & 0 deletions CLI/CMUXCLI+CommandSuggestions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ extension CMUXCLI {
"previous-window",
"read-screen",
"read-selection",
"record",
"refresh-surfaces",
"reload-config",
"remote-daemon-status",
Expand Down
231 changes: 231 additions & 0 deletions CLI/CMUXCLI+Record.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import Foundation

extension CMUXCLI {
static var recordHelp: String {
String(localized: "cli.help.record", defaultValue: """
Usage: cmux record [start] [flags]
cmux record stop|status [--id <id>]
cmux record note [--id <id>] <text>
cmux record list

Record a cmux window to an mp4 or gif, for a pull request or a bug
report. Only cmux's own windows can be recorded, and no Screen
Recording permission is involved.

Flags for start:
--format mp4|gif Output format (default: mp4)
--gif Shorthand for --format gif
--fps <1-30> Frames per second (default: 12 for mp4, 8 for gif)
--max-seconds <n> Stop by itself after n seconds, 0.5-120 (default: 15)
--scale <0.1-1> Scale the output (default: 1 for mp4, 0.5 for gif)
--max-width <64-4096> Cap the output width, keeping the aspect ratio
--region <x,y,w,h> Record part of the window, in window points
--out <path> Write here instead of a temporary directory
--label <text> Name the file
--no-captions Do not draw `record note` captions into the clip
--window <id|ref> Window to record (default: the frontmost one)

Output: `<id> <state> <frames> <path>`, or the full response with --json.

A recording stops itself at --max-seconds, so a clip is never left
running by an agent that goes away. One recording at a time.

Example:
cmux record start --gif --max-seconds 8 --label sidebar-drag
cmux record note "dragging the workspace"
cmux record stop
""")
}

static var recordUsageLine: String {
String(
localized: "cli.usage.record",
defaultValue: "record [start] [--format mp4|gif] [--gif] [--fps <n>] [--max-seconds <n>] [--scale <n>] [--max-width <n>] [--region <x,y,w,h>] [--out <path>] [--label <text>] [--no-captions] [--window <id|ref|index>] | record stop|status [--id <id>] | record note [--id <id>] <text> | record list"
)
}

func runRecordCommand(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
windowOverride: String?
) throws {
// `cmux record --gif` is `cmux record start --gif`: the subcommand is
// only a subcommand when it is not a flag.
let hasSubcommand = !(commandArgs.first?.hasPrefix("-") ?? true)
let subcommand = hasSubcommand ? commandArgs[0].lowercased() : "start"
let arguments = hasSubcommand ? Array(commandArgs.dropFirst()) : commandArgs

switch subcommand {
case "start":
try runRecordStart(
arguments: arguments,
client: client,
jsonOutput: jsonOutput,
windowOverride: windowOverride
)
case "stop", "status":
var (recordingID, trailing) = parseRecordOption(arguments, name: "--id")
if recordingID == nil, let positional = trailing.first, !positional.hasPrefix("-") {
recordingID = positional
trailing = Array(trailing.dropFirst())
}
try requireNoRecordArguments(trailing, subcommand: subcommand)
var params: [String: Any] = [:]
if let recordingID { params["id"] = recordingID }
printRecordStatus(
try client.sendV2(
method: subcommand == "stop" ? "window.record.stop" : "window.record.status",
params: params,
responseTimeout: 60
),
jsonOutput: jsonOutput
)
case "note":
let (id, parsed) = parseRecordOption(arguments, name: "--id")
// Only a leading `--` ends the options; one later in the text is
// part of the caption.
let trailing = parsed.first == "--" ? Array(parsed.dropFirst()) : parsed
let text = trailing.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
throw CLIError(message: String(
localized: "cli.record.error.noteText",
defaultValue: "record note: text is required"
))
}
var params: [String: Any] = ["text": text]
if let id { params["id"] = id }
printRecordStatus(
try client.sendV2(method: "window.record.note", params: params),
jsonOutput: jsonOutput
)
case "list":
try requireNoRecordArguments(arguments, subcommand: subcommand)
let response = try client.sendV2(method: "window.record.list")
if jsonOutput {
print(jsonString(response))
return
}
let recordings = (response["recordings"] as? [[String: Any]]) ?? []
for recording in recordings {
print(Self.recordStatusLine(recording))
}
default:
throw CLIError(message: String(
format: String(
localized: "cli.record.error.unknownSubcommand",
defaultValue: "record: unknown subcommand '%@' (start, stop, status, note, list)"
),
subcommand
))
}
}

private func runRecordStart(
arguments: [String],
client: SocketClient,
jsonOutput: Bool,
windowOverride: String?
) throws {
let (format, afterFormat) = parseRecordOption(arguments, name: "--format")
let (fps, afterFPS) = parseRecordOption(afterFormat, name: "--fps")
let (maxSeconds, afterSeconds) = parseRecordOption(afterFPS, name: "--max-seconds")
let (scale, afterScale) = parseRecordOption(afterSeconds, name: "--scale")
let (maxWidth, afterWidth) = parseRecordOption(afterScale, name: "--max-width")
let (region, afterRegion) = parseRecordOption(afterWidth, name: "--region")
let (out, afterOut) = parseRecordOption(afterRegion, name: "--out")
let (label, afterLabel) = parseRecordOption(afterOut, name: "--label")
let (window, afterWindow) = parseRecordOption(afterLabel, name: "--window")

var trailing = afterWindow
let wantsGIF = trailing.contains("--gif")
let drawsCaptions = !trailing.contains("--no-captions")
trailing.removeAll { $0 == "--gif" || $0 == "--no-captions" }
try requireNoRecordArguments(trailing, subcommand: "start")

if wantsGIF, let format, format.lowercased() != "gif" {
throw CLIError(message: String(
localized: "cli.record.error.formatConflict",
defaultValue: "record start: --gif conflicts with --format"
))
}

// Values stay as the caller typed them: the app owns the limits, so the
// CLI cannot disagree with it about what a valid frame rate is.
var params: [String: Any] = [:]
if let format { params["format"] = format } else if wantsGIF { params["format"] = "gif" }
if let fps { params["fps"] = fps }
if let maxSeconds { params["max_seconds"] = maxSeconds }
if let scale { params["scale"] = scale }
if let maxWidth { params["max_width"] = maxWidth }
if let region { params["region"] = region }
if let label { params["label"] = label }
if !drawsCaptions { params["captions"] = false }
// The app has no idea what directory the CLI was run from.
if let out { params["out"] = Self.absoluteRecordingPath(out) }
if let windowID = try normalizeWindowHandle(window ?? windowOverride, client: client) {
params["window"] = windowID
}

printRecordStatus(
try client.sendV2(
method: "window.record.start",
params: params,
responseTimeout: 45
),
jsonOutput: jsonOutput
)
}

/// A flag where a value belongs is a typo rather than a value: plain
/// `parseOption` would let `--label --gif` name the clip "--gif" and quietly
/// drop the format. Hand both back so the caller's error names them.
private func parseRecordOption(_ args: [String], name: String) -> (String?, [String]) {
let (value, remaining) = parseOption(args, name: name)
guard let value, value.hasPrefix("--") else { return (value, remaining) }
return (nil, [name, value] + remaining)
}

private func requireNoRecordArguments(_ trailing: [String], subcommand: String) throws {
guard trailing.isEmpty else {
throw CLIError(message: String(
format: String(
localized: "cli.record.error.unexpectedArguments",
defaultValue: "record %@: unexpected arguments: %@"
),
subcommand,
trailing.joined(separator: " ")
))
}
}

private func printRecordStatus(_ response: [String: Any], jsonOutput: Bool) {
if jsonOutput {
print(jsonString(response))
return
}
print(Self.recordStatusLine(response))
}

static func recordStatusLine(_ response: [String: Any]) -> String {
let id = (response["id"] as? String) ?? "-"
let state = (response["state"] as? String) ?? "-"
let frames = (response["frames"] as? Int).map(String.init) ?? "-"
let path = (response["path"] as? String) ?? "-"
var line = "\(id) \(state) \(frames) \(path)"
if let error = response["error"] as? String, !error.isEmpty {
line += " (\(error))"
}
return line
}

static func absoluteRecordingPath(_ path: String) -> String {
let expanded = NSString(string: path).expandingTildeInPath
guard !expanded.hasPrefix("/") else { return expanded }
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
.appendingPathComponent(expanded)
.standardizedFileURL
.path
}
}
1 change: 1 addition & 0 deletions CLI/CMUXCLI+TaskHelp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ extension CMUXCLI {
current-workspace [--window <id|ref|index>]
\(Self.readSelectionUsageLine)
\(Self.readScreenUsageLine)
\(Self.recordUsageLine)
sidebar-state [--workspace <id|ref|index>] [--window <id|ref|index>]
markdown [open] <path> [--focus <true|false>] (open markdown file in formatted viewer panel with live reload)
diff [patch-file|-] [--source <unstaged|staged|branch|last-turn>] [--cwd <path>] [--base <ref>] [--focus <true|false>] [--no-focus] [--title <text>] [--layout <split|unified>] [--font-size <points>] (open patch input or git source in a browser split)
Expand Down
5 changes: 5 additions & 0 deletions CLI/CMUXCLI+WindowDispatch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ extension CMUXCLI {
if normalizedCommand == "read-screen" || normalizedCommand == "read-selection" || normalizedCommand == "current" {
return false
}
// Recording films whatever is on screen; activating a window first
// would put the recording's own side effect in the clip.
if normalizedCommand == "record" {
return false
}
if normalizedCommand == "rpc",
commandArgs.first?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
== "surface.read_selection" {
Expand Down
10 changes: 10 additions & 0 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7568,6 +7568,14 @@ struct CMUXCLI {
includeContextInPlainOutput: true
)

case "record":
try runRecordCommand(
commandArgs: commandArgs,
client: client,
jsonOutput: jsonOutput,
windowOverride: windowId
)

case "read-screen":
let selectionOnly = commandArgs.contains("--selection")
if selectionOnly {
Expand Down Expand Up @@ -20491,6 +20499,8 @@ struct CMUXCLI {
return Self.readSelectionHelp
case "read-screen":
return Self.readScreenHelp
case "record":
return Self.recordHelp
case "paste":
return Self.pasteHelp
case "send":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,14 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable {
// compositor before falling back to AppKit. Keep that wait on the
// socket worker so WebKit-backed panels can render on the main actor.
"debug.window.screenshot",
// Window recording samples ScreenCaptureKit on a schedule for as long
// as the clip lasts. The sampling loop must never own the main actor:
// the window it is filming has to keep drawing.
"window.record.start",
"window.record.stop",
"window.record.status",
"window.record.note",
"window.record.list",
// debug.sidebar.simulate_drag intentionally runs on the socket worker
// so its Thread.sleep between drag-state ticks doesn't block the main
// actor (which still owns the SidebarDragState mutations via
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,20 @@ struct ControlCommandExecutionPolicyTests {
)
}

@Test func windowRecordingRunsOnTheWorkerAndIsNotMainThreadCallable() {
// A recording samples the window for as long as the clip lasts, so its
// verbs must never be callable inline on the main thread: the window
// being filmed has to keep drawing while the sampler runs.
for method in [
"window.record.start", "window.record.stop", "window.record.status",
"window.record.note", "window.record.list",
] {
let policy = ControlCommandExecutionPolicy(forMethod: method)
#expect(policy == .socketWorker(mainThreadCallable: false), "\(method)")
#expect(policy.runsOnSocketWorker, "\(method)")
}
}

@Test func v2ResolutionReadsRunOnTheWorkerAndAreMainThreadCallable() {
// Tranche D (issue #5757): the implicit handle-normalization reads.
// One controlResolveOnMain hop (refresh + witness + ref minting),
Expand Down
Loading
Loading